FROM node:22-alpine AS builder

WORKDIR /app

# build:server runs `bash scripts/generate-version.sh` (set -o pipefail), and
# the alpine base ships only ash/sh — without bash the build fails with
# "bash: not found" (exit 127). The runtime stage installs bash separately.
RUN apk add --no-cache bash

COPY package.json package-lock.json* ./
COPY ui/package.json ui/package-lock.json* ./ui/
RUN npm ci --ignore-scripts
RUN npm --prefix ui ci --ignore-scripts

# noVNC スタンドアロン (vnc.html を含む Web 配布物) を取得。
# npm の @novnc/novnc は lib のみで vnc.html を含まないため、
# Browser タブの iframe 用に GitHub から tarball を取得する。
ARG NOVNC_VERSION=1.6.0
# Pinned SHA256 of the v${NOVNC_VERSION} source tarball. Verify the integrity of
# the download before extracting so a compromised/MITM'd tarball can't inject
# code into the image. To bump NOVNC_VERSION, recompute via:
#   curl -fSL "https://github.com/novnc/noVNC/archive/refs/tags/v<ver>.tar.gz" | sha256sum
ARG NOVNC_SHA256=5066103959ef4e9b10f37e5a148627360dd8414e4cf8a7db92bdbd022e728aaa
RUN apk add --no-cache --virtual .novnc-fetch curl tar \
    && mkdir -p /app/vendor/noVNC \
    && curl -fSL "https://github.com/novnc/noVNC/archive/refs/tags/v${NOVNC_VERSION}.tar.gz" -o /tmp/novnc.tar.gz \
    && echo "${NOVNC_SHA256}  /tmp/novnc.tar.gz" | sha256sum -c - \
    && tar -xz -C /app/vendor/noVNC --strip-components=1 -f /tmp/novnc.tar.gz \
    && rm -f /tmp/novnc.tar.gz \
    && test -f /app/vendor/noVNC/vnc.html \
    && apk del .novnc-fetch

COPY tsconfig.json ./
COPY src ./src
COPY ui ./ui
# build:server runs scripts/generate-version.sh; build:ui runs
# ../scripts/validate-help-docs.mjs — both live under scripts/, so the build
# context needs it (without this: "scripts/generate-version.sh: No such file", exit 127).
COPY scripts ./scripts
RUN npm run build:server
RUN npm run build:ui

# Runtime is Debian (NOT Alpine): the noVNC headed-browser stack needs glibc.
# Playwright's bundled Chromium does not run on Alpine/musl, and Xvfb/x11vnc/
# websockify + Chromium are well-supported on Debian. The builder stage stays
# Alpine because it only emits portable artifacts (dist/, ui/dist, vendor/).
FROM node:22-bookworm-slim AS runtime

