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:
29
.dockerignore
Normal file
29
.dockerignore
Normal 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
77
Dockerfile
Normal 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"]
|
||||||
10
README.md
10
README.md
@@ -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
80
docs/docker.md
Normal 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).
|
||||||
65
packages/cli/src/docker.test.ts
Normal file
65
packages/cli/src/docker.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user