Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 | 9x 9x 6x 6x 6x 1x 5x 5x 5x 7x 7x 7x 1x 6x 4x 4x 2x 2x 2x 2x | /**
* 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>;
Iif (!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)) {
Iif (!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[] }),
};
}
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;
}
Eif (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;
}
}
|