Files
Codewalkers/apps/server/preview/config-reader.ts
Lukas May 714262fb83 feat: Add seed command support to preview deployments
Run project-specific initialization commands (DB migrations, fixture
loading, etc.) automatically after containers are healthy, before the
preview is marked ready. Configured via per-service `seed` arrays in
.cw-preview.yml.
2026-03-05 12:39:02 +01:00

177 lines
5.5 KiB
TypeScript

/**
* 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<PreviewConfig> {
// 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<string, unknown>;
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<string, PreviewServiceConfig> = {};
const rawServices = parsed.services as Record<string, Record<string, unknown>>;
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<string, string> }),
...(svc.volumes !== undefined && { volumes: svc.volumes as string[] }),
...(Array.isArray(svc.seed) && { seed: svc.seed as string[] }),
...(svc.dev !== undefined && {
dev: {
image: (svc.dev as Record<string, unknown>).image as string,
...(typeof (svc.dev as Record<string, unknown>).command === 'string' && {
command: (svc.dev as Record<string, unknown>).command as string,
}),
...(typeof (svc.dev as Record<string, unknown>).workdir === 'string' && {
workdir: (svc.dev as Record<string, unknown>).workdir 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<string, unknown>;
return {
context: (b.context as string) ?? '.',
dockerfile: (b.dockerfile as string) ?? 'Dockerfile',
};
}
return '.';
}
async function fileExists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}