/** * Preview Config Reader * * Discovers and parses preview configuration from a project directory. * Discovery order: .cw-preview.yml → docker-compose.yml/compose.yml → Dockerfile */ import { readFile, access } from 'node:fs/promises'; import { join } from 'node:path'; import yaml from 'js-yaml'; import type { PreviewConfig, PreviewServiceConfig } from './types.js'; import { createModuleLogger } from '../logger/index.js'; const log = createModuleLogger('preview:config'); /** Files to check for existing Docker Compose config */ const COMPOSE_FILES = [ 'docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml', ]; /** * Discover and parse preview configuration from a project directory. * * Discovery order: * 1. `.cw-preview.yml` — explicit CW preview config * 2. `docker-compose.yml` / `compose.yml` (+ variants) — wrap existing compose * 3. `Dockerfile` at root — single-service fallback (assumes port 3000) * * @param projectPath - Absolute path to the project directory (at the target branch) * @returns Parsed and normalized PreviewConfig * @throws If no config can be discovered */ export async function discoverConfig(projectPath: string): Promise { // 1. Check for explicit .cw-preview.yml const cwPreviewPath = join(projectPath, '.cw-preview.yml'); if (await fileExists(cwPreviewPath)) { log.info({ path: cwPreviewPath }, 'found .cw-preview.yml'); const raw = await readFile(cwPreviewPath, 'utf-8'); return parseCwPreviewConfig(raw); } // 2. Check for existing compose files for (const composeFile of COMPOSE_FILES) { const composePath = join(projectPath, composeFile); if (await fileExists(composePath)) { log.info({ path: composePath }, 'found existing compose file'); return parseExistingCompose(composePath, composeFile); } } // 3. Check for Dockerfile const dockerfilePath = join(projectPath, 'Dockerfile'); if (await fileExists(dockerfilePath)) { log.info({ path: dockerfilePath }, 'found Dockerfile, using single-service fallback'); return createDockerfileFallback(); } throw new Error( `No preview configuration found in ${projectPath}. ` + `Expected one of: .cw-preview.yml, docker-compose.yml, compose.yml, or Dockerfile` ); } /** * Parse a `.cw-preview.yml` file into a PreviewConfig. */ export function parseCwPreviewConfig(raw: string): PreviewConfig { const parsed = yaml.load(raw) as Record; if (!parsed || typeof parsed !== 'object') { throw new Error('Invalid .cw-preview.yml: expected a YAML object'); } if (!parsed.services || typeof parsed.services !== 'object') { throw new Error('Invalid .cw-preview.yml: missing "services" key'); } const services: Record = {}; const rawServices = parsed.services as Record>; for (const [name, svc] of Object.entries(rawServices)) { if (!svc || typeof svc !== 'object') { throw new Error(`Invalid service "${name}": expected an object`); } const port = svc.port as number | undefined; if (port === undefined && !svc.internal) { throw new Error(`Service "${name}" must specify a "port" (or be marked "internal: true")`); } services[name] = { name, port: port ?? 0, ...(svc.build !== undefined && { build: normalizeBuild(svc.build) }), ...(svc.image !== undefined && { image: svc.image as string }), ...(svc.route !== undefined && { route: svc.route as string }), ...(svc.internal !== undefined && { internal: svc.internal as boolean }), ...(svc.healthcheck !== undefined && { healthcheck: svc.healthcheck as PreviewServiceConfig['healthcheck'] }), ...(svc.env !== undefined && { env: svc.env as Record }), ...(svc.volumes !== undefined && { volumes: svc.volumes as string[] }), }; } return { version: 1, services, }; } /** * Wrap an existing Docker Compose file as a passthrough config. */ function parseExistingCompose(composePath: string, composeFile: string): PreviewConfig { return { version: 1, compose: composeFile, services: {}, }; } /** * Create a single-service fallback config from a Dockerfile. */ function createDockerfileFallback(): PreviewConfig { return { version: 1, services: { app: { name: 'app', build: '.', port: 3000, }, }, }; } /** * Normalize build config to a consistent format. */ function normalizeBuild(build: unknown): PreviewServiceConfig['build'] { if (typeof build === 'string') { return build; } if (typeof build === 'object' && build !== null) { const b = build as Record; return { context: (b.context as string) ?? '.', dockerfile: (b.dockerfile as string) ?? 'Dockerfile', }; } return '.'; } async function fileExists(path: string): Promise { try { await access(path); return true; } catch { return false; } }