/** * Runtime-neutral deploy — 3 modes: * 1. `flame deploy` → build + generate GitHub Actions workflow * 2. `flame deploy --docker` → build + generate Docker deployment files * 3. `flame deploy --docker --silent` → same as #2, minimal output * * Mirror of deploy.ts (Bun-only, protected) for Node.js and Deno. * Runs the neutral build in-process, then prepares .docu/dist. */ import { writeFile, mkdir } from "node:fs/promises"; import { existsSync, readFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { DIST_DIR, PROJECT_ROOT, FRAMEWORK_ROOT } from "./paths"; const FLAME_VERSION = JSON.parse( readFileSync(resolve(FRAMEWORK_ROOT, "package.json"), "utf-8") ).version; const WORKFLOW_DIR = join(PROJECT_ROOT, ".github/workflows"); const WORKFLOW_FILE = join(WORKFLOW_DIR, "deploy.yml"); export const NGINX_CONF = `server { listen 80; server_name _; root /usr/share/nginx/html; index index.html; error_page 404 /404.html; gzip on; gzip_types text/html text/css application/javascript image/svg+xml; # Security headers (HSTS effective when HTTPS is terminated upstream) add_header X-Frame-Options "DENY" always; add_header X-Content-Type-Options "nosniff" always; add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; font-src 'self' data:; connect-src 'self' https:; frame-src https://www.youtube-nocookie.com; frame-ancestors 'none'" always; # Build metadata and search content use stable URLs, so clients must revalidate them. location = /assets/manifest.json { expires -1; } location = /assets/search-index.json { expires -1; } # Generated JS, CSS, and chunks include content hashes in their filenames. location /assets/ { expires 1y; add_header Cache-Control "public, immutable"; add_header X-Frame-Options "DENY" always; add_header X-Content-Type-Options "nosniff" always; add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; font-src 'self' data:; connect-src 'self' https:; frame-src https://www.youtube-nocookie.com; frame-ancestors 'none'" always; } location /docs/assets/ { expires 7d; add_header Cache-Control "public"; add_header X-Frame-Options "DENY" always; add_header X-Content-Type-Options "nosniff" always; add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; font-src 'self' data:; connect-src 'self' https:; frame-src https://www.youtube-nocookie.com; frame-ancestors 'none'" always; } location = /404.html { } location / { try_files $uri $uri.html $uri/ =404; } } `; export const DOCKERIGNORE = `node_modules *.DS_Store .docu/dist .docu/lib .env .env.* .npmrc *.log `; /** Final stage shared by every generated Dockerfile: nginx serves the flat * static output produced by the builder stage. */ export const DOCKERFILE_MARKER = "# Generated by @docubook/flame deploy --docker"; const NGINX_STAGE = `FROM nginx:alpine COPY --from=builder /app/.docu/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80 CMD ["nginx", "-g", "daemon off;"] `; /** Bun projects build inside the published builder image — the CLI is already * installed globally there, so no project dependency install step is needed. */ export const DOCKERFILE_BUN = `${DOCKERFILE_MARKER} FROM ghcr.io/docubook/flame:${FLAME_VERSION} AS builder ENV NODE_ENV=production WORKDIR /app COPY . . RUN flame build --bun ${NGINX_STAGE}`; export type PkgManagerConfig = { baseImage: string; lockFile: string; installCmd: string; runCmd: string; cache: string; setupAction: string; }; /** Per-package-manager build config. `installCmd` is the locked install and * assumes `lockFile` exists — see `LOCKLESS_INSTALL` for projects without one. */ const PM_CONFIG: Record = { bun: { baseImage: "oven/bun:1-debian", lockFile: "bun.lock", installCmd: "bun install --frozen-lockfile", runCmd: "bun", cache: "", setupAction: "bun", }, pnpm: { baseImage: "node:22-alpine", lockFile: "pnpm-lock.yaml", installCmd: "corepack enable && pnpm install --frozen-lockfile", runCmd: "pnpm", cache: "pnpm", setupAction: "pnpm", }, yarn: { baseImage: "node:22-alpine", lockFile: "yarn.lock", installCmd: "yarn install --frozen-lockfile", runCmd: "yarn", cache: "yarn", setupAction: "node", }, npm: { baseImage: "node:22-alpine", lockFile: "package-lock.json", installCmd: "npm ci", runCmd: "npm", cache: "npm", setupAction: "node", }, }; /** Installs for projects with no lockfile, where `npm ci` and * `--frozen-lockfile` cannot run. */ const LOCKLESS_INSTALL: Record = { bun: "bun install", pnpm: "corepack enable && pnpm install --prod=false", yarn: "yarn install --production=false", npm: "npm install --include=dev", }; /** Build installs must include tools declared in devDependencies even though * the builder stage sets NODE_ENV=production for the eventual site build. */ function getInstallCommand(pm: PkgManagerConfig, hasLockFile: boolean): string { if (pm.runCmd === "npm") { return hasLockFile ? "npm ci --include=dev" : LOCKLESS_INSTALL.npm; } if (pm.runCmd === "pnpm" && hasLockFile) { return `${pm.installCmd} --prod=false`; } if (pm.runCmd === "yarn" && hasLockFile) { return `${pm.installCmd} --production=false`; } return hasLockFile ? pm.installCmd : LOCKLESS_INSTALL[pm.runCmd]; } /** * Detect the project's package manager. Lockfiles win — they describe what the * project actually committed. `userAgent` (the invoking tool's * `npm_config_user_agent`) is only consulted when no lockfile exists, so a * lockless project still installs with the package manager the user is running. * pnpm/yarn UA strings also contain "npm/?" — match specific tools first. */ export function detectPkgManager(dir: string, userAgent = ""): PkgManagerConfig { const bunLockFile = existsSync(join(dir, "bun.lock")) ? "bun.lock" : existsSync(join(dir, "bun.lockb")) ? "bun.lockb" : null; if (bunLockFile) return { ...PM_CONFIG.bun, lockFile: bunLockFile }; if (existsSync(join(dir, "pnpm-lock.yaml"))) return PM_CONFIG.pnpm; if (existsSync(join(dir, "yarn.lock"))) return PM_CONFIG.yarn; if (existsSync(join(dir, "package-lock.json"))) return PM_CONFIG.npm; if (/bun\//.test(userAgent)) return PM_CONFIG.bun; if (/pnpm\//.test(userAgent)) return PM_CONFIG.pnpm; if (/yarn\//.test(userAgent)) return PM_CONFIG.yarn; return PM_CONFIG.npm; } /** * Dockerfile for the project's package manager. Unlike the Bun builder image, * node-based images ship no project dependencies — without an install step the * build fails resolving imports from node_modules, so the locked dependencies * are installed before `flame` is invoked through the project's build script. * The lockfile and its frozen install are used only when the lockfile exists; * otherwise the install is derived from the invoking package manager. */ export function generateDockerfile( dir: string = PROJECT_ROOT, userAgent: string = process.env.npm_config_user_agent || "" ): string { const pm = detectPkgManager(dir, userAgent); if (pm.runCmd === "bun") return DOCKERFILE_BUN; const hasLockFile = existsSync(join(dir, pm.lockFile)); return `${DOCKERFILE_MARKER} FROM ${pm.baseImage} AS builder ENV NODE_ENV=production WORKDIR /app COPY package.json${hasLockFile ? ` ${pm.lockFile}` : ""} ./ RUN ${getInstallCommand(pm, hasLockFile)} COPY . . RUN ${pm.runCmd} run build ${NGINX_STAGE}`; } export const HEADERS_FILE = `/* X-Frame-Options: DENY X-Content-Type-Options: nosniff Strict-Transport-Security: max-age=63072000; includeSubDomains; preload Referrer-Policy: strict-origin-when-cross-origin Permissions-Policy: camera=(), microphone=(), geolocation=() Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; font-src 'self' data:; connect-src 'self' https:; frame-src https://www.youtube-nocookie.com; frame-ancestors 'none' /assets/client-* Cache-Control: public, max-age=31536000, immutable /assets/home-client-* Cache-Control: public, max-age=31536000, immutable /assets/docs-* Cache-Control: public, max-age=31536000, immutable /assets/site-* Cache-Control: public, max-age=31536000, immutable /assets/chunks/* Cache-Control: public, max-age=31536000, immutable /assets/assets/* Cache-Control: public, max-age=31536000, immutable /assets/manifest.json Cache-Control: no-cache /assets/search-index.json Cache-Control: no-cache `; const isDocker = !!process.env.FLAME_DEPLOY_DOCKER; const isSilent = !!process.env.FLAME_DEPLOY_SILENT; const isCi = !!process.env.FLAME_DEPLOY_CI; /** Logger that no-ops all non-error output in silent mode. */ const log = isSilent ? { info: () => {}, ok: () => {}, created: () => {}, out: () => {} } : { info: (m: string) => console.log(m), ok: () => console.log("\n✅ Ready to deploy!"), created: (m: string) => console.log(m), out: (m: string) => console.log(m), }; async function runBuild() { process.env.NODE_ENV = "production"; if (isSilent) { process.env.FLAME_BUILD_SILENT = "1"; process.env.LOG_LEVEL = "error"; } const { runBuildCli } = await import("./build.impl"); await runBuildCli(); } async function writeDockerFiles() { const dockerDir = PROJECT_ROOT; const dockerfilePath = join(dockerDir, "Dockerfile"); if (!existsSync(dockerfilePath)) { await writeFile(dockerfilePath, generateDockerfile(dockerDir)); log.created("📄 Created Dockerfile"); } else if (readFileSync(dockerfilePath, "utf-8").startsWith(`${DOCKERFILE_MARKER}\n`)) { await writeFile(dockerfilePath, generateDockerfile(dockerDir)); log.created("📄 Updated generated Dockerfile"); } else { log.info( "⚠️ Dockerfile already exists; skipped it. Delete it and rerun flame deploy --docker to regenerate." ); } if (!existsSync(join(dockerDir, "nginx.conf"))) { await writeFile(join(dockerDir, "nginx.conf"), NGINX_CONF); log.created("📄 Created nginx.conf"); } if (!existsSync(join(dockerDir, ".dockerignore"))) { await writeFile(join(dockerDir, ".dockerignore"), DOCKERIGNORE); log.created("📄 Created .dockerignore"); } } function generateWorkflowYml(): string { const pm = detectPkgManager(PROJECT_ROOT, process.env.npm_config_user_agent || ""); const hasLockFile = existsSync(join(PROJECT_ROOT, pm.lockFile)); const installCmd = getInstallCommand(pm, hasLockFile); const cacheLine = hasLockFile && pm.cache ? ` cache: ${pm.cache}` : ""; const setupSteps: string[] = [ " - uses: actions/checkout@v7", " with:", " fetch-depth: 0", "", ]; if (pm.setupAction === "bun") { setupSteps.push( " - uses: oven-sh/setup-bun@v2", " with:", " bun-version: latest", "" ); } else if (pm.setupAction === "pnpm") { setupSteps.push( " - uses: pnpm/action-setup@v6", "", " - uses: actions/setup-node@v6", " with:", " node-version: 22", ...(cacheLine ? [cacheLine] : []), "" ); } else { // npm / yarn setupSteps.push( " - uses: actions/setup-node@v6", " with:", " node-version: 22", ...(cacheLine ? [cacheLine] : []), "" ); } return [ `name: Deploy to GitHub Pages`, "", "on:", " push:", " branches: [main]", " workflow_dispatch:", "", "permissions:", " contents: read", " pages: write", " id-token: write", "", "concurrency:", ' group: "pages"', " cancel-in-progress: false", "", "jobs:", " build:", " runs-on: ubuntu-latest", " steps:", ...setupSteps, ` - run: ${installCmd}`, "", ` - run: ${pm.runCmd} run build`, "", " - name: Add .nojekyll", " run: touch .docu/dist/.nojekyll", "", " - uses: actions/upload-pages-artifact@v3", " with:", " path: .docu/dist", "", " deploy:", " environment:", " name: github-pages", " url: ${{ steps.deployment.outputs.page_url }}", " runs-on: ubuntu-latest", " needs: build", " steps:", " - id: deployment", " uses: actions/deploy-pages@v4", ].join("\n"); } function generateDockerWorkflowYml(): string { const imageName = "ghcr.io/${{ github.repository }}"; return [ `name: Build & Push Docker Image`, "", "on:", " push:", " branches: [main]", " workflow_dispatch:", "", "permissions:", " contents: read", " packages: write", "", "jobs:", " build:", " runs-on: ubuntu-latest", " steps:", " - uses: actions/checkout@v4", " with:", " fetch-depth: 0", "", " - name: Log in to GHCR", " uses: docker/login-action@v3", " with:", " registry: ghcr.io", " username: ${{ github.actor }}", " password: ${{ secrets.GITHUB_TOKEN }}", "", " - name: Build & push", " uses: docker/build-push-action@v5", " with:", " context: .", ` tags: ${imageName}:latest`, " push: true", ].join("\n"); } async function writeGhaWorkflow() { if (!existsSync(WORKFLOW_FILE)) { await mkdir(WORKFLOW_DIR, { recursive: true }); await writeFile(WORKFLOW_FILE, generateWorkflowYml()); log.created("📄 Created .github/workflows/deploy.yml"); } } const DOCKER_WORKFLOW_FILE = join(WORKFLOW_DIR, "deploy-docker.yml"); async function writeDockerWorkflow() { if (!existsSync(DOCKER_WORKFLOW_FILE)) { await mkdir(WORKFLOW_DIR, { recursive: true }); await writeFile(DOCKER_WORKFLOW_FILE, generateDockerWorkflowYml()); log.created("📄 Created .github/workflows/deploy-docker.yml"); } } export async function runDeploy(): Promise { log.info("📦 Building for production...\n"); await runBuild(); // Common: .nojekyll + _headers await writeFile(join(DIST_DIR, ".nojekyll"), ""); await writeFile(join(DIST_DIR, "_headers"), HEADERS_FILE); if (isDocker) { await writeDockerFiles(); if (isCi) await writeDockerWorkflow(); } else { await writeGhaWorkflow(); } log.ok(); log.out(" Output: .docu/dist/"); if (isDocker) { log.out(" Build locally: docker build -t my-docs . && docker run -p 80:80 my-docs"); if (isCi) { log.out(" Push to GitHub — CI will build & push Docker image to GHCR"); log.out(" Then pull latest image on your hosting platform (Coolify, etc.)"); } else { log.out(" For Coolify: connect repo, set build pack to Dockerfile"); } } else { log.out(" Push to GitHub and enable Pages (Settings → Pages → Source: GitHub Actions)"); } }