# syntax=docker/dockerfile:1.7
#
# Repo-root Dockerfile. Build context is the repo root so we can bundle the
# middleware TypeScript app under `middleware/` together with its runtime
# assets (plugin manifests, builder boilerplate, entity registry).

# --- builder ----------------------------------------------------------------
# Debian-slim instead of alpine because some transitive dependencies of
# botbuilder (@azure/msal-node family) have post-install hooks that break on
# Alpine's musl libc without extra build tools. Slim keeps the image small
# while staying on glibc.
FROM node:22.23.2-slim AS builder
ARG TARGETARCH
WORKDIR /app

# Install ALL deps (incl. dev) so tsc can run. Workspace packages must be
# present BEFORE `npm ci` so the `workspaces: ["packages/*"]` entries in
# package.json resolve — otherwise the @omadia/* symlinks in node_modules
# would be missing and imports break at build/runtime.
COPY middleware/package.json middleware/package-lock.json ./
COPY middleware/packages ./packages
# preinstall hook from package.json requires this file; copy it BEFORE
# `npm ci` so the hook can resolve. Full `scripts/` overlay follows after.
COPY middleware/scripts/check-node-version.mjs ./scripts/check-node-version.mjs
# sharp ships pre-built native binaries per CPU arch. The `npm ci` above
# already pulls the right one when the host arch matches the target — for
# cross-platform builds (e.g. Apple Silicon building a linux-amd64 image)
# we re-install with the explicit --os/--cpu flags so the correct binary
# lands. arm64 / x64 covers every realistic container host today. Docker's
# TARGETARCH uses `amd64`; npm's `--cpu` uses `x64` — translate once here.
# `--ignore-scripts` is load-bearing, not hardening theatre: better-sqlite3 v13
# ships N-API prebuilds and declares `gypfile: false`, but `npm ci` ignores that
# field (unlike `npm install`) and runs `node-gyp rebuild` anyway, which fails on
# slim because there is no Python. Reproduced on npm 10, 11 and 12.
# Skipping scripts lets the bundled prebuild be used. The two packages that
# genuinely need their install script are rebuilt explicitly afterwards; both
# fetch prebuilt binaries, so no compiler is required either.
# npm's registry fetches are retried harder than the default (2 attempts, short
# backoff) because a dropped connection here fails the whole image build: the
# web-ui edge image died on `npm error code ECONNRESET` mid-`npm ci` while the
# very same commit built fine in the other pipeline minutes later. Retrying in
# npm is strictly cheaper than re-running a Docker job by hand, and it applies
# to `npm rebuild` / `npm install` in this stage too.
ENV NPM_CONFIG_FETCH_RETRIES=5 \
    NPM_CONFIG_FETCH_RETRY_MINTIMEOUT=20000 \
    NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT=120000
RUN NPM_CPU=$(case "${TARGETARCH:-amd64}" in arm64) echo arm64;; *) echo x64;; esac) \
 && npm ci --ignore-scripts --no-audit --no-fund \
 && npm rebuild argon2 esbuild \
 && npm install --no-save --include=optional --os=linux --cpu="${NPM_CPU}" sharp

COPY middleware/tsconfig.json ./
COPY middleware/src ./src
COPY middleware/scripts ./scripts
RUN npm run build

# --- runtime ----------------------------------------------------------------
FROM node:22.23.2-slim AS runtime
ARG TARGETARCH
WORKDIR /app

ENV NODE_ENV=production
ENV PORT=8080
# Build identity (#432). Stamped by .github/workflows/publish-images.yml —
# `vX.Y.Z` on a release build, `edge`/`sha-<short>` otherwise. `/health` and the
# admin update page report this; the updater sidecar's health gate polls it to
# confirm the new image is actually serving before it stops rolling back.
# Left empty for a local build, which then honestly reports `unknown` rather
# than repeating package.json's long-stale 0.2.0.
ARG OMADIA_VERSION=
ENV OMADIA_VERSION=${OMADIA_VERSION}
# Optional skills directory. Per-domain SKILL.md bundles ship here when a
# deployment carries domain-specific runtime prompts. Empty by default.
ENV SKILLS_DIR=/app/skills
# Plugin manifests are bundled at a fixed path; without this the loader's
# REPO_ROOT heuristic resolves to `/` (see manifestLoader.ts for the why).
ENV PLUGIN_MANIFEST_DIR=/app/plugin-manifests

