feat(FN-1226): add production Docker workflow

- Add a root .dockerignore to reduce build context and exclude local runtime state
- Add a multi-stage Dockerfile that builds the workspace, runs as non-root, and includes health checks
- Document Docker build/run usage, env vars, persistence, and quick-start commands in README and docs/docker.md
- Add Docker configuration tests in packages/cli/src/docker.test.ts to validate required image and docs expectations
This commit is contained in:
gsxdsm
2026-04-08 06:55:35 -07:00
parent ad54b0f6f9
commit 872369faed
5 changed files with 261 additions and 0 deletions

29
.dockerignore Normal file
View File

@@ -0,0 +1,29 @@
# Dependencies
node_modules/
# Build output
dist/
*.tsbuildinfo
coverage/
# Local runtime/state directories
.fusion/
.hai/
.worktrees/
.pi/
.kb/
.fusion-backup/
.fusion-backup-*/
# Mobile platform artifacts
packages/dashboard/ios/
packages/dashboard/android/
# VCS/editor/OS artifacts
.git/
.DS_Store
.idea/
.vscode/
# Release metadata not needed in image build context
.changeset/

77
Dockerfile Normal file
View File

@@ -0,0 +1,77 @@
# syntax=docker/dockerfile:1
FROM node:22-slim AS builder
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends git build-essential python3 \
&& rm -rf /var/lib/apt/lists/*
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY packages/cli/package.json ./packages/cli/package.json
COPY packages/core/package.json ./packages/core/package.json
COPY packages/dashboard/package.json ./packages/dashboard/package.json
COPY packages/desktop/package.json ./packages/desktop/package.json
COPY packages/engine/package.json ./packages/engine/package.json
COPY packages/mobile/package.json ./packages/mobile/package.json
COPY packages/tui/package.json ./packages/tui/package.json
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM node:22-slim AS runner
LABEL org.opencontainers.image.source="https://github.com/gsxdsm/fusion"
LABEL org.opencontainers.image.description="AI-orchestrated task board"
ENV NODE_ENV=production
ENV PORT=4040
RUN apt-get update \
&& apt-get install -y --no-install-recommends git \
&& rm -rf /var/lib/apt/lists/*
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
WORKDIR /project
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY packages/cli/package.json ./packages/cli/package.json
COPY packages/core/package.json ./packages/core/package.json
COPY packages/dashboard/package.json ./packages/dashboard/package.json
COPY packages/engine/package.json ./packages/engine/package.json
RUN pnpm install --frozen-lockfile --prod \
--filter @gsxdsm/fusion
# tsup rewrites node:sqlite imports to "sqlite" in the CLI bundle.
# Provide a tiny ESM shim so runtime resolves to Node's built-in sqlite module.
RUN mkdir -p /project/node_modules/sqlite \
&& printf '{"name":"sqlite","version":"0.0.0","type":"module","exports":"./index.js"}\n' > /project/node_modules/sqlite/package.json \
&& printf 'export * from "node:sqlite";\n' > /project/node_modules/sqlite/index.js
COPY --from=builder /app/packages/core/dist ./packages/core/dist
COPY --from=builder /app/packages/engine/dist ./packages/engine/dist
COPY --from=builder /app/packages/dashboard/dist ./packages/dashboard/dist
COPY --from=builder /app/packages/cli/dist ./packages/cli/dist
# @gsxdsm/fusion references @sinclair/typebox at runtime via the bundled CLI.
COPY --from=builder /app/node_modules/.pnpm/@sinclair+typebox@*/node_modules/@sinclair/typebox /project/node_modules/@sinclair/typebox
# tsup emits dynamic require() helpers; provide require in ESM context.
RUN sed -i '2i import { createRequire as __createRequire } from "node:module";\nconst require = __createRequire(import.meta.url);' /project/packages/cli/dist/bin.js
RUN chown node:node /project
USER node
EXPOSE 4040
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD node -e "fetch('http://localhost:4040/api/tasks').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
ENTRYPOINT ["node", "packages/cli/dist/bin.js"]
CMD ["dashboard"]

View File

@@ -37,6 +37,16 @@ Fusion reuses your existing pi authentication.
For Capacitor + PWA workflow, see [MOBILE.md](./MOBILE.md). For Capacitor + PWA workflow, see [MOBILE.md](./MOBILE.md).
## Docker
Quick start:
```bash
docker build -t fusion . && docker run -p 4040:4040 -v $(pwd):/project -e ANTHROPIC_API_KEY=... fusion
```
For full Docker usage (env vars, persistence volumes, and runtime options), see [docs/docker.md](./docs/docker.md).
## Workflow ## Workflow
```mermaid ```mermaid

