# ── Stage 1: Install dependencies ────────────────────────────── FROM node:24-alpine AS deps WORKDIR /app COPY package.json package-lock.json ./ COPY packages/engine/package.json packages/engine/ # Create a minimal package.json for web to satisfy workspace resolution RUN mkdir -p packages/web && echo '{"name":"@polyant/web","version":"1.3.5","private":true}' > packages/web/package.json RUN npm ci --workspace=@polyant/engine --include-workspace-root # Ensure workspace node_modules dir exists (even if empty) for COPY RUN mkdir -p packages/engine/node_modules # ── Stage: prod-deps (runtime deps only — no devDeps) ────────── # Separate prod-only install so the runtime image doesn't carry the dev tooling # (typescript, tsx, eslint, vitest, drizzle-kit, @types/*): the entrypoint runs # `node dist/...` (not tsx) and migrations use drizzle-orm (not drizzle-kit). # `npm prune --omit=dev` on the hoisted workspace tree is fragile → install prod # from scratch instead. BuildKit runs this stage in parallel with `build`, and # Docker layer caching skips it entirely when package-lock is unchanged. # @polyant-ai/plugin-sdk is an engine `dependency` (the core *.tool.ts author it), # so it survives here. FROM node:24-alpine AS prod-deps WORKDIR /app COPY package.json package-lock.json ./ COPY packages/engine/package.json packages/engine/ RUN mkdir -p packages/web && echo '{"name":"@polyant/web","version":"1.3.5","private":true}' > packages/web/package.json RUN npm ci --omit=dev --workspace=@polyant/engine --include-workspace-root RUN mkdir -p packages/engine/node_modules # ── Stage: plugins (self-contained per-plugin build) ─────────── # Plugins baked into the image live in packages/engine/src/plugins/ (the # same convention dir the loader scans at runtime, gitignored — an operator or a # deploy pipeline drops them there before building). Each is built SELF-CONTAINED: # its own node_modules and its tools compiled TS→JS in place. --rootDir . keeps # the `tools/` prefix (a plugin tsconfig infers rootDir=tools, which would # otherwise flatten the output to the root). git is needed for the SDK's git # dependency. FROM node:24-alpine AS plugins RUN apk add --no-cache git WORKDIR /build COPY packages/engine/src/plugins/ ./plugins/ # What the plugins need from the RUNTIME IMAGE, aggregated into three plain files # the runtime stage consumes. This stage is the only one that sees every plugin # before the runtime image is built. COPY scripts/collect-plugin-system-requirements.mjs ./collect.mjs RUN node collect.mjs plugins /build/system # The declared env applies to the plugin's own BUILD too, not only to the # runtime: a plugin that points at a distro binary has to say so before its # `npm install` tries to download a private copy of one. RUN . /build/system/env.sh && \ for d in plugins/*/; do \ [ -f "$d/plugin.json" ] || continue; \ ( set -e; cd "$d"; \ npm install --no-audit --no-fund; \ tsconfig=tsconfig.json; \ if [ -f tsconfig.build.json ]; then tsconfig=tsconfig.build.json; fi; \ npx tsc -p "$tsconfig" --rootDir . --outDir . --noEmit false; \ npm prune --omit=dev; \ find . -name node_modules -prune -o -name '*.ts' -type f -exec rm -f {} + ) || exit 1; \ done # Strip the .ts sources after compiling: they sit next to the emitted .js in the # same dir, and Node 22.18+ strips types → the loader (regex /\.(tool|hook)\.(ts|js)$/) # would import BOTH and hit "Duplicate registration". Runtime only needs the .js. # ── Stage 2: Build ───────────────────────────────────────────── FROM node:24-alpine AS build WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/packages/engine/node_modules ./packages/engine/node_modules COPY package.json package-lock.json ./ COPY packages/engine/ packages/engine/ # Use tsconfig.build.json to exclude test files from compilation; the script also # copies the migration SQL next to the compiled migrate.js (dist/database/migrations). RUN npm run build:docker -w @polyant/engine # ── Stage 3: Runtime ─────────────────────────────────────────── FROM node:24-alpine AS runtime WORKDIR /app ENV NODE_ENV=production # What the ENGINE itself needs. Nothing here exists for a tool: the tool families # that shelled out to git, gh and chromium are plugins now, and each declares its # own packages in plugin.json. That is why this list is one line instead of the # page it used to be — and why an image built without the PDF plugin no longer # carries Chromium and its font stack. RUN apk add --no-cache curl ca-certificates # What the INCLUDED PLUGINS asked for, collected from their manifests in the # plugins stage. Both files exist even when empty, and `xargs -r` runs nothing on # an empty one — so a build with no plugins installs nothing extra. COPY --from=plugins /build/system/ /app/plugin-system/ RUN xargs -r apk add --no-cache < /app/plugin-system/apk.txt && \ xargs -r npm install -g --no-audit --no-fund < /app/plugin-system/npm-global.txt # Plugin env vars reach the process through the entrypoint rather than ENV: their # names are not known when this file is written. See docker-entrypoint.sh. # Copy hoisted deps + non-hoisted workspace deps (e.g. langsmith). # Runtime pulls from prod-deps (npm ci --omit=dev) — NOT deps — so the image # ships no dev tooling. The build stage above still uses deps (it needs tsc). COPY --from=prod-deps /app/node_modules ./node_modules COPY --from=prod-deps /app/packages/engine/node_modules ./packages/engine/node_modules COPY --from=build /app/packages/engine/dist ./packages/engine/dist # Baked-in plugins: self-contained (own node_modules) at the loader's convention # dir dist/plugins/. loadAllTools scans dist/plugins/* at boot; each tool's # imports resolve from the plugin's own node_modules by walk-up. COPY --from=plugins /build/plugins/ ./packages/engine/dist/plugins/ COPY packages/engine/package.json packages/engine/ COPY package.json ./ COPY docker-entrypoint.sh /usr/local/bin/ RUN chmod +x /usr/local/bin/docker-entrypoint.sh # Run as the image's unprivileged `node` user. The engine writes to two places # under /app, both created here and owned by it: the file logger's logs/ and the # per-conversation workspaces of writeFile and friends. Everything else it # writes goes to /tmp or $HOME (/home/node), already writable. The code and the # plugins stay root-owned, so the process cannot rewrite what it runs. RUN mkdir -p /app/logs /app/packages/engine/workspaces && \ chown node:node /app/logs /app/packages/engine/workspaces USER node EXPOSE 4000 # /health answers once migrations have run and the HTTP server listens; the # start period covers the migrations the entrypoint runs first. HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ CMD wget -q --spider "http://127.0.0.1:${API_PORT:-4000}/health" || exit 1 ENTRYPOINT ["docker-entrypoint.sh"]