Files
Codewalkers/apps/server/preview/compose-generator.ts
Lukas May ebe186bd5e feat: Add agent preview integration with auto-teardown and simplified commands
- Add agentId label to preview containers (cw.agent-id) for tracking
- Add startForAgent/stopByAgentId methods to PreviewManager
- Auto-teardown: previews torn down on agent:stopped event
- Conditional preview prompt injection for execute/refine/discuss agents
- Agent-simplified CLI: cw preview start/stop --agent <id>
- cw preview setup command with --auto mode for guided config generation
- hasPreviewConfig hint on cw project register output
- New tRPC procedures: startPreviewForAgent, stopPreviewByAgent
2026-03-05 15:39:15 +01:00

153 lines
4.4 KiB
TypeScript

/**
* Docker Compose Generator
*
* Generates per-preview docker-compose.yml files (no Caddy sidecar — the
* shared gateway handles routing). Services connect to the external
* `cw-preview-net` network for gateway access and an internal bridge
* network for inter-service communication.
*/
import yaml from 'js-yaml';
import type { PreviewConfig, PreviewServiceConfig } from './types.js';
import { PREVIEW_LABELS, GATEWAY_NETWORK } from './types.js';
export interface ComposeGeneratorOptions {
projectPath: string;
deploymentId: string;
labels: Record<string, string>;
mode: 'preview' | 'dev';
}
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[];
container_name?: string;
command?: string;
working_dir?: string;
}
interface ComposeFile {
services: Record<string, ComposeService>;
networks: Record<string, { driver?: string; external?: boolean }>;
}
/**
* Generate a Docker Compose YAML string for a preview deployment.
*
* Structure:
* - User-defined services with build contexts (no published ports)
* - Public services connect to both cw-preview-net and internal network
* - Internal services connect only to internal network
* - Container names: cw-preview-<id>-<service> for DNS resolution on shared network
* - Dev mode: uses image + volumes + command instead of build
*/
export function generateComposeFile(
config: PreviewConfig,
opts: ComposeGeneratorOptions,
): string {
const compose: ComposeFile = {
services: {},
networks: {
[GATEWAY_NETWORK]: { external: true },
internal: { driver: 'bridge' },
},
};
for (const [name, svc] of Object.entries(config.services)) {
const containerName = `cw-preview-${opts.deploymentId}-${name}`;
const service: ComposeService = {
container_name: containerName,
labels: { ...opts.labels },
networks: svc.internal
? ['internal']
: [GATEWAY_NETWORK, 'internal'],
};
if (opts.mode === 'dev' && svc.dev) {
// Dev mode: use dev image + mount worktree
service.image = svc.dev.image;
service.working_dir = svc.dev.workdir ?? '/app';
service.volumes = [
`${opts.projectPath}:${svc.dev.workdir ?? '/app'}`,
// Anonymous volume for node_modules to avoid host overwrite
`${svc.dev.workdir ?? '/app'}/node_modules`,
];
if (svc.dev.command) {
service.command = svc.dev.command;
}
} else {
// Preview mode: build from source
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 (from config, not dev overrides)
if (opts.mode !== 'dev' && svc.volumes && svc.volumes.length > 0) {
service.volumes = svc.volumes;
}
compose.services[name] = service;
}
return yaml.dump(compose, { lineWidth: 120, noRefs: true });
}
/**
* Generate compose labels for a preview deployment.
*/
export function generateLabels(opts: {
initiativeId: string;
phaseId?: string;
projectId: string;
branch: string;
gatewayPort: number;
previewId: string;
mode: 'preview' | 'dev';
agentId?: 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.gatewayPort),
[PREVIEW_LABELS.previewId]: opts.previewId,
[PREVIEW_LABELS.mode]: opts.mode,
};
if (opts.phaseId) {
labels[PREVIEW_LABELS.phaseId] = opts.phaseId;
}
if (opts.agentId) {
labels[PREVIEW_LABELS.agentId] = opts.agentId;
}
return labels;
}