/** * Docker Compose Generator * * Generates per-preview docker-compose.yml files (no Caddy sidecar — the * shared gateway handles routing). Services connect to the external * `cw-preview-net` network for gateway access and an internal bridge * network for inter-service communication. */ import yaml from 'js-yaml'; import type { PreviewConfig, PreviewServiceConfig } from './types.js'; import { PREVIEW_LABELS, GATEWAY_NETWORK } from './types.js'; export interface ComposeGeneratorOptions { projectPath: string; deploymentId: string; labels: Record; mode: 'preview' | 'dev'; } interface ComposeService { build?: { context: string; dockerfile: string } | string; image?: string; environment?: Record; volumes?: string[]; labels?: Record; networks?: string[]; depends_on?: string[]; container_name?: string; command?: string; working_dir?: string; } interface ComposeFile { services: Record; networks: Record; } /** * Generate a Docker Compose YAML string for a preview deployment. * * Structure: * - User-defined services with build contexts (no published ports) * - Public services connect to both cw-preview-net and internal network * - Internal services connect only to internal network * - Container names: cw-preview-- for DNS resolution on shared network * - Dev mode: uses image + volumes + command instead of build */ export function generateComposeFile( config: PreviewConfig, opts: ComposeGeneratorOptions, ): string { const compose: ComposeFile = { services: {}, networks: { [GATEWAY_NETWORK]: { external: true }, internal: { driver: 'bridge' }, }, }; for (const [name, svc] of Object.entries(config.services)) { const containerName = `cw-preview-${opts.deploymentId}-${name}`; const service: ComposeService = { container_name: containerName, labels: { ...opts.labels }, networks: svc.internal ? ['internal'] : [GATEWAY_NETWORK, 'internal'], }; if (opts.mode === 'dev' && svc.dev) { // Dev mode: use dev image + mount worktree service.image = svc.dev.image; service.working_dir = svc.dev.workdir ?? '/app'; service.volumes = [ `${opts.projectPath}:${svc.dev.workdir ?? '/app'}`, // Anonymous volume for node_modules to avoid host overwrite `${svc.dev.workdir ?? '/app'}/node_modules`, ]; if (svc.dev.command) { service.command = svc.dev.command; } } else { // Preview mode: build from source if (svc.build) { if (typeof svc.build === 'string') { service.build = { context: opts.projectPath, dockerfile: svc.build === '.' ? 'Dockerfile' : svc.build, }; } else { service.build = { context: svc.build.context.startsWith('/') ? svc.build.context : `${opts.projectPath}/${svc.build.context}`, dockerfile: svc.build.dockerfile, }; } } else if (svc.image) { service.image = svc.image; } } // Environment if (svc.env && Object.keys(svc.env).length > 0) { service.environment = svc.env; } // Volumes (from config, not dev overrides) if (opts.mode !== 'dev' && svc.volumes && svc.volumes.length > 0) { service.volumes = svc.volumes; } compose.services[name] = service; } return yaml.dump(compose, { lineWidth: 120, noRefs: true }); } /** * Generate compose labels for a preview deployment. */ export function generateLabels(opts: { initiativeId: string; phaseId?: string; projectId: string; branch: string; gatewayPort: number; previewId: string; mode: 'preview' | 'dev'; agentId?: string; }): Record { const labels: Record = { [PREVIEW_LABELS.preview]: 'true', [PREVIEW_LABELS.initiativeId]: opts.initiativeId, [PREVIEW_LABELS.projectId]: opts.projectId, [PREVIEW_LABELS.branch]: opts.branch, [PREVIEW_LABELS.port]: String(opts.gatewayPort), [PREVIEW_LABELS.previewId]: opts.previewId, [PREVIEW_LABELS.mode]: opts.mode, }; if (opts.phaseId) { labels[PREVIEW_LABELS.phaseId] = opts.phaseId; } if (opts.agentId) { labels[PREVIEW_LABELS.agentId] = opts.agentId; } return labels; }