80
docs/docker.md Normal file
View File

@@ -0,0 +1,80 @@
# Running Fusion in Docker
This guide shows how to build and run Fusion in a container.
## Build the image
```bash
docker build -t fusion .
```
## Run the dashboard
Mount your project into `/project` and publish the dashboard port:
```bash
docker run -p 4040:4040 -v /path/to/project:/project fusion
```
By default, the container runs:
```bash
fn dashboard
```
on port `4040`.
## Environment variables
Pass provider credentials and integrations with `-e` flags:
```bash
-e ANTHROPIC_API_KEY=...
-e OPENAI_API_KEY=...
-e GITHUB_TOKEN=...
```
Add any other provider keys your setup requires (for example `OPENROUTER_API_KEY`).
## Pass additional CLI flags
You can append normal CLI arguments after the image name:
```bash
docker run fusion dashboard --port 8080
```
If you change the dashboard port, also update Docker port mapping:
```bash
docker run -p 8080:8080 fusion dashboard --port 8080
```
## Persistence
Fusion state lives in `.fusion` under the mounted project. You can mount it explicitly:
```bash
docker run -p 4040:4040 \
-v /path/to/project:/project \
-v /path/to/project/.fusion:/project/.fusion \
fusion
```
## Complete example
```bash
docker run --rm \
-p 4040:4040 \
-v /path/to/project:/project \
-v /path/to/project/.fusion:/project/.fusion \
-e ANTHROPIC_API_KEY=your_key \
-e OPENAI_API_KEY=your_key \
-e GITHUB_TOKEN=your_token \
fusion dashboard --port 4040
```
## Notes
- The container runs as the non-root `node` user.
- `git` must be available in the project volume for worktree operations (`.git` metadata and repository history are required).

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
const workspaceRoot = resolve(import.meta.dirname, "../../..");
const dockerfilePath = resolve(workspaceRoot, "Dockerfile");
const dockerignorePath = resolve(workspaceRoot, ".dockerignore");
const dockerDocsPath = resolve(workspaceRoot, "docs", "docker.md");
describe("Docker configuration", () => {
it("has a Dockerfile with required production instructions", () => {
expect(existsSync(dockerfilePath)).toBe(true);
const dockerfile = readFileSync(dockerfilePath, "utf8");
expect(dockerfile).toContain("FROM node:22");
expect(dockerfile).toContain("ENTRYPOINT");
expect(dockerfile).toContain("USER node");
expect(dockerfile).toContain("HEALTHCHECK");
expect(dockerfile).toContain("EXPOSE 4040");
expect(dockerfile).toContain("CMD");
});
it("uses a multi-stage Docker build", () => {
const dockerfile = readFileSync(dockerfilePath, "utf8");
const fromInstructions = dockerfile.match(/^FROM\s+/gm) ?? [];
expect(fromInstructions.length).toBeGreaterThanOrEqual(2);
});
it("installs git and uses deterministic pnpm installs", () => {
const dockerfile = readFileSync(dockerfilePath, "utf8");
expect(dockerfile).toMatch(/apt-get[^\n]*install[^\n]*git/);
expect(dockerfile).toContain("pnpm install --frozen-lockfile");
});
it("has a .dockerignore with required exclusions", () => {
expect(existsSync(dockerignorePath)).toBe(true);
const dockerignore = readFileSync(dockerignorePath, "utf8");
expect(dockerignore).toContain("node_modules/");
expect(dockerignore).toContain(".git/");
expect(dockerignore).toContain("dist/");
expect(dockerignore).toContain(".fusion/");
});
it("does not exclude package manifests needed for install", () => {
const dockerignore = readFileSync(dockerignorePath, "utf8");
const dockerignoreLines = dockerignore
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith("#"));
expect(dockerignoreLines).not.toContain("package.json");
expect(dockerignoreLines).not.toContain("pnpm-lock.yaml");
});
it("has docker documentation for build, run, and environment variables", () => {
expect(existsSync(dockerDocsPath)).toBe(true);
const docs = readFileSync(dockerDocsPath, "utf8").toLowerCase();
expect(docs).toContain("build");
expect(docs).toContain("run");
expect(docs).toContain("environment variables");
});
});