# System deps:
#  - git/ca-certificates/tzdata/bash: app + git ops + the bash sandbox shell
#  - bubblewrap/python3/python3-pip: sandboxed bash + pre-baked python tools
#  - xvfb/x11vnc/websockify: the noVNC display stack (display_mode: novnc) that
#    powers the Browser tab live view, InteractiveBrowse, and the CAPTCHA pool
#  - fonts-*: legible text (incl. CJK) in the headed browser / screenshots
#  - libreoffice-impress: headless .pptx/.ppt -> PDF conversion for the file-
#    preview feature (office-preview.ts). The PDF is then rasterised to PNG via
#    the pre-baked PyMuPDF (fitz). --no-install-recommends keeps the image lean
#    (skips the GUI/java extras); CJK glyphs come from the fonts-noto-cjk above.
RUN apt-get update && apt-get install -y --no-install-recommends \
    git \
    ca-certificates \
    tzdata \
    bash \
    bubblewrap \
    python3 \
    python3-pip \
    xvfb \
    x11vnc \
    websockify \
    fonts-liberation \
    fonts-noto-cjk \
    libreoffice-impress \
    && rm -rf /var/lib/apt/lists/*

# Pre-bake python packages into the system site-packages (read-only bind-mounted
# into every bash sandbox). Runtime `pip install` is intentionally unsupported.
COPY runtime/python-requirements.txt /tmp/python-requirements.txt
RUN pip3 install --no-cache-dir --break-system-packages -r /tmp/python-requirements.txt \
    && rm /tmp/python-requirements.txt

WORKDIR /app

# Shared, world-readable Playwright browser cache so the non-root `node` user can
# launch Chromium. The browser binary is fetched explicitly below.
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright

COPY package.json package-lock.json* ./
# build-essential compiles better-sqlite3's native addon (removed afterward to
# stay lean). `playwright install --with-deps chromium` downloads the Chromium
# binary INTO PLAYWRIGHT_BROWSERS_PATH and installs its shared-library apt deps
# in one step. NOTE: `npm ci` alone does NOT download the browser — Playwright
# dropped the npm-install postinstall browser download, so relying on it left
# /ms-playwright absent and the chmod below failing on a clean build (issue
# #528). chmod makes the browser readable by the non-root `node` user.
#
# We invoke the CLI as `node node_modules/playwright/cli.js` rather than
# `npx playwright`: prod `playwright` and dev `@playwright/test` both declare a
# `playwright` bin, and lockfile generation resolves that collision in favour of
# `@playwright/test`. That ownership is baked into package-lock.json, so even a
# CLEAN `npm ci --omit=dev` (with @playwright/test absent) does NOT relink
# node_modules/.bin/playwright to the prod package — the bin is simply missing
# and `npx playwright` fails with exit 127 (issue #007). Calling the package's
# own cli.js sidesteps bin resolution entirely and is version-matched to the
# installed prod `playwright`.
RUN apt-get update \
    && apt-get install -y --no-install-recommends build-essential \
    && npm ci --omit=dev \
    && node node_modules/playwright/cli.js install --with-deps chromium \
    && chmod -R go+rX /ms-playwright \
    && npm cache clean --force \
    && apt-get purge -y build-essential \
    && apt-get autoremove -y \
    && rm -rf /var/lib/apt/lists/*

COPY --from=builder /app/dist ./dist
COPY --from=builder /app/ui/dist ./ui/dist
COPY --from=builder /app/vendor ./vendor
COPY pieces ./pieces
COPY docs ./docs
# Browser setup wizard shares its pure LLM logic (probe / parse / worker build /
# isLlmConfigured) with the CLI wizard via this single .mjs. The server imports
# it at runtime as `../../scripts/setup-lib.mjs` (resolves to /app/scripts from
# /app/dist/bridge), so it MUST ship in the runtime image — without it the setup
# endpoints 500 on first `docker compose up` (Codex P2 #7). The .d.mts is
# build-time only and intentionally omitted.
COPY scripts/setup-lib.mjs ./scripts/setup-lib.mjs

# Ship config.yaml.example as a REFERENCE, plus a MINIMAL unconfigured
# config.yaml. A fresh `docker compose up` (no ./config.yaml bind-mount, no
# OLLAMA_* env) must detect "no LLM configured" and show the browser setup
# wizard — baking the fully-worked example (which has an enabled localhost
# worker) would mark the install "configured", suppress the wizard, and leave
# the user pointed at a localhost Ollama that isn't running. Mounting
# ./config.yaml or setting OLLAMA_BASE_URL/OLLAMA_MODEL overrides this default
# and skips the wizard.
COPY config.yaml.example ./config.yaml.example
RUN printf 'config_version: 2\n' > ./config.yaml

# The app runs as the non-root `node` user and writes its state under ./data
# (db, users, skills, secrets) — relative to WORKDIR /app, i.e. /app/data — plus
# /workspaces (worktree) and config.yaml (Settings save). Create and own those
# so a fresh deploy doesn't hit EACCES. /app/data and /workspaces are the volume
# mount points in docker-compose.
RUN mkdir -p /app/data /workspaces \
    && chown -R node:node /app/data /workspaces config.yaml

# HOST=0.0.0.0 is required INSIDE the container so the published port is
# reachable. docker-compose.yml publishes that port on ALL host interfaces by
# default, so a Docker deployment is reachable from the LAN out of the box —
# enabling auth/TLS before exposing it is the operator's responsibility (see
# docs/docker.md, SECURITY.md). The app's own default (when HOST is unset, e.g.
# bare-metal) is 127.0.0.1; see createCoreServer in src/bridge/server.ts.
ENV NODE_ENV=production \
    PORT=9876 \
    HOST=0.0.0.0 \
    DB_PATH=/app/data/maestro.db

EXPOSE 9876

USER node

CMD ["node", "dist/main.js"]
