Move src/ → apps/server/ and packages/web/ → apps/web/ to adopt standard monorepo conventions (apps/ for runnable apps, packages/ for reusable libraries). Update all config files, shared package imports, test fixtures, and documentation to reflect new paths. Key fixes: - Update workspace config to ["apps/*", "packages/*"] - Update tsconfig.json rootDir/include for apps/server/ - Add apps/web/** to vitest exclude list - Update drizzle.config.ts schema path - Fix ensure-schema.ts migration path detection (3 levels up in dev, 2 levels up in dist) - Fix tests/integration/cli-server.test.ts import paths - Update packages/shared imports to apps/server/ paths - Update all docs/ files with new paths
192 lines
5.1 KiB
TypeScript
192 lines
5.1 KiB
TypeScript
/**
|
|
* Docker Compose Generator
|
|
*
|
|
* Generates docker-compose.preview.yml and Caddyfile for preview deployments.
|
|
* All services share a Docker network; only Caddy publishes a host port.
|
|
*/
|
|
|
|
import yaml from 'js-yaml';
|
|
import type { PreviewConfig, PreviewServiceConfig } from './types.js';
|
|
import { PREVIEW_LABELS } from './types.js';
|
|
|
|
export interface ComposeGeneratorOptions {
|
|
projectPath: string;
|
|
port: number;
|
|
deploymentId: string;
|
|
labels: Record<string, string>;
|
|
}
|
|
|
|
interface ComposeService {
|
|
build?: { context: string; dockerfile: string } | string;
|
|
image?: string;
|
|
environment?: Record<string, string>;
|
|
volumes?: string[];
|
|
labels?: Record<string, string>;
|
|
networks?: string[];
|
|
depends_on?: string[];
|
|
}
|
|
|
|
interface ComposeFile {
|
|
services: Record<string, ComposeService>;
|
|
networks: Record<string, { driver: string }>;
|
|
}
|
|
|
|
/**
|
|
* Generate a Docker Compose YAML string for the preview deployment.
|
|
*
|
|
* Structure:
|
|
* - User-defined services with build contexts
|
|
* - Caddy reverse proxy publishing the single host port
|
|
* - Shared `preview` network
|
|
*/
|
|
export function generateComposeFile(
|
|
config: PreviewConfig,
|
|
opts: ComposeGeneratorOptions,
|
|
): string {
|
|
const compose: ComposeFile = {
|
|
services: {},
|
|
networks: {
|
|
preview: { driver: 'bridge' },
|
|
},
|
|
};
|
|
|
|
const serviceNames: string[] = [];
|
|
|
|
// Add user-defined services
|
|
for (const [name, svc] of Object.entries(config.services)) {
|
|
serviceNames.push(name);
|
|
const service: ComposeService = {
|
|
labels: { ...opts.labels },
|
|
networks: ['preview'],
|
|
};
|
|
|
|
// Build config
|
|
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
|
|
if (svc.volumes && svc.volumes.length > 0) {
|
|
service.volumes = svc.volumes;
|
|
}
|
|
|
|
compose.services[name] = service;
|
|
}
|
|
|
|
// Generate and add Caddy proxy service
|
|
const caddyfile = generateCaddyfile(config);
|
|
const caddyService: ComposeService = {
|
|
image: 'caddy:2-alpine',
|
|
networks: ['preview'],
|
|
labels: { ...opts.labels },
|
|
};
|
|
|
|
// Caddy publishes the single host port
|
|
(caddyService as Record<string, unknown>).ports = [`${opts.port}:80`];
|
|
|
|
// Mount Caddyfile via inline config
|
|
(caddyService as Record<string, unknown>).command = ['caddy', 'run', '--config', '/etc/caddy/Caddyfile'];
|
|
|
|
// Caddy config will be written to the deployment directory and mounted
|
|
(caddyService as Record<string, unknown>).volumes = ['./Caddyfile:/etc/caddy/Caddyfile:ro'];
|
|
|
|
if (serviceNames.length > 0) {
|
|
caddyService.depends_on = serviceNames;
|
|
}
|
|
|
|
compose.services['caddy-proxy'] = caddyService;
|
|
|
|
return yaml.dump(compose, { lineWidth: 120, noRefs: true });
|
|
}
|
|
|
|
/**
|
|
* Generate a Caddyfile from route mappings in the preview config.
|
|
*
|
|
* Routes are sorted by specificity (longest path first) to ensure
|
|
* more specific routes match before catch-all.
|
|
*/
|
|
export function generateCaddyfile(config: PreviewConfig): string {
|
|
const routes: Array<{ name: string; route: string; port: number }> = [];
|
|
|
|
for (const [name, svc] of Object.entries(config.services)) {
|
|
if (svc.internal) continue;
|
|
routes.push({
|
|
name,
|
|
route: svc.route ?? '/',
|
|
port: svc.port,
|
|
});
|
|
}
|
|
|
|
// Sort by route specificity (longer paths first, root last)
|
|
routes.sort((a, b) => {
|
|
if (a.route === '/') return 1;
|
|
if (b.route === '/') return -1;
|
|
return b.route.length - a.route.length;
|
|
});
|
|
|
|
const lines: string[] = [':80 {'];
|
|
|
|
for (const route of routes) {
|
|
if (route.route === '/') {
|
|
lines.push(` handle {`);
|
|
lines.push(` reverse_proxy ${route.name}:${route.port}`);
|
|
lines.push(` }`);
|
|
} else {
|
|
// Strip trailing slash for handle_path
|
|
const path = route.route.endsWith('/') ? route.route.slice(0, -1) : route.route;
|
|
lines.push(` handle_path ${path}/* {`);
|
|
lines.push(` reverse_proxy ${route.name}:${route.port}`);
|
|
lines.push(` }`);
|
|
}
|
|
}
|
|
|
|
lines.push('}');
|
|
return lines.join('\n');
|
|
}
|
|
|
|
/**
|
|
* Generate compose labels for a preview deployment.
|
|
*/
|
|
export function generateLabels(opts: {
|
|
initiativeId: string;
|
|
phaseId?: string;
|
|
projectId: string;
|
|
branch: string;
|
|
port: number;
|
|
previewId: string;
|
|
}): Record<string, string> {
|
|
const labels: Record<string, string> = {
|
|
[PREVIEW_LABELS.preview]: 'true',
|
|
[PREVIEW_LABELS.initiativeId]: opts.initiativeId,
|
|
[PREVIEW_LABELS.projectId]: opts.projectId,
|
|
[PREVIEW_LABELS.branch]: opts.branch,
|
|
[PREVIEW_LABELS.port]: String(opts.port),
|
|
[PREVIEW_LABELS.previewId]: opts.previewId,
|
|
};
|
|
|
|
if (opts.phaseId) {
|
|
labels[PREVIEW_LABELS.phaseId] = opts.phaseId;
|
|
}
|
|
|
|
return labels;
|
|
}
|