# LifeOS — https://github.com/lifeos-app/lifeos # # Two stages: Node builds the Vite/React bundle, Python serves that bundle plus # the Flask API from a single process. Build context is the upstream source tree # (cloned to /opt/lifeos on the node); this file is copied in alongside it. # ─── Stage 1: build the frontend ────────────────────────────────────── FROM node:22-bookworm-slim AS frontend WORKDIR /build COPY package.json package-lock.json ./ # --ignore-scripts skips the electron-builder postinstall, which pulls a desktop # toolchain we never use and fails without an X stack. RUN npm ci --ignore-scripts COPY . . # Upstream is hosted under an /app base path and hardcodes it in two places. # We serve at the root, so both must be rewritten or the app renders a blank page: # - App.tsx pins React Router to basename "/app"; at "/" it matches no route and # renders NOTHING (silent -- only a console warning, no error). # - index.html points the PWA manifest and touch icons at /app/... RUN sed -i 's|basename: "/app"|basename: "/"|' src/App.tsx \ && sed -i 's|"/app/|"/|g' index.html \ && grep -q 'basename: "/"' src/App.tsx \ && ! grep -q '"/app/' index.html # initAuth() only boots the single local user for Tauri/Electron, so a browser # build in local-api mode falls through to a Supabase login screen instead -- # one the backend cannot serve (it has no /api/auth/login or /api/auth/signup # routes, being single-user by design). Treat local-api like the desktop envs. RUN sed -i "s@if (detectedEnv === 'tauri' || detectedEnv === 'electron') {@if (detectedEnv === 'tauri' || detectedEnv === 'electron' || detectedEnv === 'local-api') {@" src/stores/useUserStore.ts \ && grep -q "detectedEnv === 'local-api') {" src/stores/useUserStore.ts # The local API adapter has no realtime layer, so supabase.channel() is undefined # and every subscribing component crashes the app. Append no-op stubs. COPY local-api-realtime-shim.ts /tmp/shim.ts RUN cat /tmp/shim.ts >> src/lib/local-api.ts && rm /tmp/shim.ts # Routes the data layer (src/lib/data-access.ts) to the local Flask API instead # of Supabase Cloud. VITE_API_BASE_URL is deliberately left unset: local-api.ts # then falls back to a relative /api, so the bundle works on any host and port. ENV VITE_USE_LOCAL_API=true # Dummy Supabase values. Nothing reads Supabase in local mode, but src/lib/supabase.ts # calls createClient() at module scope, and createClient throws "supabaseUrl is # required" on an empty string -- which kills the bundle before React mounts, leaving # a blank page. Its own comment claims it only warns; it does not. A syntactically # valid dummy URL constructs fine and is never called. ENV VITE_SUPABASE_URL=https://noop.invalid ENV VITE_SUPABASE_ANON_KEY=noop ENV NODE_OPTIONS=--max-old-space-size=3072 RUN npm run build # ─── Stage 2: Flask API + built frontend ────────────────────────────── FROM python:3.12-slim WORKDIR /app COPY backend/requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt gunicorn COPY backend/ /app/ COPY serve_container.py /app/ COPY --from=frontend /build/dist /app/dist # Upstream hardcodes the author's own model name in the chat proxy, and a 30s # timeout that a cold Ollama model load (~46s for a 9.6GB model) blows through. # Make both env vars. Fails the build if upstream renames these out from under us. RUN sed -i 's/"model": "sentient-teddy"/"model": os.environ.get("AI_MODEL", "sentient-teddy")/' app.py \ && sed -i 's/timeout=30$/timeout=int(os.environ.get("AI_TIMEOUT", "120"))/' app.py \ && grep -q 'os.environ.get("AI_MODEL"' app.py \ && grep -q 'os.environ.get("AI_TIMEOUT"' app.py # app.py hardcodes its SQLite path to ~/.lifeos/data.db, so HOME is what pins # the database into the mounted volume. ENV HOME=/data RUN mkdir -p /data/.lifeos EXPOSE 8080 CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "--threads", "4", \ "--timeout", "60", "--access-logfile", "-", "serve_container:app"]