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.
177 lines
5.5 KiB
TypeScript
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;
|
|
}
|
|
}
|