# `gosu` lets the entrypoint drop from root to the `node` user after the volume
# chown step. ~2 MB, single static binary.
# `zip` is required by the boilerplate's scripts/build-zip.mjs which the
# Builder pipeline spawns to package generated agents.
RUN apt-get update \
 && apt-get install -y --no-install-recommends gosu zip \
 && rm -rf /var/lib/apt/lists/*

# Subscription-CLI backends (#309): optionally bundle the official Claude CLI so
# a single-operator self-host can run agents on a subscription (Claude Pro/Max)
# instead of a metered API key. The CLI detector surfaces it in the admin UI.
# Default OFF for public images — redistributing a proprietary vendor CLI needs
# a legal review; the local/dev compose enables it via build arg. Login is still
# per-host (`claude auth login`), never baked into the image.
ARG INSTALL_SUBSCRIPTION_CLIS=false
ARG CLAUDE_CODE_VERSION=2.1.187
RUN if [ "${INSTALL_SUBSCRIPTION_CLIS}" = "true" ]; then \
      npm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \
   && npm cache clean --force; \
    fi
# Subscription-CLI credentials live on the persisted data volume (not the
# ephemeral image home), so a `claude auth login` survives container recreate.
# The detector + login flow both honor CLAUDE_CONFIG_DIR.
ENV CLAUDE_CONFIG_DIR=/data/claude-cli

COPY middleware/package.json middleware/package-lock.json ./
# S+11+ workspace packages: pull from the builder stage (with compiled dist/ per
# package). Must be in place BEFORE `npm ci --omit=dev` so npm can
# create the @omadia/* plugin symlinks in node_modules (at runtime,
# the compiled imports `@omadia/orchestrator` etc. resolve directly
# through those symlinks). Per-package source/.ts files are harmlessly
# copied along (image size ~MB-range, no security concern since internal).
COPY --from=builder /app/packages ./packages
# preinstall hook from package.json requires this file (Node-version guard).
COPY middleware/scripts/check-node-version.mjs ./scripts/check-node-version.mjs
# See the builder stage for why `--ignore-scripts` + an explicit rebuild is
# required here (better-sqlite3 v13 + `npm ci` ignoring `gypfile: false`).
# esbuild is dev-only, so this stage rebuilds argon2 alone.
# Same registry-retry hardening as the builder stage above — see the note there.
ENV NPM_CONFIG_FETCH_RETRIES=5 \
    NPM_CONFIG_FETCH_RETRY_MINTIMEOUT=20000 \
    NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT=120000
RUN NPM_CPU=$(case "${TARGETARCH:-amd64}" in arm64) echo arm64;; *) echo x64;; esac) \
 && npm ci --omit=dev --ignore-scripts --no-audit --no-fund \
 && npm rebuild argon2 \
 && npm install --no-save --include=optional --os=linux --cpu="${NPM_CPU}" sharp \
 && npm cache clean --force

COPY --from=builder /app/dist ./dist
# Non-TS prompt assets — tsc skips .md, so copy them next to the compiled
# loader (BUILDER_PROMPT_PATH expects them in dist/plugins/builder/prompts).
COPY middleware/src/plugins/builder/prompts ./dist/plugins/builder/prompts
# Routines migrations — tsc skips .sql, so copy them next to the compiled
# migrator (runRoutineMigrations resolves the dir relative to its own URL).
COPY middleware/src/plugins/routines/migrations ./dist/plugins/routines/migrations
# Auth migrations (OB-49a) — same reason as routines: tsc doesn't bundle
# .sql, so the runtime needs them copied next to the compiled migrator.
COPY middleware/src/auth/migrations ./dist/auth/migrations
# Profile-storage migrations — runProfileStorageMigrations scans this dir
# at boot; tsc skips .sql so the .sql files need explicit COPY.
COPY middleware/src/profileStorage/migrations ./dist/profileStorage/migrations
# Profile-snapshots migrations — same pattern (palaia-phase profile snapshots).
COPY middleware/src/profileSnapshots/migrations ./dist/profileSnapshots/migrations
# Conductor migrations (Spec 005) — tsc skips .sql, so copy them next to the
# compiled migrator (runConductorMigrations scans dist/conductor/migrations).
COPY middleware/src/conductor/migrations ./dist/conductor/migrations
# Multi-orchestrator runtime migrations — runMultiOrchestratorMigrations
# (in @omadia/orchestrator) scans this dir. Top-level location matches the
# spec convention (specs/001-multi-orchestrator-runtime/data-model.md); the
# runner's defaultMigrationsDir resolves ../../../../migrations relative to
# the compiled migrator.js, which lands here in the Docker layout.
COPY middleware/migrations ./migrations
# Graph services migrations — same pattern (KG schema bootstrap helpers).
# Graph migrations now live inside the @omadia/knowledge-graph-neon plugin
# package (consolidated into a single ordered series). The plugin's
# copy-sql-assets.mjs build step puts them under dist/migrations/, which is
# bundled via the `COPY --from=builder /app/packages` step above.
# Seed files are bundled with the image so a fresh volume gets primed on first boot.
COPY middleware/seed ./seed
# Plugin manifests directory. The loader reads `<plugin-id>.manifest.yaml`
# files here at boot to register external plugins without going through the
# ZIP upload flow. Empty by default (ships with a README only).
COPY middleware/plugin-manifests ./plugin-manifests
# Bootstrap profiles — profileLoader.ts reads the curated plugin stacks
# (production / minimal-dev / blank) here. Without this path the onboarding
# modal cannot offer profile templates.
COPY middleware/profiles ./profiles
ENV PROFILES_DIR=/app/profiles
# Plugin-Builder boilerplate — boilerplateSource.ts reads the template files
# for the codegen engine here. Whole-tree COPY so both templates
# (agent-integration, agent-pure-llm) land in the image; BUILDER_BOILERPLATE_DIR
# points at the root, individual templates are resolved via templateId.
COPY middleware/assets/boilerplate ./boilerplate
ENV BUILDER_BOILERPLATE_DIR=/app/boilerplate
# Entity-Registry (Builder vocabulary) — entityVocabulary.ts reads this yaml
# at boot. Without the env var the dist-relative walk would target a docs/
# path that doesn't ship in the public release.
COPY middleware/assets/entity-registry.v1.yaml ./entity-registry.v1.yaml
ENV BUILDER_ENTITY_REGISTRY_PATH=/app/entity-registry.v1.yaml
# Plugin-UI assets — the generated design-system stylesheet plugin UIs link
# (`/api/_harness/plugin-ui.css`) plus any @font-face sources. Generated by
# `web-ui/scripts/build-plugin-ui-css.mjs` and committed; a missing directory
# aborts boot via verifyAssetBundles rather than 404ing at runtime.
COPY middleware/assets/plugin-ui ./plugin-ui
ENV PLUGIN_UI_ASSETS_DIR=/app/plugin-ui
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh

# Prepare /data (platform state: vault, installed.json, uploaded packages,
# builder drafts) so the first boot without a mounted volume also works.
# Also create an empty /app/skills so SKILLS_DIR points at an existing dir
# even when the deployment ships no domain skill bundles. (No /data/memory —
# memory is Postgres- or RAM-backed, there is no on-disk memory store.)
RUN mkdir -p /data /app/skills && chown -R node:node /data /app

EXPOSE 8080

# Entrypoint runs as root so it can chown the Fly volume (mounted as root:root by
# default), then drops to the `node` user via gosu.
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
