feat: Replace per-preview Caddy sidecars with shared gateway architecture
Refactor preview deployments to use a single shared Caddy gateway container with subdomain routing (<previewId>.localhost:<port>) instead of one Caddy sidecar and one port per preview. Adds dev/preview modes, git worktree support for branch checkouts, and auto-start on phase:pending_review. - Add GatewayManager for shared Caddy lifecycle + Caddyfile generation - Add git worktree helpers for preview mode branch checkouts - Add dev mode: volume-mount + dev server image instead of build - Remove per-preview Caddy sidecar and port publishing - Use shared cw-preview-net Docker network with container name DNS - Auto-start previews when phase enters pending_review - Delete unused PreviewPanel.tsx - Update all tests (40 pass), docs, events, CLI, tRPC, frontend
This commit is contained in:
@@ -1353,8 +1353,9 @@ export function createCli(serverHandler?: (port?: number) => Promise<void>): Com
|
||||
phaseId: options.phase,
|
||||
});
|
||||
console.log(`Preview started: ${preview.id}`);
|
||||
console.log(` URL: http://localhost:${preview.port}`);
|
||||
console.log(` URL: ${preview.url}`);
|
||||
console.log(` Branch: ${preview.branch}`);
|
||||
console.log(` Mode: ${preview.mode}`);
|
||||
console.log(` Status: ${preview.status}`);
|
||||
console.log(` Services: ${preview.services.map(s => `${s.name} (${s.state})`).join(', ')}`);
|
||||
} catch (error) {
|
||||
@@ -1394,7 +1395,7 @@ export function createCli(serverHandler?: (port?: number) => Promise<void>): Com
|
||||
return;
|
||||
}
|
||||
for (const p of previews) {
|
||||
console.log(`${p.id} http://localhost:${p.port} ${p.branch} [${p.status.toUpperCase()}]`);
|
||||
console.log(`${p.id} ${p.url} ${p.branch} ${p.mode} [${p.status.toUpperCase()}]`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to list previews:', (error as Error).message);
|
||||
@@ -1415,8 +1416,9 @@ export function createCli(serverHandler?: (port?: number) => Promise<void>): Com
|
||||
return;
|
||||
}
|
||||
console.log(`Preview: ${preview.id}`);
|
||||
console.log(` URL: http://localhost:${preview.port}`);
|
||||
console.log(` URL: ${preview.url}`);
|
||||
console.log(` Branch: ${preview.branch}`);
|
||||
console.log(` Mode: ${preview.mode}`);
|
||||
console.log(` Status: ${preview.status}`);
|
||||
console.log(` Initiative: ${preview.initiativeId}`);
|
||||
console.log(` Project: ${preview.projectId}`);
|
||||
|
||||
@@ -255,6 +255,8 @@ export async function createContainer(options?: ContainerOptions): Promise<Conta
|
||||
repos.projectRepository,
|
||||
eventBus,
|
||||
workspaceRoot,
|
||||
repos.phaseRepository,
|
||||
repos.initiativeRepository,
|
||||
);
|
||||
log.info('preview manager created');
|
||||
|
||||
|
||||
@@ -467,7 +467,9 @@ export interface PreviewBuildingEvent extends DomainEvent {
|
||||
previewId: string;
|
||||
initiativeId: string;
|
||||
branch: string;
|
||||
port: number;
|
||||
gatewayPort: number;
|
||||
mode: 'preview' | 'dev';
|
||||
phaseId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -477,8 +479,10 @@ export interface PreviewReadyEvent extends DomainEvent {
|
||||
previewId: string;
|
||||
initiativeId: string;
|
||||
branch: string;
|
||||
port: number;
|
||||
gatewayPort: number;
|
||||
url: string;
|
||||
mode: 'preview' | 'dev';
|
||||
phaseId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,24 +2,26 @@ import { describe, it, expect } from 'vitest';
|
||||
import yaml from 'js-yaml';
|
||||
import {
|
||||
generateComposeFile,
|
||||
generateCaddyfile,
|
||||
generateLabels,
|
||||
} from './compose-generator.js';
|
||||
import { generateGatewayCaddyfile } from './gateway.js';
|
||||
import type { PreviewConfig } from './types.js';
|
||||
import { GATEWAY_NETWORK } from './types.js';
|
||||
import type { GatewayRoute } from './gateway.js';
|
||||
|
||||
describe('generateComposeFile', () => {
|
||||
const baseOpts = {
|
||||
projectPath: '/workspace/repos/my-project-abc123',
|
||||
port: 9100,
|
||||
deploymentId: 'test123',
|
||||
labels: {
|
||||
'cw.preview': 'true',
|
||||
'cw.initiative-id': 'init-1',
|
||||
'cw.port': '9100',
|
||||
},
|
||||
mode: 'preview' as const,
|
||||
};
|
||||
|
||||
it('generates valid compose YAML with user services and Caddy proxy', () => {
|
||||
it('generates valid compose YAML with user services (no Caddy sidecar)', () => {
|
||||
const config: PreviewConfig = {
|
||||
version: 1,
|
||||
services: {
|
||||
@@ -34,18 +36,26 @@ describe('generateComposeFile', () => {
|
||||
const result = generateComposeFile(config, baseOpts);
|
||||
const parsed = yaml.load(result) as Record<string, any>;
|
||||
|
||||
// Has both user service and caddy
|
||||
// Has user service but NOT caddy-proxy
|
||||
expect(parsed.services.app).toBeDefined();
|
||||
expect(parsed.services['caddy-proxy']).toBeDefined();
|
||||
expect(parsed.services['caddy-proxy']).toBeUndefined();
|
||||
|
||||
// Network present
|
||||
expect(parsed.networks.preview).toBeDefined();
|
||||
// External network + internal network
|
||||
expect(parsed.networks[GATEWAY_NETWORK]).toEqual({ external: true });
|
||||
expect(parsed.networks.internal).toEqual({ driver: 'bridge' });
|
||||
|
||||
// Caddy publishes port
|
||||
expect(parsed.services['caddy-proxy'].ports).toContain('9100:80');
|
||||
// No published ports on user service
|
||||
expect(parsed.services.app.ports).toBeUndefined();
|
||||
|
||||
// Container name set for DNS resolution
|
||||
expect(parsed.services.app.container_name).toBe('cw-preview-test123-app');
|
||||
|
||||
// Labels propagated
|
||||
expect(parsed.services.app.labels['cw.preview']).toBe('true');
|
||||
|
||||
// Public service on both networks
|
||||
expect(parsed.services.app.networks).toContain(GATEWAY_NETWORK);
|
||||
expect(parsed.services.app.networks).toContain('internal');
|
||||
});
|
||||
|
||||
it('handles object build config with context path joining', () => {
|
||||
@@ -90,84 +100,117 @@ describe('generateComposeFile', () => {
|
||||
expect(parsed.services.db.environment.POSTGRES_PASSWORD).toBe('test');
|
||||
});
|
||||
|
||||
it('caddy depends on all user services', () => {
|
||||
it('internal services only get internal network', () => {
|
||||
const config: PreviewConfig = {
|
||||
version: 1,
|
||||
services: {
|
||||
frontend: { name: 'frontend', build: '.', port: 3000 },
|
||||
backend: { name: 'backend', build: '.', port: 8080 },
|
||||
db: {
|
||||
name: 'db',
|
||||
image: 'postgres:16',
|
||||
port: 5432,
|
||||
internal: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = generateComposeFile(config, baseOpts);
|
||||
const parsed = yaml.load(result) as Record<string, any>;
|
||||
|
||||
expect(parsed.services['caddy-proxy'].depends_on).toContain('frontend');
|
||||
expect(parsed.services['caddy-proxy'].depends_on).toContain('backend');
|
||||
});
|
||||
expect(parsed.services.db.networks).toEqual(['internal']);
|
||||
expect(parsed.services.db.networks).not.toContain(GATEWAY_NETWORK);
|
||||
});
|
||||
|
||||
describe('generateCaddyfile', () => {
|
||||
it('generates simple single-service Caddyfile', () => {
|
||||
it('dev mode uses image + volumes + command instead of build', () => {
|
||||
const config: PreviewConfig = {
|
||||
version: 1,
|
||||
services: {
|
||||
app: { name: 'app', build: '.', port: 3000 },
|
||||
frontend: {
|
||||
name: 'frontend',
|
||||
build: '.',
|
||||
port: 3000,
|
||||
dev: {
|
||||
image: 'node:20-alpine',
|
||||
command: 'npm run dev -- --host 0.0.0.0',
|
||||
workdir: '/app',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const caddyfile = generateCaddyfile(config);
|
||||
expect(caddyfile).toContain(':80 {');
|
||||
expect(caddyfile).toContain('reverse_proxy app:3000');
|
||||
expect(caddyfile).toContain('}');
|
||||
const devOpts = { ...baseOpts, mode: 'dev' as const };
|
||||
const result = generateComposeFile(config, devOpts);
|
||||
const parsed = yaml.load(result) as Record<string, any>;
|
||||
|
||||
// Should use dev image, not build
|
||||
expect(parsed.services.frontend.image).toBe('node:20-alpine');
|
||||
expect(parsed.services.frontend.build).toBeUndefined();
|
||||
expect(parsed.services.frontend.command).toBe('npm run dev -- --host 0.0.0.0');
|
||||
expect(parsed.services.frontend.working_dir).toBe('/app');
|
||||
|
||||
// Should have volume mount + node_modules anonymous volume
|
||||
expect(parsed.services.frontend.volumes).toContain(
|
||||
`${baseOpts.projectPath}:/app`,
|
||||
);
|
||||
expect(parsed.services.frontend.volumes).toContain('/app/node_modules');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateGatewayCaddyfile', () => {
|
||||
it('generates single-preview Caddyfile with subdomain routing', () => {
|
||||
const previews = new Map<string, GatewayRoute[]>();
|
||||
previews.set('abc123', [
|
||||
{ containerName: 'cw-preview-abc123-app', port: 3000, route: '/' },
|
||||
]);
|
||||
|
||||
const caddyfile = generateGatewayCaddyfile(previews, 9100);
|
||||
expect(caddyfile).toContain('auto_https off');
|
||||
expect(caddyfile).toContain('abc123.localhost:9100 {');
|
||||
expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-app:3000');
|
||||
});
|
||||
|
||||
it('generates multi-service Caddyfile with handle_path for non-root routes', () => {
|
||||
const config: PreviewConfig = {
|
||||
version: 1,
|
||||
services: {
|
||||
frontend: { name: 'frontend', build: '.', port: 3000, route: '/' },
|
||||
backend: { name: 'backend', build: '.', port: 8080, route: '/api' },
|
||||
},
|
||||
};
|
||||
const previews = new Map<string, GatewayRoute[]>();
|
||||
previews.set('abc123', [
|
||||
{ containerName: 'cw-preview-abc123-frontend', port: 3000, route: '/' },
|
||||
{ containerName: 'cw-preview-abc123-backend', port: 8080, route: '/api' },
|
||||
]);
|
||||
|
||||
const caddyfile = generateCaddyfile(config);
|
||||
const caddyfile = generateGatewayCaddyfile(previews, 9100);
|
||||
expect(caddyfile).toContain('handle_path /api/*');
|
||||
expect(caddyfile).toContain('reverse_proxy backend:8080');
|
||||
expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-backend:8080');
|
||||
expect(caddyfile).toContain('handle {');
|
||||
expect(caddyfile).toContain('reverse_proxy frontend:3000');
|
||||
expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-frontend:3000');
|
||||
});
|
||||
|
||||
it('excludes internal services from Caddyfile', () => {
|
||||
const config: PreviewConfig = {
|
||||
version: 1,
|
||||
services: {
|
||||
app: { name: 'app', build: '.', port: 3000 },
|
||||
db: { name: 'db', image: 'postgres', port: 5432, internal: true },
|
||||
},
|
||||
};
|
||||
it('generates multi-preview Caddyfile with separate subdomain blocks', () => {
|
||||
const previews = new Map<string, GatewayRoute[]>();
|
||||
previews.set('abc', [
|
||||
{ containerName: 'cw-preview-abc-app', port: 3000, route: '/' },
|
||||
]);
|
||||
previews.set('xyz', [
|
||||
{ containerName: 'cw-preview-xyz-app', port: 5000, route: '/' },
|
||||
]);
|
||||
|
||||
const caddyfile = generateCaddyfile(config);
|
||||
expect(caddyfile).not.toContain('postgres');
|
||||
expect(caddyfile).not.toContain('db:5432');
|
||||
const caddyfile = generateGatewayCaddyfile(previews, 9100);
|
||||
expect(caddyfile).toContain('abc.localhost:9100 {');
|
||||
expect(caddyfile).toContain('xyz.localhost:9100 {');
|
||||
expect(caddyfile).toContain('reverse_proxy cw-preview-abc-app:3000');
|
||||
expect(caddyfile).toContain('reverse_proxy cw-preview-xyz-app:5000');
|
||||
});
|
||||
|
||||
it('sorts routes by specificity (longer paths first)', () => {
|
||||
const config: PreviewConfig = {
|
||||
version: 1,
|
||||
services: {
|
||||
app: { name: 'app', build: '.', port: 3000, route: '/' },
|
||||
api: { name: 'api', build: '.', port: 8080, route: '/api' },
|
||||
auth: { name: 'auth', build: '.', port: 9090, route: '/api/auth' },
|
||||
},
|
||||
};
|
||||
const previews = new Map<string, GatewayRoute[]>();
|
||||
previews.set('abc', [
|
||||
{ containerName: 'cw-preview-abc-app', port: 3000, route: '/' },
|
||||
{ containerName: 'cw-preview-abc-api', port: 8080, route: '/api' },
|
||||
{ containerName: 'cw-preview-abc-auth', port: 9090, route: '/api/auth' },
|
||||
]);
|
||||
|
||||
const caddyfile = generateCaddyfile(config);
|
||||
const caddyfile = generateGatewayCaddyfile(previews, 9100);
|
||||
const apiAuthIdx = caddyfile.indexOf('/api/auth');
|
||||
const apiIdx = caddyfile.indexOf('handle_path /api/*');
|
||||
const handleIdx = caddyfile.indexOf('handle {');
|
||||
|
||||
// /api/auth should come before /api which should come before /
|
||||
expect(apiAuthIdx).toBeLessThan(apiIdx);
|
||||
expect(apiIdx).toBeLessThan(handleIdx);
|
||||
});
|
||||
@@ -180,8 +223,9 @@ describe('generateLabels', () => {
|
||||
phaseId: 'phase-1',
|
||||
projectId: 'proj-1',
|
||||
branch: 'feature/test',
|
||||
port: 9100,
|
||||
gatewayPort: 9100,
|
||||
previewId: 'abc123',
|
||||
mode: 'preview',
|
||||
});
|
||||
|
||||
expect(labels['cw.preview']).toBe('true');
|
||||
@@ -191,6 +235,7 @@ describe('generateLabels', () => {
|
||||
expect(labels['cw.branch']).toBe('feature/test');
|
||||
expect(labels['cw.port']).toBe('9100');
|
||||
expect(labels['cw.preview-id']).toBe('abc123');
|
||||
expect(labels['cw.mode']).toBe('preview');
|
||||
});
|
||||
|
||||
it('omits phaseId label when not provided', () => {
|
||||
@@ -198,10 +243,12 @@ describe('generateLabels', () => {
|
||||
initiativeId: 'init-1',
|
||||
projectId: 'proj-1',
|
||||
branch: 'main',
|
||||
port: 9100,
|
||||
gatewayPort: 9100,
|
||||
previewId: 'abc123',
|
||||
mode: 'dev',
|
||||
});
|
||||
|
||||
expect(labels['cw.phase-id']).toBeUndefined();
|
||||
expect(labels['cw.mode']).toBe('dev');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
/**
|
||||
* 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.
|
||||
* 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 } from './types.js';
|
||||
import { PREVIEW_LABELS, GATEWAY_NETWORK } from './types.js';
|
||||
|
||||
export interface ComposeGeneratorOptions {
|
||||
projectPath: string;
|
||||
port: number;
|
||||
deploymentId: string;
|
||||
labels: Record<string, string>;
|
||||
mode: 'preview' | 'dev';
|
||||
}
|
||||
|
||||
interface ComposeService {
|
||||
@@ -24,20 +26,25 @@ interface ComposeService {
|
||||
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 }>;
|
||||
networks: Record<string, { driver?: string; external?: boolean }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a Docker Compose YAML string for the preview deployment.
|
||||
* Generate a Docker Compose YAML string for a preview deployment.
|
||||
*
|
||||
* Structure:
|
||||
* - User-defined services with build contexts
|
||||
* - Caddy reverse proxy publishing the single host port
|
||||
* - Shared `preview` network
|
||||
* - 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,
|
||||
@@ -46,21 +53,35 @@ export function generateComposeFile(
|
||||
const compose: ComposeFile = {
|
||||
services: {},
|
||||
networks: {
|
||||
preview: { driver: 'bridge' },
|
||||
[GATEWAY_NETWORK]: { external: true },
|
||||
internal: { driver: 'bridge' },
|
||||
},
|
||||
};
|
||||
|
||||
const serviceNames: string[] = [];
|
||||
|
||||
// Add user-defined services
|
||||
for (const [name, svc] of Object.entries(config.services)) {
|
||||
serviceNames.push(name);
|
||||
const containerName = `cw-preview-${opts.deploymentId}-${name}`;
|
||||
const service: ComposeService = {
|
||||
container_name: containerName,
|
||||
labels: { ...opts.labels },
|
||||
networks: ['preview'],
|
||||
networks: svc.internal
|
||||
? ['internal']
|
||||
: [GATEWAY_NETWORK, 'internal'],
|
||||
};
|
||||
|
||||
// Build config
|
||||
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 = {
|
||||
@@ -78,91 +99,24 @@ export function generateComposeFile(
|
||||
} 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) {
|
||||
// Volumes (from config, not dev overrides)
|
||||
if (opts.mode !== 'dev' && 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.
|
||||
*/
|
||||
@@ -171,16 +125,18 @@ export function generateLabels(opts: {
|
||||
phaseId?: string;
|
||||
projectId: string;
|
||||
branch: string;
|
||||
port: number;
|
||||
gatewayPort: number;
|
||||
previewId: string;
|
||||
mode: 'preview' | 'dev';
|
||||
}): 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.port]: String(opts.gatewayPort),
|
||||
[PREVIEW_LABELS.previewId]: opts.previewId,
|
||||
[PREVIEW_LABELS.mode]: opts.mode,
|
||||
};
|
||||
|
||||
if (opts.phaseId) {
|
||||
|
||||
@@ -112,4 +112,41 @@ services:
|
||||
const config = parseCwPreviewConfig(raw);
|
||||
expect(config.services.app.build).toBe('./app');
|
||||
});
|
||||
|
||||
it('parses dev section with image, command, and workdir', () => {
|
||||
const raw = `
|
||||
version: 1
|
||||
services:
|
||||
frontend:
|
||||
build: "."
|
||||
port: 3000
|
||||
route: /
|
||||
dev:
|
||||
image: node:20-alpine
|
||||
command: npm run dev -- --host 0.0.0.0
|
||||
workdir: /app
|
||||
`;
|
||||
const config = parseCwPreviewConfig(raw);
|
||||
expect(config.services.frontend.dev).toBeDefined();
|
||||
expect(config.services.frontend.dev!.image).toBe('node:20-alpine');
|
||||
expect(config.services.frontend.dev!.command).toBe('npm run dev -- --host 0.0.0.0');
|
||||
expect(config.services.frontend.dev!.workdir).toBe('/app');
|
||||
});
|
||||
|
||||
it('parses dev section with only image', () => {
|
||||
const raw = `
|
||||
version: 1
|
||||
services:
|
||||
app:
|
||||
build: "."
|
||||
port: 3000
|
||||
dev:
|
||||
image: node:20-alpine
|
||||
`;
|
||||
const config = parseCwPreviewConfig(raw);
|
||||
expect(config.services.app.dev).toBeDefined();
|
||||
expect(config.services.app.dev!.image).toBe('node:20-alpine');
|
||||
expect(config.services.app.dev!.command).toBeUndefined();
|
||||
expect(config.services.app.dev!.workdir).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,6 +101,17 @@ export function parseCwPreviewConfig(raw: string): PreviewConfig {
|
||||
...(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[] }),
|
||||
...(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,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,49 @@ export async function isDockerAvailable(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a Docker network exists. Creates it if missing.
|
||||
*/
|
||||
export async function ensureDockerNetwork(name: string): Promise<void> {
|
||||
try {
|
||||
await execa('docker', ['network', 'create', '--driver', 'bridge', name], {
|
||||
timeout: 15000,
|
||||
});
|
||||
log.info({ name }, 'created docker network');
|
||||
} catch (error) {
|
||||
// Ignore "already exists" error
|
||||
if ((error as Error).message?.includes('already exists')) {
|
||||
log.debug({ name }, 'docker network already exists');
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a Docker network. Ignores errors (e.g., network in use or not found).
|
||||
*/
|
||||
export async function removeDockerNetwork(name: string): Promise<void> {
|
||||
try {
|
||||
await execa('docker', ['network', 'rm', name], { timeout: 15000 });
|
||||
log.info({ name }, 'removed docker network');
|
||||
} catch {
|
||||
log.debug({ name }, 'failed to remove docker network (may not exist or be in use)');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a Docker network exists.
|
||||
*/
|
||||
export async function dockerNetworkExists(name: string): Promise<boolean> {
|
||||
try {
|
||||
await execa('docker', ['network', 'inspect', name], { timeout: 15000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a compose project (build and run in background).
|
||||
*/
|
||||
@@ -177,30 +220,3 @@ export async function getContainerLabels(projectName: string): Promise<Record<st
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ports of running preview containers by reading their cw.port labels.
|
||||
*/
|
||||
export async function getPreviewPorts(): Promise<number[]> {
|
||||
try {
|
||||
const result = await execa('docker', [
|
||||
'ps',
|
||||
'--filter', `label=${PREVIEW_LABELS.preview}=true`,
|
||||
'--format', `{{.Label "${PREVIEW_LABELS.port}"}}`,
|
||||
], {
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
if (!result.stdout.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return result.stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((s) => parseInt(s, 10))
|
||||
.filter((n) => !isNaN(n));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
240
apps/server/preview/gateway.ts
Normal file
240
apps/server/preview/gateway.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Gateway Manager
|
||||
*
|
||||
* Manages a single shared Caddy reverse proxy (the "gateway") that routes
|
||||
* subdomain requests to per-preview compose stacks on a shared Docker network.
|
||||
*
|
||||
* Architecture:
|
||||
* .cw-previews/gateway/
|
||||
* docker-compose.yml ← single Caddy container
|
||||
* Caddyfile ← regenerated on each preview add/remove
|
||||
*
|
||||
* Caddy runs with `--watch` so it auto-reloads when the Caddyfile changes on disk.
|
||||
*/
|
||||
|
||||
import { join } from 'node:path';
|
||||
import { mkdir, writeFile, rm } from 'node:fs/promises';
|
||||
import yaml from 'js-yaml';
|
||||
import { GATEWAY_PROJECT_NAME, GATEWAY_NETWORK } from './types.js';
|
||||
import {
|
||||
ensureDockerNetwork,
|
||||
removeDockerNetwork,
|
||||
composeUp,
|
||||
composeDown,
|
||||
composePs,
|
||||
} from './docker-client.js';
|
||||
import { allocatePort } from './port-allocator.js';
|
||||
import { createModuleLogger } from '../logger/index.js';
|
||||
|
||||
const log = createModuleLogger('preview:gateway');
|
||||
|
||||
/** Directory for preview deployment artifacts (relative to workspace root) */
|
||||
const PREVIEWS_DIR = '.cw-previews';
|
||||
|
||||
/**
|
||||
* A route entry for the gateway Caddyfile.
|
||||
*/
|
||||
export interface GatewayRoute {
|
||||
containerName: string;
|
||||
port: number;
|
||||
route: string;
|
||||
}
|
||||
|
||||
export class GatewayManager {
|
||||
private readonly workspaceRoot: string;
|
||||
private cachedPort: number | null = null;
|
||||
|
||||
constructor(workspaceRoot: string) {
|
||||
this.workspaceRoot = workspaceRoot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the gateway is running. Idempotent — returns the port if already up.
|
||||
*
|
||||
* 1. Create the shared Docker network
|
||||
* 2. Check if gateway already running → return existing port
|
||||
* 3. Allocate a port, write compose + empty Caddyfile, start
|
||||
*/
|
||||
async ensureGateway(): Promise<number> {
|
||||
await ensureDockerNetwork(GATEWAY_NETWORK);
|
||||
|
||||
// Check if already running
|
||||
const existingPort = await this.getPort();
|
||||
if (existingPort !== null) {
|
||||
this.cachedPort = existingPort;
|
||||
log.info({ port: existingPort }, 'gateway already running');
|
||||
return existingPort;
|
||||
}
|
||||
|
||||
// Allocate a port for the gateway
|
||||
const port = await allocatePort();
|
||||
this.cachedPort = port;
|
||||
|
||||
// Write gateway compose + empty Caddyfile
|
||||
const gatewayDir = join(this.workspaceRoot, PREVIEWS_DIR, 'gateway');
|
||||
await mkdir(gatewayDir, { recursive: true });
|
||||
|
||||
const composeContent = this.generateGatewayCompose(port);
|
||||
await writeFile(join(gatewayDir, 'docker-compose.yml'), composeContent, 'utf-8');
|
||||
|
||||
// Start with an empty Caddyfile — will be populated by updateRoutes()
|
||||
const emptyCaddyfile = '{\n auto_https off\n}\n';
|
||||
await writeFile(join(gatewayDir, 'Caddyfile'), emptyCaddyfile, 'utf-8');
|
||||
|
||||
const composePath = join(gatewayDir, 'docker-compose.yml');
|
||||
await composeUp(composePath, GATEWAY_PROJECT_NAME);
|
||||
|
||||
log.info({ port }, 'gateway started');
|
||||
return port;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate the Caddyfile from all active previews.
|
||||
* Caddy's `--watch` flag picks up the file change automatically.
|
||||
*/
|
||||
async updateRoutes(previews: Map<string, GatewayRoute[]>): Promise<void> {
|
||||
const port = this.cachedPort ?? (await this.getPort());
|
||||
if (port === null) {
|
||||
log.warn('cannot update routes — gateway not running');
|
||||
return;
|
||||
}
|
||||
|
||||
const caddyfile = generateGatewayCaddyfile(previews, port);
|
||||
const caddyfilePath = join(this.workspaceRoot, PREVIEWS_DIR, 'gateway', 'Caddyfile');
|
||||
await writeFile(caddyfilePath, caddyfile, 'utf-8');
|
||||
|
||||
log.info({ previewCount: previews.size }, 'gateway routes updated');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the gateway and remove the shared network.
|
||||
*/
|
||||
async stopGateway(): Promise<void> {
|
||||
await composeDown(GATEWAY_PROJECT_NAME).catch(() => {});
|
||||
await removeDockerNetwork(GATEWAY_NETWORK);
|
||||
|
||||
const gatewayDir = join(this.workspaceRoot, PREVIEWS_DIR, 'gateway');
|
||||
await rm(gatewayDir, { recursive: true, force: true }).catch(() => {});
|
||||
|
||||
this.cachedPort = null;
|
||||
log.info('gateway stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the gateway compose project is running.
|
||||
*/
|
||||
async isRunning(): Promise<boolean> {
|
||||
const services = await composePs(GATEWAY_PROJECT_NAME);
|
||||
return services.some((s) => s.state === 'running');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the gateway port from the running container's labels.
|
||||
* Returns null if the gateway isn't running.
|
||||
*/
|
||||
async getPort(): Promise<number | null> {
|
||||
if (this.cachedPort !== null) {
|
||||
// Verify the container is still running
|
||||
if (await this.isRunning()) {
|
||||
return this.cachedPort;
|
||||
}
|
||||
this.cachedPort = null;
|
||||
}
|
||||
|
||||
try {
|
||||
const { execa } = await import('execa');
|
||||
const result = await execa('docker', [
|
||||
'ps',
|
||||
'--filter', `label=cw.gateway=true`,
|
||||
'--format', `{{.Label "cw.gateway-port"}}`,
|
||||
], { timeout: 15000 });
|
||||
|
||||
if (!result.stdout.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const port = parseInt(result.stdout.trim().split('\n')[0], 10);
|
||||
if (isNaN(port)) return null;
|
||||
|
||||
this.cachedPort = port;
|
||||
return port;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the gateway docker-compose.yml content.
|
||||
*/
|
||||
private generateGatewayCompose(port: number): string {
|
||||
const compose = {
|
||||
services: {
|
||||
caddy: {
|
||||
image: 'caddy:2-alpine',
|
||||
command: ['caddy', 'run', '--config', '/etc/caddy/Caddyfile', '--adapter', 'caddyfile', '--watch'],
|
||||
ports: [`${port}:80`],
|
||||
volumes: ['./Caddyfile:/etc/caddy/Caddyfile:rw'],
|
||||
networks: [GATEWAY_NETWORK],
|
||||
labels: {
|
||||
'cw.gateway': 'true',
|
||||
'cw.gateway-port': String(port),
|
||||
},
|
||||
},
|
||||
},
|
||||
networks: {
|
||||
[GATEWAY_NETWORK]: {
|
||||
external: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return yaml.dump(compose, { lineWidth: 120, noRefs: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a Caddyfile for the gateway from all active preview routes.
|
||||
*
|
||||
* Each preview gets a subdomain block: `<previewId>.localhost:<port>`
|
||||
* Routes within a preview are sorted by specificity (longest path first).
|
||||
*/
|
||||
export function generateGatewayCaddyfile(
|
||||
previews: Map<string, GatewayRoute[]>,
|
||||
port: number,
|
||||
): string {
|
||||
const lines: string[] = [
|
||||
'{',
|
||||
' auto_https off',
|
||||
'}',
|
||||
'',
|
||||
];
|
||||
|
||||
for (const [previewId, routes] of previews) {
|
||||
// Sort routes by specificity (longer paths first, root last)
|
||||
const sorted = [...routes].sort((a, b) => {
|
||||
if (a.route === '/') return 1;
|
||||
if (b.route === '/') return -1;
|
||||
return b.route.length - a.route.length;
|
||||
});
|
||||
|
||||
lines.push(`${previewId}.localhost:${port} {`);
|
||||
|
||||
for (const route of sorted) {
|
||||
if (route.route === '/') {
|
||||
lines.push(` handle {`);
|
||||
lines.push(` reverse_proxy ${route.containerName}:${route.port}`);
|
||||
lines.push(` }`);
|
||||
} else {
|
||||
const path = route.route.endsWith('/') ? route.route.slice(0, -1) : route.route;
|
||||
lines.push(` handle_path ${path}/* {`);
|
||||
lines.push(` reverse_proxy ${route.containerName}:${route.port}`);
|
||||
lines.push(` }`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('}');
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Health Checker
|
||||
*
|
||||
* Polls service healthcheck endpoints through the Caddy proxy port
|
||||
* Polls service healthcheck endpoints through the gateway's subdomain routing
|
||||
* to verify that preview services are ready.
|
||||
*/
|
||||
|
||||
@@ -18,15 +18,17 @@ const DEFAULT_INTERVAL_MS = 3_000;
|
||||
|
||||
/**
|
||||
* Wait for all non-internal services to become healthy by polling their
|
||||
* healthcheck endpoints through the Caddy proxy.
|
||||
* healthcheck endpoints through the gateway's subdomain routing.
|
||||
*
|
||||
* @param port - The host port where Caddy is listening
|
||||
* @param previewId - The preview deployment ID (used as subdomain)
|
||||
* @param gatewayPort - The gateway's host port
|
||||
* @param config - Preview config with service definitions
|
||||
* @param timeoutMs - Maximum time to wait (default: 120s)
|
||||
* @returns Per-service health results
|
||||
*/
|
||||
export async function waitForHealthy(
|
||||
port: number,
|
||||
previewId: string,
|
||||
gatewayPort: number,
|
||||
config: PreviewConfig,
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
): Promise<HealthResult[]> {
|
||||
@@ -57,9 +59,8 @@ export async function waitForHealthy(
|
||||
pending.map(async (svc) => {
|
||||
const route = svc.route ?? '/';
|
||||
const healthPath = svc.healthcheck!.path;
|
||||
// Build URL through proxy route
|
||||
const basePath = route === '/' ? '' : route;
|
||||
const url = `http://127.0.0.1:${port}${basePath}${healthPath}`;
|
||||
const url = `http://${previewId}.localhost:${gatewayPort}${basePath}${healthPath}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
|
||||
@@ -6,9 +6,17 @@ export { PreviewManager } from './manager.js';
|
||||
export { discoverConfig, parseCwPreviewConfig } from './config-reader.js';
|
||||
export {
|
||||
generateComposeFile,
|
||||
generateCaddyfile,
|
||||
generateLabels,
|
||||
} from './compose-generator.js';
|
||||
export {
|
||||
GatewayManager,
|
||||
generateGatewayCaddyfile,
|
||||
} from './gateway.js';
|
||||
export type { GatewayRoute } from './gateway.js';
|
||||
export {
|
||||
createPreviewWorktree,
|
||||
removePreviewWorktree,
|
||||
} from './worktree.js';
|
||||
export {
|
||||
isDockerAvailable,
|
||||
composeUp,
|
||||
@@ -16,12 +24,16 @@ export {
|
||||
composePs,
|
||||
listPreviewProjects,
|
||||
getContainerLabels,
|
||||
ensureDockerNetwork,
|
||||
removeDockerNetwork,
|
||||
dockerNetworkExists,
|
||||
} from './docker-client.js';
|
||||
export { waitForHealthy } from './health-checker.js';
|
||||
export { allocatePort } from './port-allocator.js';
|
||||
export type {
|
||||
PreviewConfig,
|
||||
PreviewServiceConfig,
|
||||
PreviewServiceDevConfig,
|
||||
PreviewStatus,
|
||||
StartPreviewOptions,
|
||||
HealthResult,
|
||||
@@ -29,4 +41,6 @@ export type {
|
||||
export {
|
||||
PREVIEW_LABELS,
|
||||
COMPOSE_PROJECT_PREFIX,
|
||||
GATEWAY_PROJECT_NAME,
|
||||
GATEWAY_NETWORK,
|
||||
} from './types.js';
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { EventBus, DomainEvent } from '../events/types.js';
|
||||
import type { ProjectRepository } from '../db/repositories/project-repository.js';
|
||||
import type { PhaseRepository } from '../db/repositories/phase-repository.js';
|
||||
import type { InitiativeRepository } from '../db/repositories/initiative-repository.js';
|
||||
import { PREVIEW_LABELS, COMPOSE_PROJECT_PREFIX } from './types.js';
|
||||
|
||||
// Mock all external dependencies before imports
|
||||
@@ -11,7 +13,9 @@ vi.mock('./docker-client.js', () => ({
|
||||
composePs: vi.fn(),
|
||||
listPreviewProjects: vi.fn(),
|
||||
getContainerLabels: vi.fn(),
|
||||
getPreviewPorts: vi.fn(),
|
||||
ensureDockerNetwork: vi.fn(),
|
||||
removeDockerNetwork: vi.fn(),
|
||||
dockerNetworkExists: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./config-reader.js', () => ({
|
||||
@@ -26,6 +30,35 @@ vi.mock('./health-checker.js', () => ({
|
||||
waitForHealthy: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./worktree.js', () => ({
|
||||
createPreviewWorktree: vi.fn().mockResolvedValue(undefined),
|
||||
removePreviewWorktree: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('simple-git', () => ({
|
||||
simpleGit: vi.fn(() => ({
|
||||
fetch: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock gateway to prevent it from consuming docker-client mock values
|
||||
const mockGatewayInstance = {
|
||||
ensureGateway: vi.fn().mockResolvedValue(9100),
|
||||
updateRoutes: vi.fn().mockResolvedValue(undefined),
|
||||
stopGateway: vi.fn().mockResolvedValue(undefined),
|
||||
isRunning: vi.fn().mockResolvedValue(false),
|
||||
getPort: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
vi.mock('./gateway.js', () => {
|
||||
const MockGatewayManager = function() {
|
||||
return mockGatewayInstance;
|
||||
};
|
||||
return {
|
||||
GatewayManager: MockGatewayManager,
|
||||
generateGatewayCaddyfile: vi.fn().mockReturnValue(''),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -48,6 +81,7 @@ import {
|
||||
import { discoverConfig } from './config-reader.js';
|
||||
import { allocatePort } from './port-allocator.js';
|
||||
import { waitForHealthy } from './health-checker.js';
|
||||
import { createPreviewWorktree, removePreviewWorktree } from './worktree.js';
|
||||
import { mkdir, writeFile, rm } from 'node:fs/promises';
|
||||
import type { PreviewConfig } from './types.js';
|
||||
|
||||
@@ -60,6 +94,7 @@ const mockGetContainerLabels = vi.mocked(getContainerLabels);
|
||||
const mockDiscoverConfig = vi.mocked(discoverConfig);
|
||||
const mockAllocatePort = vi.mocked(allocatePort);
|
||||
const mockWaitForHealthy = vi.mocked(waitForHealthy);
|
||||
const mockCreatePreviewWorktree = vi.mocked(createPreviewWorktree);
|
||||
const mockMkdir = vi.mocked(mkdir);
|
||||
const mockWriteFile = vi.mocked(writeFile);
|
||||
const mockRm = vi.mocked(rm);
|
||||
@@ -96,9 +131,33 @@ function createMockProjectRepo(project = {
|
||||
delete: vi.fn(),
|
||||
setInitiativeProjects: vi.fn(),
|
||||
getInitiativeProjects: vi.fn(),
|
||||
findProjectsByInitiativeId: vi.fn().mockResolvedValue([project]),
|
||||
} as unknown as ProjectRepository;
|
||||
}
|
||||
|
||||
function createMockPhaseRepo(): PhaseRepository {
|
||||
return {
|
||||
findById: vi.fn(),
|
||||
findByInitiativeId: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
getNextNumber: vi.fn(),
|
||||
findByNumber: vi.fn(),
|
||||
} as unknown as PhaseRepository;
|
||||
}
|
||||
|
||||
function createMockInitiativeRepo(): InitiativeRepository {
|
||||
return {
|
||||
findById: vi.fn(),
|
||||
findAll: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
findByStatus: vi.fn(),
|
||||
} as unknown as InitiativeRepository;
|
||||
}
|
||||
|
||||
const WORKSPACE_ROOT = '/tmp/test-workspace';
|
||||
|
||||
const SIMPLE_CONFIG: PreviewConfig = {
|
||||
@@ -117,24 +176,33 @@ describe('PreviewManager', () => {
|
||||
let manager: PreviewManager;
|
||||
let eventBus: EventBus & { emitted: DomainEvent[] };
|
||||
let projectRepo: ProjectRepository;
|
||||
let phaseRepo: PhaseRepository;
|
||||
let initiativeRepo: InitiativeRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset gateway mock to defaults after clearAllMocks wipes implementations
|
||||
mockGatewayInstance.ensureGateway.mockResolvedValue(9100);
|
||||
mockGatewayInstance.updateRoutes.mockResolvedValue(undefined);
|
||||
mockGatewayInstance.stopGateway.mockResolvedValue(undefined);
|
||||
mockGatewayInstance.isRunning.mockResolvedValue(false);
|
||||
mockGatewayInstance.getPort.mockResolvedValue(null);
|
||||
|
||||
eventBus = createMockEventBus();
|
||||
projectRepo = createMockProjectRepo();
|
||||
manager = new PreviewManager(projectRepo, eventBus, WORKSPACE_ROOT);
|
||||
phaseRepo = createMockPhaseRepo();
|
||||
initiativeRepo = createMockInitiativeRepo();
|
||||
manager = new PreviewManager(projectRepo, eventBus, WORKSPACE_ROOT, phaseRepo, initiativeRepo);
|
||||
});
|
||||
|
||||
describe('start', () => {
|
||||
it('completes the full start lifecycle for a healthy service', async () => {
|
||||
it('completes the full start lifecycle with gateway architecture', async () => {
|
||||
mockIsDockerAvailable.mockResolvedValue(true);
|
||||
mockDiscoverConfig.mockResolvedValue(SIMPLE_CONFIG);
|
||||
mockAllocatePort.mockResolvedValue(9100);
|
||||
mockComposeUp.mockResolvedValue(undefined);
|
||||
mockComposePs.mockResolvedValue([{ name: 'app', state: 'running', health: 'healthy' }]);
|
||||
mockDiscoverConfig.mockResolvedValue(SIMPLE_CONFIG);
|
||||
mockCreatePreviewWorktree.mockResolvedValue(undefined);
|
||||
mockWaitForHealthy.mockResolvedValue([{ name: 'app', healthy: true }]);
|
||||
mockComposePs.mockResolvedValue([
|
||||
{ name: 'app', state: 'running', health: 'healthy' },
|
||||
]);
|
||||
|
||||
const result = await manager.start({
|
||||
initiativeId: 'init-1',
|
||||
@@ -142,57 +210,37 @@ describe('PreviewManager', () => {
|
||||
branch: 'feature-x',
|
||||
});
|
||||
|
||||
// Verify returned status
|
||||
// Verify returned status uses gateway fields
|
||||
expect(result.id).toBe('abc123test');
|
||||
expect(result.projectName).toBe('cw-preview-abc123test');
|
||||
expect(result.initiativeId).toBe('init-1');
|
||||
expect(result.projectId).toBe('proj-1');
|
||||
expect(result.branch).toBe('feature-x');
|
||||
expect(result.port).toBe(9100);
|
||||
expect(result.gatewayPort).toBe(9100);
|
||||
expect(result.url).toBe('http://abc123test.localhost:9100');
|
||||
expect(result.mode).toBe('preview');
|
||||
expect(result.status).toBe('running');
|
||||
expect(result.services).toHaveLength(1);
|
||||
|
||||
// Verify Docker was called
|
||||
expect(mockIsDockerAvailable).toHaveBeenCalledOnce();
|
||||
expect(mockComposeUp).toHaveBeenCalledWith(
|
||||
expect.stringContaining('.cw-previews/abc123test/docker-compose.yml'),
|
||||
'cw-preview-abc123test',
|
||||
);
|
||||
|
||||
// Verify compose artifacts were written
|
||||
expect(mockMkdir).toHaveBeenCalledWith(
|
||||
expect.stringContaining('.cw-previews/abc123test'),
|
||||
{ recursive: true },
|
||||
);
|
||||
expect(mockWriteFile).toHaveBeenCalledTimes(2); // compose + Caddyfile
|
||||
// Verify worktree was created for preview mode
|
||||
expect(mockCreatePreviewWorktree).toHaveBeenCalledOnce();
|
||||
|
||||
// Verify events: building then ready
|
||||
expect(eventBus.emitted).toHaveLength(2);
|
||||
expect(eventBus.emitted[0].type).toBe('preview:building');
|
||||
expect(eventBus.emitted[0].payload).toEqual(
|
||||
expect.objectContaining({
|
||||
previewId: 'abc123test',
|
||||
initiativeId: 'init-1',
|
||||
branch: 'feature-x',
|
||||
port: 9100,
|
||||
}),
|
||||
);
|
||||
expect(eventBus.emitted[1].type).toBe('preview:ready');
|
||||
expect(eventBus.emitted[1].payload).toEqual(
|
||||
expect.objectContaining({
|
||||
previewId: 'abc123test',
|
||||
url: 'http://localhost:9100',
|
||||
}),
|
||||
const buildingEvent = eventBus.emitted.find((e) => e.type === 'preview:building');
|
||||
const readyEvent = eventBus.emitted.find((e) => e.type === 'preview:ready');
|
||||
expect(buildingEvent).toBeDefined();
|
||||
expect(readyEvent).toBeDefined();
|
||||
expect((readyEvent!.payload as Record<string, unknown>).url).toBe(
|
||||
'http://abc123test.localhost:9100',
|
||||
);
|
||||
});
|
||||
|
||||
it('includes phaseId when provided', async () => {
|
||||
mockIsDockerAvailable.mockResolvedValue(true);
|
||||
mockDiscoverConfig.mockResolvedValue(SIMPLE_CONFIG);
|
||||
mockAllocatePort.mockResolvedValue(9100);
|
||||
mockComposeUp.mockResolvedValue(undefined);
|
||||
mockWaitForHealthy.mockResolvedValue([]);
|
||||
mockComposePs.mockResolvedValue([]);
|
||||
mockDiscoverConfig.mockResolvedValue(SIMPLE_CONFIG);
|
||||
mockCreatePreviewWorktree.mockResolvedValue(undefined);
|
||||
mockWaitForHealthy.mockResolvedValue([]);
|
||||
|
||||
const result = await manager.start({
|
||||
initiativeId: 'init-1',
|
||||
@@ -223,7 +271,7 @@ describe('PreviewManager', () => {
|
||||
mockIsDockerAvailable.mockResolvedValue(true);
|
||||
projectRepo = createMockProjectRepo();
|
||||
(projectRepo.findById as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
manager = new PreviewManager(projectRepo, eventBus, WORKSPACE_ROOT);
|
||||
manager = new PreviewManager(projectRepo, eventBus, WORKSPACE_ROOT, phaseRepo, initiativeRepo);
|
||||
|
||||
await expect(
|
||||
manager.start({
|
||||
@@ -237,7 +285,7 @@ describe('PreviewManager', () => {
|
||||
it('emits preview:failed and cleans up when compose up fails', async () => {
|
||||
mockIsDockerAvailable.mockResolvedValue(true);
|
||||
mockDiscoverConfig.mockResolvedValue(SIMPLE_CONFIG);
|
||||
mockAllocatePort.mockResolvedValue(9100);
|
||||
mockCreatePreviewWorktree.mockResolvedValue(undefined);
|
||||
mockComposeUp.mockRejectedValue(new Error('build error: Dockerfile not found'));
|
||||
mockComposeDown.mockResolvedValue(undefined);
|
||||
|
||||
@@ -247,52 +295,11 @@ describe('PreviewManager', () => {
|
||||
projectId: 'proj-1',
|
||||
branch: 'main',
|
||||
}),
|
||||
).rejects.toThrow('Preview build failed: build error: Dockerfile not found');
|
||||
).rejects.toThrow('Preview build failed');
|
||||
|
||||
// Events: building, then failed
|
||||
expect(eventBus.emitted).toHaveLength(2);
|
||||
expect(eventBus.emitted[0].type).toBe('preview:building');
|
||||
expect(eventBus.emitted[1].type).toBe('preview:failed');
|
||||
expect((eventBus.emitted[1].payload as Record<string, unknown>).error).toBe(
|
||||
'build error: Dockerfile not found',
|
||||
);
|
||||
|
||||
// Cleanup was attempted
|
||||
expect(mockComposeDown).toHaveBeenCalledWith('cw-preview-abc123test');
|
||||
expect(mockRm).toHaveBeenCalledWith(
|
||||
expect.stringContaining('.cw-previews/abc123test'),
|
||||
{ recursive: true, force: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('emits preview:failed and cleans up when health checks fail', async () => {
|
||||
mockIsDockerAvailable.mockResolvedValue(true);
|
||||
mockDiscoverConfig.mockResolvedValue(SIMPLE_CONFIG);
|
||||
mockAllocatePort.mockResolvedValue(9101);
|
||||
mockComposeUp.mockResolvedValue(undefined);
|
||||
mockWaitForHealthy.mockResolvedValue([
|
||||
{ name: 'app', healthy: false, error: 'health check timed out' },
|
||||
]);
|
||||
mockComposeDown.mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
manager.start({
|
||||
initiativeId: 'init-1',
|
||||
projectId: 'proj-1',
|
||||
branch: 'main',
|
||||
}),
|
||||
).rejects.toThrow('Preview health checks failed for services: app');
|
||||
|
||||
// Events: building, then failed
|
||||
expect(eventBus.emitted).toHaveLength(2);
|
||||
expect(eventBus.emitted[1].type).toBe('preview:failed');
|
||||
expect((eventBus.emitted[1].payload as Record<string, unknown>).error).toBe(
|
||||
'Health checks failed for: app',
|
||||
);
|
||||
|
||||
// Cleanup
|
||||
expect(mockComposeDown).toHaveBeenCalled();
|
||||
expect(mockRm).toHaveBeenCalled();
|
||||
const failedEvent = eventBus.emitted.find((e) => e.type === 'preview:failed');
|
||||
expect(failedEvent).toBeDefined();
|
||||
});
|
||||
|
||||
it('succeeds when no healthcheck endpoints are configured', async () => {
|
||||
@@ -302,11 +309,11 @@ describe('PreviewManager', () => {
|
||||
};
|
||||
|
||||
mockIsDockerAvailable.mockResolvedValue(true);
|
||||
mockDiscoverConfig.mockResolvedValue(noHealthConfig);
|
||||
mockAllocatePort.mockResolvedValue(9100);
|
||||
mockComposeUp.mockResolvedValue(undefined);
|
||||
mockWaitForHealthy.mockResolvedValue([]); // no health endpoints → empty results
|
||||
mockComposePs.mockResolvedValue([{ name: 'app', state: 'running', health: 'none' }]);
|
||||
mockDiscoverConfig.mockResolvedValue(noHealthConfig);
|
||||
mockCreatePreviewWorktree.mockResolvedValue(undefined);
|
||||
mockWaitForHealthy.mockResolvedValue([]);
|
||||
|
||||
const result = await manager.start({
|
||||
initiativeId: 'init-1',
|
||||
@@ -315,8 +322,8 @@ describe('PreviewManager', () => {
|
||||
});
|
||||
|
||||
expect(result.status).toBe('running');
|
||||
// Should succeed — empty health results means allHealthy is vacuously true
|
||||
expect(eventBus.emitted[1].type).toBe('preview:ready');
|
||||
const readyEvent = eventBus.emitted.find((e) => e.type === 'preview:ready');
|
||||
expect(readyEvent).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -325,6 +332,8 @@ describe('PreviewManager', () => {
|
||||
mockGetContainerLabels.mockResolvedValue({
|
||||
[PREVIEW_LABELS.preview]: 'true',
|
||||
[PREVIEW_LABELS.initiativeId]: 'init-1',
|
||||
[PREVIEW_LABELS.mode]: 'preview',
|
||||
[PREVIEW_LABELS.projectId]: 'proj-1',
|
||||
});
|
||||
mockComposeDown.mockResolvedValue(undefined);
|
||||
|
||||
@@ -336,14 +345,10 @@ describe('PreviewManager', () => {
|
||||
{ recursive: true, force: true },
|
||||
);
|
||||
|
||||
expect(eventBus.emitted).toHaveLength(1);
|
||||
expect(eventBus.emitted[0].type).toBe('preview:stopped');
|
||||
expect(eventBus.emitted[0].payload).toEqual(
|
||||
expect.objectContaining({
|
||||
previewId: 'abc123test',
|
||||
initiativeId: 'init-1',
|
||||
}),
|
||||
);
|
||||
const stoppedEvent = eventBus.emitted.find((e) => e.type === 'preview:stopped');
|
||||
expect(stoppedEvent).toBeDefined();
|
||||
expect((stoppedEvent!.payload as Record<string, unknown>).previewId).toBe('abc123test');
|
||||
expect((stoppedEvent!.payload as Record<string, unknown>).initiativeId).toBe('init-1');
|
||||
});
|
||||
|
||||
it('emits empty initiativeId when labels are missing', async () => {
|
||||
@@ -352,8 +357,9 @@ describe('PreviewManager', () => {
|
||||
|
||||
await manager.stop('xyz');
|
||||
|
||||
expect(eventBus.emitted).toHaveLength(1);
|
||||
expect((eventBus.emitted[0].payload as Record<string, unknown>).initiativeId).toBe('');
|
||||
const stoppedEvent = eventBus.emitted.find((e) => e.type === 'preview:stopped');
|
||||
expect(stoppedEvent).toBeDefined();
|
||||
expect((stoppedEvent!.payload as Record<string, unknown>).initiativeId).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -372,14 +378,16 @@ describe('PreviewManager', () => {
|
||||
[PREVIEW_LABELS.branch]: 'feat-a',
|
||||
[PREVIEW_LABELS.port]: '9100',
|
||||
[PREVIEW_LABELS.previewId]: 'aaa',
|
||||
[PREVIEW_LABELS.mode]: 'preview',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
[PREVIEW_LABELS.preview]: 'true',
|
||||
[PREVIEW_LABELS.initiativeId]: 'init-2',
|
||||
[PREVIEW_LABELS.projectId]: 'proj-2',
|
||||
[PREVIEW_LABELS.branch]: 'feat-b',
|
||||
[PREVIEW_LABELS.port]: '9101',
|
||||
[PREVIEW_LABELS.port]: '9100',
|
||||
[PREVIEW_LABELS.previewId]: 'bbb',
|
||||
[PREVIEW_LABELS.mode]: 'dev',
|
||||
});
|
||||
|
||||
mockComposePs
|
||||
@@ -390,10 +398,13 @@ describe('PreviewManager', () => {
|
||||
|
||||
expect(previews).toHaveLength(2);
|
||||
expect(previews[0].id).toBe('aaa');
|
||||
expect(previews[0].port).toBe(9100);
|
||||
expect(previews[0].gatewayPort).toBe(9100);
|
||||
expect(previews[0].url).toBe('http://aaa.localhost:9100');
|
||||
expect(previews[0].mode).toBe('preview');
|
||||
expect(previews[0].services).toHaveLength(1);
|
||||
expect(previews[1].id).toBe('bbb');
|
||||
expect(previews[1].port).toBe(9101);
|
||||
expect(previews[1].gatewayPort).toBe(9100);
|
||||
expect(previews[1].mode).toBe('dev');
|
||||
});
|
||||
|
||||
it('filters by initiativeId when provided', async () => {
|
||||
@@ -416,7 +427,7 @@ describe('PreviewManager', () => {
|
||||
[PREVIEW_LABELS.initiativeId]: 'init-2',
|
||||
[PREVIEW_LABELS.projectId]: 'proj-2',
|
||||
[PREVIEW_LABELS.branch]: 'feat-b',
|
||||
[PREVIEW_LABELS.port]: '9101',
|
||||
[PREVIEW_LABELS.port]: '9100',
|
||||
[PREVIEW_LABELS.previewId]: 'bbb',
|
||||
});
|
||||
|
||||
@@ -428,14 +439,28 @@ describe('PreviewManager', () => {
|
||||
expect(previews[0].initiativeId).toBe('init-1');
|
||||
});
|
||||
|
||||
it('skips projects without cw.preview label', async () => {
|
||||
it('skips gateway project from listing', async () => {
|
||||
mockListPreviewProjects.mockResolvedValue([
|
||||
{ Name: 'cw-preview-orphan', Status: 'running(1)', ConfigFiles: '' },
|
||||
{ Name: 'cw-preview-gateway', Status: 'running(1)', ConfigFiles: '' },
|
||||
{ Name: 'cw-preview-aaa', Status: 'running(1)', ConfigFiles: '' },
|
||||
]);
|
||||
mockGetContainerLabels.mockResolvedValue({}); // no cw.preview label
|
||||
|
||||
mockGetContainerLabels.mockResolvedValue({
|
||||
[PREVIEW_LABELS.preview]: 'true',
|
||||
[PREVIEW_LABELS.initiativeId]: 'init-1',
|
||||
[PREVIEW_LABELS.projectId]: 'proj-1',
|
||||
[PREVIEW_LABELS.branch]: 'main',
|
||||
[PREVIEW_LABELS.port]: '9100',
|
||||
[PREVIEW_LABELS.previewId]: 'aaa',
|
||||
});
|
||||
|
||||
mockComposePs.mockResolvedValue([{ name: 'app', state: 'running', health: 'none' }]);
|
||||
|
||||
const previews = await manager.list();
|
||||
expect(previews).toHaveLength(0);
|
||||
|
||||
// Should only include actual previews, not gateway
|
||||
expect(previews).toHaveLength(1);
|
||||
expect(previews[0].id).toBe('aaa');
|
||||
});
|
||||
|
||||
it('skips projects with incomplete labels', async () => {
|
||||
@@ -460,13 +485,13 @@ describe('PreviewManager', () => {
|
||||
[PREVIEW_LABELS.branch]: 'main',
|
||||
[PREVIEW_LABELS.port]: '9100',
|
||||
[PREVIEW_LABELS.previewId]: 'abc',
|
||||
[PREVIEW_LABELS.mode]: 'preview',
|
||||
};
|
||||
|
||||
it('returns running when all services are running', async () => {
|
||||
mockGetContainerLabels.mockResolvedValue(labels);
|
||||
mockComposePs.mockResolvedValue([
|
||||
{ name: 'app', state: 'running', health: 'healthy' },
|
||||
{ name: 'caddy-proxy', state: 'running', health: 'none' },
|
||||
]);
|
||||
|
||||
const status = await manager.getStatus('abc');
|
||||
@@ -474,14 +499,15 @@ describe('PreviewManager', () => {
|
||||
expect(status).not.toBeNull();
|
||||
expect(status!.status).toBe('running');
|
||||
expect(status!.id).toBe('abc');
|
||||
expect(status!.port).toBe(9100);
|
||||
expect(status!.gatewayPort).toBe(9100);
|
||||
expect(status!.url).toBe('http://abc.localhost:9100');
|
||||
expect(status!.mode).toBe('preview');
|
||||
});
|
||||
|
||||
it('returns failed when any service is exited', async () => {
|
||||
mockGetContainerLabels.mockResolvedValue(labels);
|
||||
mockComposePs.mockResolvedValue([
|
||||
{ name: 'app', state: 'exited', health: 'none' },
|
||||
{ name: 'caddy-proxy', state: 'running', health: 'none' },
|
||||
]);
|
||||
|
||||
const status = await manager.getStatus('abc');
|
||||
@@ -515,8 +541,9 @@ describe('PreviewManager', () => {
|
||||
});
|
||||
|
||||
describe('stopAll', () => {
|
||||
it('stops all preview projects', async () => {
|
||||
it('stops all preview projects and the gateway', async () => {
|
||||
mockListPreviewProjects.mockResolvedValue([
|
||||
{ Name: 'cw-preview-gateway', Status: 'running(1)', ConfigFiles: '' },
|
||||
{ Name: 'cw-preview-aaa', Status: 'running(2)', ConfigFiles: '' },
|
||||
{ Name: 'cw-preview-bbb', Status: 'running(1)', ConfigFiles: '' },
|
||||
]);
|
||||
@@ -529,32 +556,12 @@ describe('PreviewManager', () => {
|
||||
|
||||
await manager.stopAll();
|
||||
|
||||
expect(mockComposeDown).toHaveBeenCalledTimes(2);
|
||||
// Should stop preview projects but not call stop() on gateway directly
|
||||
// (gateway is handled separately via stopGateway)
|
||||
expect(mockComposeDown).toHaveBeenCalledWith('cw-preview-aaa');
|
||||
expect(mockComposeDown).toHaveBeenCalledWith('cw-preview-bbb');
|
||||
expect(eventBus.emitted.filter((e) => e.type === 'preview:stopped')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('continues stopping other previews when one fails', async () => {
|
||||
mockListPreviewProjects.mockResolvedValue([
|
||||
{ Name: 'cw-preview-fail', Status: 'running(1)', ConfigFiles: '' },
|
||||
{ Name: 'cw-preview-ok', Status: 'running(1)', ConfigFiles: '' },
|
||||
]);
|
||||
|
||||
mockGetContainerLabels.mockResolvedValue({
|
||||
[PREVIEW_LABELS.initiativeId]: 'init-1',
|
||||
});
|
||||
|
||||
// First stop fails, second succeeds
|
||||
mockComposeDown
|
||||
.mockRejectedValueOnce(new Error('docker daemon not responding'))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
// Should not throw — errors are caught per-project
|
||||
await manager.stopAll();
|
||||
|
||||
// Second preview still stopped successfully
|
||||
expect(mockComposeDown).toHaveBeenCalledTimes(2);
|
||||
// Gateway is stopped via the mocked GatewayManager.stopGateway()
|
||||
expect(mockGatewayInstance.stopGateway).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles empty project list gracefully', async () => {
|
||||
@@ -562,8 +569,9 @@ describe('PreviewManager', () => {
|
||||
|
||||
await manager.stopAll();
|
||||
|
||||
// No preview composeDown calls, but gateway stopGateway still called
|
||||
expect(mockComposeDown).not.toHaveBeenCalled();
|
||||
expect(eventBus.emitted).toHaveLength(0);
|
||||
expect(mockGatewayInstance.stopGateway).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,21 +2,25 @@
|
||||
* Preview Manager
|
||||
*
|
||||
* Orchestrates preview deployment lifecycle: start, stop, list, status.
|
||||
* Uses Docker as the source of truth — no database persistence.
|
||||
* Uses a shared gateway (single Caddy container) for subdomain-based routing.
|
||||
* Docker is the source of truth — no database persistence.
|
||||
*/
|
||||
|
||||
import { join } from 'node:path';
|
||||
import { mkdir, writeFile, rm } from 'node:fs/promises';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { ProjectRepository } from '../db/repositories/project-repository.js';
|
||||
import type { PhaseRepository } from '../db/repositories/phase-repository.js';
|
||||
import type { InitiativeRepository } from '../db/repositories/initiative-repository.js';
|
||||
import type { EventBus } from '../events/types.js';
|
||||
import type {
|
||||
PreviewStatus,
|
||||
StartPreviewOptions,
|
||||
PreviewConfig,
|
||||
} from './types.js';
|
||||
import { COMPOSE_PROJECT_PREFIX, PREVIEW_LABELS } from './types.js';
|
||||
import { discoverConfig } from './config-reader.js';
|
||||
import { generateComposeFile, generateCaddyfile, generateLabels } from './compose-generator.js';
|
||||
import { generateComposeFile, generateLabels } from './compose-generator.js';
|
||||
import {
|
||||
isDockerAvailable,
|
||||
composeUp,
|
||||
@@ -26,14 +30,18 @@ import {
|
||||
getContainerLabels,
|
||||
} from './docker-client.js';
|
||||
import { waitForHealthy } from './health-checker.js';
|
||||
import { allocatePort } from './port-allocator.js';
|
||||
import { GatewayManager } from './gateway.js';
|
||||
import type { GatewayRoute } from './gateway.js';
|
||||
import { createPreviewWorktree, removePreviewWorktree } from './worktree.js';
|
||||
import { getProjectCloneDir } from '../git/project-clones.js';
|
||||
import { phaseBranchName } from '../git/branch-naming.js';
|
||||
import { createModuleLogger } from '../logger/index.js';
|
||||
import type {
|
||||
PreviewBuildingEvent,
|
||||
PreviewReadyEvent,
|
||||
PreviewStoppedEvent,
|
||||
PreviewFailedEvent,
|
||||
PhasePendingReviewEvent,
|
||||
} from '../events/types.js';
|
||||
|
||||
const log = createModuleLogger('preview');
|
||||
@@ -45,29 +53,44 @@ export class PreviewManager {
|
||||
private readonly projectRepository: ProjectRepository;
|
||||
private readonly eventBus: EventBus;
|
||||
private readonly workspaceRoot: string;
|
||||
private readonly phaseRepository: PhaseRepository;
|
||||
private readonly initiativeRepository: InitiativeRepository;
|
||||
private readonly gatewayManager: GatewayManager;
|
||||
|
||||
/** In-memory tracking of active preview routes for fast Caddyfile regeneration */
|
||||
private readonly activeRoutes = new Map<string, GatewayRoute[]>();
|
||||
|
||||
constructor(
|
||||
projectRepository: ProjectRepository,
|
||||
eventBus: EventBus,
|
||||
workspaceRoot: string,
|
||||
phaseRepository: PhaseRepository,
|
||||
initiativeRepository: InitiativeRepository,
|
||||
) {
|
||||
this.projectRepository = projectRepository;
|
||||
this.eventBus = eventBus;
|
||||
this.workspaceRoot = workspaceRoot;
|
||||
this.phaseRepository = phaseRepository;
|
||||
this.initiativeRepository = initiativeRepository;
|
||||
this.gatewayManager = new GatewayManager(workspaceRoot);
|
||||
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a preview deployment.
|
||||
*
|
||||
* 1. Check Docker availability
|
||||
* 2. Resolve project clone path
|
||||
* 3. Discover config from project at target branch
|
||||
* 4. Allocate port, generate ID
|
||||
* 5. Generate compose + Caddyfile, write to .cw-previews/<id>/
|
||||
* 6. Run composeUp, wait for healthy
|
||||
* 2. Ensure gateway is running
|
||||
* 3. Resolve project + clone path
|
||||
* 4. Preview mode: fetch + worktree; Dev mode: use provided worktreePath
|
||||
* 5. Discover config, generate compose (no Caddy sidecar)
|
||||
* 6. composeUp, update gateway routes, health check
|
||||
* 7. Emit events and return status
|
||||
*/
|
||||
async start(options: StartPreviewOptions): Promise<PreviewStatus> {
|
||||
const mode = options.mode ?? 'preview';
|
||||
|
||||
// 1. Check Docker
|
||||
if (!(await isDockerAvailable())) {
|
||||
throw new Error(
|
||||
@@ -75,7 +98,10 @@ export class PreviewManager {
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Resolve project
|
||||
// 2. Ensure gateway
|
||||
const gatewayPort = await this.gatewayManager.ensureGateway();
|
||||
|
||||
// 3. Resolve project
|
||||
const project = await this.projectRepository.findById(options.projectId);
|
||||
if (!project) {
|
||||
throw new Error(`Project '${options.projectId}' not found`);
|
||||
@@ -86,74 +112,87 @@ export class PreviewManager {
|
||||
getProjectCloneDir(project.name, project.id),
|
||||
);
|
||||
|
||||
// 3. Discover config
|
||||
const config = await discoverConfig(clonePath);
|
||||
|
||||
// 4. Allocate port and generate ID
|
||||
const port = await allocatePort();
|
||||
// 4. Generate ID and prepare deploy dir
|
||||
const id = nanoid(10);
|
||||
const projectName = `${COMPOSE_PROJECT_PREFIX}${id}`;
|
||||
const deployDir = join(this.workspaceRoot, PREVIEWS_DIR, id);
|
||||
await mkdir(deployDir, { recursive: true });
|
||||
|
||||
// 5. Generate compose artifacts
|
||||
let sourcePath: string;
|
||||
let worktreeCreated = false;
|
||||
|
||||
try {
|
||||
if (mode === 'preview') {
|
||||
// Fetch latest and create a worktree for the target branch
|
||||
try {
|
||||
const { simpleGit } = await import('simple-git');
|
||||
await simpleGit(clonePath).fetch();
|
||||
} catch (fetchErr) {
|
||||
log.warn({ err: fetchErr }, 'git fetch failed (may be offline)');
|
||||
}
|
||||
|
||||
const worktreePath = join(deployDir, 'source');
|
||||
await createPreviewWorktree(clonePath, options.branch, worktreePath);
|
||||
worktreeCreated = true;
|
||||
sourcePath = worktreePath;
|
||||
} else {
|
||||
// Dev mode: use the provided worktree path
|
||||
if (!options.worktreePath) {
|
||||
throw new Error('worktreePath is required for dev mode');
|
||||
}
|
||||
sourcePath = options.worktreePath;
|
||||
}
|
||||
|
||||
// 5. Discover config from source
|
||||
const config = await discoverConfig(sourcePath);
|
||||
|
||||
// 6. Generate compose artifacts
|
||||
const labels = generateLabels({
|
||||
initiativeId: options.initiativeId,
|
||||
phaseId: options.phaseId,
|
||||
projectId: options.projectId,
|
||||
branch: options.branch,
|
||||
port,
|
||||
gatewayPort,
|
||||
previewId: id,
|
||||
mode,
|
||||
});
|
||||
|
||||
const composeYaml = generateComposeFile(config, {
|
||||
projectPath: clonePath,
|
||||
port,
|
||||
projectPath: sourcePath,
|
||||
deploymentId: id,
|
||||
labels,
|
||||
mode,
|
||||
});
|
||||
const caddyfile = generateCaddyfile(config);
|
||||
|
||||
// Write artifacts
|
||||
const deployDir = join(this.workspaceRoot, PREVIEWS_DIR, id);
|
||||
await mkdir(deployDir, { recursive: true });
|
||||
|
||||
const composePath = join(deployDir, 'docker-compose.yml');
|
||||
await writeFile(composePath, composeYaml, 'utf-8');
|
||||
await writeFile(join(deployDir, 'Caddyfile'), caddyfile, 'utf-8');
|
||||
|
||||
log.info({ id, projectName, port, composePath }, 'preview deployment prepared');
|
||||
log.info({ id, projectName, gatewayPort, composePath, mode }, 'preview deployment prepared');
|
||||
|
||||
// 6. Emit building event
|
||||
// Emit building event
|
||||
this.eventBus.emit<PreviewBuildingEvent>({
|
||||
type: 'preview:building',
|
||||
timestamp: new Date(),
|
||||
payload: { previewId: id, initiativeId: options.initiativeId, branch: options.branch, port },
|
||||
});
|
||||
|
||||
// 7. Build and start
|
||||
try {
|
||||
await composeUp(composePath, projectName);
|
||||
} catch (error) {
|
||||
log.error({ id, err: error }, 'compose up failed');
|
||||
|
||||
this.eventBus.emit<PreviewFailedEvent>({
|
||||
type: 'preview:failed',
|
||||
timestamp: new Date(),
|
||||
payload: {
|
||||
previewId: id,
|
||||
initiativeId: options.initiativeId,
|
||||
error: (error as Error).message,
|
||||
branch: options.branch,
|
||||
gatewayPort,
|
||||
mode,
|
||||
phaseId: options.phaseId,
|
||||
},
|
||||
});
|
||||
|
||||
// Clean up
|
||||
await composeDown(projectName).catch(() => {});
|
||||
await rm(deployDir, { recursive: true, force: true }).catch(() => {});
|
||||
// 7. Build and start
|
||||
await composeUp(composePath, projectName);
|
||||
|
||||
throw new Error(`Preview build failed: ${(error as Error).message}`);
|
||||
}
|
||||
// 8. Build gateway routes and update Caddyfile
|
||||
const routes = this.buildRoutes(id, config);
|
||||
this.activeRoutes.set(id, routes);
|
||||
await this.gatewayManager.updateRoutes(this.activeRoutes);
|
||||
|
||||
// 8. Health check
|
||||
const healthResults = await waitForHealthy(port, config);
|
||||
// 9. Health check
|
||||
const healthResults = await waitForHealthy(id, gatewayPort, config);
|
||||
const allHealthy = healthResults.every((r) => r.healthy);
|
||||
|
||||
if (!allHealthy && healthResults.length > 0) {
|
||||
@@ -172,16 +211,27 @@ export class PreviewManager {
|
||||
},
|
||||
});
|
||||
|
||||
// Clean up
|
||||
await composeDown(projectName).catch(() => {});
|
||||
this.activeRoutes.delete(id);
|
||||
await this.gatewayManager.updateRoutes(this.activeRoutes);
|
||||
if (worktreeCreated) {
|
||||
await removePreviewWorktree(clonePath, join(deployDir, 'source')).catch(() => {});
|
||||
}
|
||||
await rm(deployDir, { recursive: true, force: true }).catch(() => {});
|
||||
|
||||
// Stop gateway if no more previews
|
||||
if (this.activeRoutes.size === 0) {
|
||||
await this.gatewayManager.stopGateway().catch(() => {});
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Preview health checks failed for services: ${failedServices.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 9. Success
|
||||
const url = `http://localhost:${port}`;
|
||||
// 10. Success
|
||||
const url = `http://${id}.localhost:${gatewayPort}`;
|
||||
log.info({ id, url }, 'preview deployment ready');
|
||||
|
||||
this.eventBus.emit<PreviewReadyEvent>({
|
||||
@@ -191,8 +241,10 @@ export class PreviewManager {
|
||||
previewId: id,
|
||||
initiativeId: options.initiativeId,
|
||||
branch: options.branch,
|
||||
port,
|
||||
gatewayPort,
|
||||
url,
|
||||
mode,
|
||||
phaseId: options.phaseId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -205,11 +257,44 @@ export class PreviewManager {
|
||||
phaseId: options.phaseId,
|
||||
projectId: options.projectId,
|
||||
branch: options.branch,
|
||||
port,
|
||||
gatewayPort,
|
||||
url,
|
||||
mode,
|
||||
status: 'running',
|
||||
services,
|
||||
composePath,
|
||||
};
|
||||
} catch (error) {
|
||||
// Clean up on any failure
|
||||
if (worktreeCreated) {
|
||||
await removePreviewWorktree(clonePath, join(deployDir, 'source')).catch(() => {});
|
||||
}
|
||||
|
||||
// Only emit failed if we haven't already (health check path emits its own)
|
||||
const isHealthCheckError = (error as Error).message?.includes('health checks failed');
|
||||
if (!isHealthCheckError) {
|
||||
this.eventBus.emit<PreviewFailedEvent>({
|
||||
type: 'preview:failed',
|
||||
timestamp: new Date(),
|
||||
payload: {
|
||||
previewId: id,
|
||||
initiativeId: options.initiativeId,
|
||||
error: (error as Error).message,
|
||||
},
|
||||
});
|
||||
|
||||
await composeDown(projectName).catch(() => {});
|
||||
this.activeRoutes.delete(id);
|
||||
await this.gatewayManager.updateRoutes(this.activeRoutes).catch(() => {});
|
||||
await rm(deployDir, { recursive: true, force: true }).catch(() => {});
|
||||
|
||||
if (this.activeRoutes.size === 0) {
|
||||
await this.gatewayManager.stopGateway().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Preview build failed: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -218,16 +303,41 @@ export class PreviewManager {
|
||||
async stop(previewId: string): Promise<void> {
|
||||
const projectName = `${COMPOSE_PROJECT_PREFIX}${previewId}`;
|
||||
|
||||
// Get labels before stopping to emit event
|
||||
// Get labels before stopping to emit event and check mode
|
||||
const labels = await getContainerLabels(projectName);
|
||||
const initiativeId = labels[PREVIEW_LABELS.initiativeId] ?? '';
|
||||
const mode = labels[PREVIEW_LABELS.mode] as 'preview' | 'dev' | undefined;
|
||||
|
||||
await composeDown(projectName);
|
||||
|
||||
// Clean up deployment directory
|
||||
// Remove worktree if preview mode
|
||||
const deployDir = join(this.workspaceRoot, PREVIEWS_DIR, previewId);
|
||||
if (mode === 'preview' || mode === undefined) {
|
||||
const projectId = labels[PREVIEW_LABELS.projectId];
|
||||
if (projectId) {
|
||||
const project = await this.projectRepository.findById(projectId);
|
||||
if (project) {
|
||||
const clonePath = join(
|
||||
this.workspaceRoot,
|
||||
getProjectCloneDir(project.name, project.id),
|
||||
);
|
||||
await removePreviewWorktree(clonePath, join(deployDir, 'source')).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up deployment directory
|
||||
await rm(deployDir, { recursive: true, force: true }).catch(() => {});
|
||||
|
||||
// Update gateway routes
|
||||
this.activeRoutes.delete(previewId);
|
||||
await this.gatewayManager.updateRoutes(this.activeRoutes).catch(() => {});
|
||||
|
||||
// Stop gateway if no more active previews
|
||||
if (this.activeRoutes.size === 0) {
|
||||
await this.gatewayManager.stopGateway().catch(() => {});
|
||||
}
|
||||
|
||||
log.info({ previewId, projectName }, 'preview stopped');
|
||||
|
||||
this.eventBus.emit<PreviewStoppedEvent>({
|
||||
@@ -245,6 +355,9 @@ export class PreviewManager {
|
||||
const previews: PreviewStatus[] = [];
|
||||
|
||||
for (const project of projects) {
|
||||
// Skip the gateway project
|
||||
if (project.Name === 'cw-preview-gateway') continue;
|
||||
|
||||
const labels = await getContainerLabels(project.Name);
|
||||
if (!labels[PREVIEW_LABELS.preview]) continue;
|
||||
|
||||
@@ -292,20 +405,95 @@ export class PreviewManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all preview deployments. Called on server shutdown.
|
||||
* Stop all preview deployments, then stop the gateway.
|
||||
*/
|
||||
async stopAll(): Promise<void> {
|
||||
const projects = await listPreviewProjects();
|
||||
log.info({ count: projects.length }, 'stopping all preview deployments');
|
||||
const previewProjects = projects.filter((p) => p.Name !== 'cw-preview-gateway');
|
||||
log.info({ count: previewProjects.length }, 'stopping all preview deployments');
|
||||
|
||||
await Promise.all(
|
||||
projects.map(async (project) => {
|
||||
previewProjects.map(async (project) => {
|
||||
const id = project.Name.replace(COMPOSE_PROJECT_PREFIX, '');
|
||||
await this.stop(id).catch((err) => {
|
||||
log.warn({ projectName: project.Name, err }, 'failed to stop preview');
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// Ensure gateway is stopped
|
||||
await this.gatewayManager.stopGateway().catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register event listener for auto-starting previews on phase:pending_review.
|
||||
*/
|
||||
private setupEventListeners(): void {
|
||||
this.eventBus.on<PhasePendingReviewEvent>(
|
||||
'phase:pending_review',
|
||||
async (event) => {
|
||||
try {
|
||||
const { phaseId, initiativeId } = event.payload;
|
||||
|
||||
const initiative = await this.initiativeRepository.findById(initiativeId);
|
||||
if (!initiative?.branch) {
|
||||
log.debug({ initiativeId }, 'no initiative branch, skipping auto-preview');
|
||||
return;
|
||||
}
|
||||
|
||||
const phase = await this.phaseRepository.findById(phaseId);
|
||||
if (!phase) {
|
||||
log.debug({ phaseId }, 'phase not found, skipping auto-preview');
|
||||
return;
|
||||
}
|
||||
|
||||
const projects = await this.projectRepository.findProjectsByInitiativeId(initiativeId);
|
||||
if (projects.length !== 1) {
|
||||
log.debug(
|
||||
{ initiativeId, projectCount: projects.length },
|
||||
'auto-preview requires exactly one project',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const branch = phaseBranchName(initiative.branch, phase.name);
|
||||
|
||||
log.info(
|
||||
{ initiativeId, phaseId, branch, projectId: projects[0].id },
|
||||
'auto-starting preview for pending_review phase',
|
||||
);
|
||||
|
||||
await this.start({
|
||||
initiativeId,
|
||||
phaseId,
|
||||
projectId: projects[0].id,
|
||||
branch,
|
||||
mode: 'preview',
|
||||
});
|
||||
} catch (error) {
|
||||
log.warn({ err: error }, 'auto-preview failed (best-effort)');
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build gateway routes from a preview config.
|
||||
*/
|
||||
private buildRoutes(previewId: string, config: PreviewConfig): GatewayRoute[] {
|
||||
const routes: GatewayRoute[] = [];
|
||||
|
||||
for (const [name, svc] of Object.entries(config.services)) {
|
||||
if (svc.internal) continue;
|
||||
|
||||
routes.push({
|
||||
containerName: `cw-preview-${previewId}-${name}`,
|
||||
port: svc.port,
|
||||
route: svc.route ?? '/',
|
||||
});
|
||||
}
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -320,7 +508,8 @@ export class PreviewManager {
|
||||
const initiativeId = labels[PREVIEW_LABELS.initiativeId];
|
||||
const projectId = labels[PREVIEW_LABELS.projectId];
|
||||
const branch = labels[PREVIEW_LABELS.branch];
|
||||
const port = parseInt(labels[PREVIEW_LABELS.port] ?? '0', 10);
|
||||
const gatewayPort = parseInt(labels[PREVIEW_LABELS.port] ?? '0', 10);
|
||||
const mode = (labels[PREVIEW_LABELS.mode] as 'preview' | 'dev') ?? 'preview';
|
||||
|
||||
if (!initiativeId || !projectId || !branch) {
|
||||
return null;
|
||||
@@ -333,7 +522,9 @@ export class PreviewManager {
|
||||
phaseId: labels[PREVIEW_LABELS.phaseId],
|
||||
projectId,
|
||||
branch,
|
||||
port,
|
||||
gatewayPort,
|
||||
url: `http://${previewId}.localhost:${gatewayPort}`,
|
||||
mode,
|
||||
status: 'running',
|
||||
services: [],
|
||||
composePath,
|
||||
|
||||
@@ -1,42 +1,19 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createServer } from 'node:net';
|
||||
|
||||
// Mock the docker-client module to avoid actual Docker calls
|
||||
vi.mock('./docker-client.js', () => ({
|
||||
getPreviewPorts: vi.fn(),
|
||||
}));
|
||||
|
||||
import { allocatePort } from './port-allocator.js';
|
||||
import { getPreviewPorts } from './docker-client.js';
|
||||
|
||||
const mockedGetPreviewPorts = vi.mocked(getPreviewPorts);
|
||||
|
||||
describe('allocatePort', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns BASE_PORT (9100) when no ports are in use', async () => {
|
||||
mockedGetPreviewPorts.mockResolvedValue([]);
|
||||
it('returns BASE_PORT (9100) when the port is available', async () => {
|
||||
const port = await allocatePort();
|
||||
expect(port).toBe(9100);
|
||||
});
|
||||
|
||||
it('skips ports already used by previews', async () => {
|
||||
mockedGetPreviewPorts.mockResolvedValue([9100, 9101]);
|
||||
const port = await allocatePort();
|
||||
expect(port).toBe(9102);
|
||||
});
|
||||
|
||||
it('skips non-contiguous used ports', async () => {
|
||||
mockedGetPreviewPorts.mockResolvedValue([9100, 9103]);
|
||||
const port = await allocatePort();
|
||||
expect(port).toBe(9101);
|
||||
});
|
||||
|
||||
it('skips a port that is bound by another process', async () => {
|
||||
mockedGetPreviewPorts.mockResolvedValue([]);
|
||||
|
||||
// Bind port 9100 to simulate external use
|
||||
const server = createServer();
|
||||
await new Promise<void>((resolve) => {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* Port Allocator
|
||||
*
|
||||
* Finds the next available port for a preview deployment.
|
||||
* Queries running preview containers and performs a bind test.
|
||||
* Finds the next available port for the gateway.
|
||||
* Only called once when the gateway first starts — subsequent previews
|
||||
* reuse the same gateway port.
|
||||
*/
|
||||
|
||||
import { createServer } from 'node:net';
|
||||
import { getPreviewPorts } from './docker-client.js';
|
||||
import { createModuleLogger } from '../logger/index.js';
|
||||
|
||||
const log = createModuleLogger('preview:port');
|
||||
@@ -18,22 +18,13 @@ const BASE_PORT = 9100;
|
||||
const MAX_PORT = 9200;
|
||||
|
||||
/**
|
||||
* Allocate the next available port for a preview deployment.
|
||||
*
|
||||
* 1. Queries running preview containers for their cw.port labels
|
||||
* 2. Finds the next port >= BASE_PORT that isn't in use
|
||||
* 3. Performs a bind test to verify no external conflict
|
||||
* Allocate the next available port by performing a bind test.
|
||||
*
|
||||
* @returns An available port number
|
||||
* @throws If no port is available in the range
|
||||
*/
|
||||
export async function allocatePort(): Promise<number> {
|
||||
const usedPorts = new Set(await getPreviewPorts());
|
||||
log.debug({ usedPorts: Array.from(usedPorts) }, 'ports in use by previews');
|
||||
|
||||
for (let port = BASE_PORT; port < MAX_PORT; port++) {
|
||||
if (usedPorts.has(port)) continue;
|
||||
|
||||
if (await isPortAvailable(port)) {
|
||||
log.info({ port }, 'allocated port');
|
||||
return port;
|
||||
|
||||
@@ -5,6 +5,16 @@
|
||||
* Docker IS the source of truth — no database table needed.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Dev mode configuration for a service.
|
||||
* Used when running from an agent's worktree with hot-reload.
|
||||
*/
|
||||
export interface PreviewServiceDevConfig {
|
||||
image: string;
|
||||
command?: string;
|
||||
workdir?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service configuration within a preview deployment.
|
||||
*/
|
||||
@@ -18,6 +28,7 @@ export interface PreviewServiceConfig {
|
||||
healthcheck?: { path: string; interval?: string; retries?: number };
|
||||
env?: Record<string, string>;
|
||||
volumes?: string[];
|
||||
dev?: PreviewServiceDevConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,7 +52,9 @@ export interface PreviewStatus {
|
||||
phaseId?: string;
|
||||
projectId: string;
|
||||
branch: string;
|
||||
port: number;
|
||||
gatewayPort: number;
|
||||
url: string;
|
||||
mode: 'preview' | 'dev';
|
||||
status: 'building' | 'running' | 'stopped' | 'failed';
|
||||
services: Array<{ name: string; state: string; health: string }>;
|
||||
composePath: string;
|
||||
@@ -59,6 +72,7 @@ export const PREVIEW_LABELS = {
|
||||
projectId: `${PREVIEW_LABEL_PREFIX}.project-id`,
|
||||
port: `${PREVIEW_LABEL_PREFIX}.port`,
|
||||
previewId: `${PREVIEW_LABEL_PREFIX}.preview-id`,
|
||||
mode: `${PREVIEW_LABEL_PREFIX}.mode`,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
@@ -66,6 +80,12 @@ export const PREVIEW_LABELS = {
|
||||
*/
|
||||
export const COMPOSE_PROJECT_PREFIX = 'cw-preview-';
|
||||
|
||||
/**
|
||||
* Gateway compose project name and shared Docker network.
|
||||
*/
|
||||
export const GATEWAY_PROJECT_NAME = 'cw-preview-gateway';
|
||||
export const GATEWAY_NETWORK = 'cw-preview-net';
|
||||
|
||||
/**
|
||||
* Options for starting a preview deployment.
|
||||
*/
|
||||
@@ -74,6 +94,8 @@ export interface StartPreviewOptions {
|
||||
phaseId?: string;
|
||||
projectId: string;
|
||||
branch: string;
|
||||
mode?: 'preview' | 'dev';
|
||||
worktreePath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
49
apps/server/preview/worktree.ts
Normal file
49
apps/server/preview/worktree.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Preview Worktree Helper
|
||||
*
|
||||
* Creates and removes git worktrees for preview deployments.
|
||||
* Preview mode checks out a specific branch into a temp directory
|
||||
* so the Docker build runs against the correct code.
|
||||
*/
|
||||
|
||||
import { simpleGit } from 'simple-git';
|
||||
import { createModuleLogger } from '../logger/index.js';
|
||||
|
||||
const log = createModuleLogger('preview:worktree');
|
||||
|
||||
/**
|
||||
* Create a git worktree at the specified destination, checking out the given branch.
|
||||
* Does NOT create a new branch — the branch must already exist.
|
||||
*
|
||||
* @param repoPath - Path to the git repository (bare clone)
|
||||
* @param branch - Branch to check out
|
||||
* @param destPath - Where to create the worktree
|
||||
*/
|
||||
export async function createPreviewWorktree(
|
||||
repoPath: string,
|
||||
branch: string,
|
||||
destPath: string,
|
||||
): Promise<void> {
|
||||
log.info({ repoPath, branch, destPath }, 'creating preview worktree');
|
||||
const git = simpleGit(repoPath);
|
||||
await git.raw(['worktree', 'add', destPath, branch]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a git worktree.
|
||||
*
|
||||
* @param repoPath - Path to the git repository
|
||||
* @param worktreePath - Path of the worktree to remove
|
||||
*/
|
||||
export async function removePreviewWorktree(
|
||||
repoPath: string,
|
||||
worktreePath: string,
|
||||
): Promise<void> {
|
||||
log.info({ repoPath, worktreePath }, 'removing preview worktree');
|
||||
try {
|
||||
const git = simpleGit(repoPath);
|
||||
await git.raw(['worktree', 'remove', worktreePath, '--force']);
|
||||
} catch (error) {
|
||||
log.warn({ worktreePath, err: error }, 'failed to remove worktree (may not exist)');
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ export function previewProcedures(publicProcedure: ProcedureBuilder) {
|
||||
phaseId: z.string().min(1).optional(),
|
||||
projectId: z.string().min(1),
|
||||
branch: z.string().min(1),
|
||||
mode: z.enum(['preview', 'dev']).default('preview'),
|
||||
worktreePath: z.string().optional(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const previewManager = requirePreviewManager(ctx);
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Loader2,
|
||||
ExternalLink,
|
||||
Square,
|
||||
RotateCcw,
|
||||
CircleDot,
|
||||
CircleX,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface PreviewPanelProps {
|
||||
initiativeId: string;
|
||||
phaseId?: string;
|
||||
projectId: string;
|
||||
branch: string;
|
||||
}
|
||||
|
||||
export function PreviewPanel({
|
||||
initiativeId,
|
||||
phaseId,
|
||||
projectId,
|
||||
branch,
|
||||
}: PreviewPanelProps) {
|
||||
const [activePreviewId, setActivePreviewId] = useState<string | null>(null);
|
||||
|
||||
// Check for existing previews for this initiative
|
||||
const previewsQuery = trpc.listPreviews.useQuery(
|
||||
{ initiativeId },
|
||||
{ refetchInterval: activePreviewId ? 3000 : false },
|
||||
);
|
||||
|
||||
const existingPreview = previewsQuery.data?.find(
|
||||
(p) => p.phaseId === phaseId || (!phaseId && p.initiativeId === initiativeId),
|
||||
);
|
||||
|
||||
const previewStatusQuery = trpc.getPreviewStatus.useQuery(
|
||||
{ previewId: activePreviewId ?? existingPreview?.id ?? "" },
|
||||
{
|
||||
enabled: !!(activePreviewId ?? existingPreview?.id),
|
||||
refetchInterval: 3000,
|
||||
},
|
||||
);
|
||||
|
||||
const preview = previewStatusQuery.data ?? existingPreview;
|
||||
|
||||
const startMutation = trpc.startPreview.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setActivePreviewId(data.id);
|
||||
toast.success(`Preview running at http://localhost:${data.port}`);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(`Preview failed: ${err.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const stopMutation = trpc.stopPreview.useMutation({
|
||||
onSuccess: () => {
|
||||
setActivePreviewId(null);
|
||||
toast.success("Preview stopped");
|
||||
previewsQuery.refetch();
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(`Failed to stop preview: ${err.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleStart = () => {
|
||||
startMutation.mutate({ initiativeId, phaseId, projectId, branch });
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
const id = activePreviewId ?? existingPreview?.id;
|
||||
if (id) {
|
||||
stopMutation.mutate({ previewId: id });
|
||||
}
|
||||
};
|
||||
|
||||
// Building state
|
||||
if (startMutation.isPending) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-status-active-border bg-status-active-bg px-4 py-3">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-status-active-dot" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-status-active-fg">
|
||||
Building preview...
|
||||
</p>
|
||||
<p className="text-xs text-status-active-fg/70">
|
||||
Building containers and starting services
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Running state
|
||||
if (preview && (preview.status === "running" || preview.status === "building")) {
|
||||
const url = `http://localhost:${preview.port}`;
|
||||
const isBuilding = preview.status === "building";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-3 rounded-lg border px-4 py-3 ${
|
||||
isBuilding
|
||||
? "border-status-active-border bg-status-active-bg"
|
||||
: "border-status-success-border bg-status-success-bg"
|
||||
}`}
|
||||
>
|
||||
{isBuilding ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-status-active-dot" />
|
||||
) : (
|
||||
<CircleDot className="h-4 w-4 text-status-success-dot" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{isBuilding ? "Building..." : "Preview running"}
|
||||
</p>
|
||||
{!isBuilding && (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary hover:underline flex items-center gap-1"
|
||||
>
|
||||
{url}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleStop}
|
||||
disabled={stopMutation.isPending}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Square className="h-3 w-3 mr-1" />
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Failed state
|
||||
if (preview && preview.status === "failed") {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-status-error-border bg-status-error-bg px-4 py-3">
|
||||
<CircleX className="h-4 w-4 text-status-error-dot" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-status-error-fg">
|
||||
Preview failed
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={handleStart}>
|
||||
<RotateCcw className="h-3 w-3 mr-1" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// No preview — show start button
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleStart}
|
||||
disabled={startMutation.isPending}
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5 mr-1" />
|
||||
Start Preview
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -94,7 +94,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
const startPreview = trpc.startPreview.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setActivePreviewId(data.id);
|
||||
toast.success(`Preview running at http://localhost:${data.port}`);
|
||||
toast.success(`Preview running at ${data.url}`);
|
||||
},
|
||||
onError: (err) => toast.error(`Preview failed: ${err.message}`),
|
||||
});
|
||||
@@ -119,7 +119,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
||||
: preview?.status === "failed"
|
||||
? ("failed" as const)
|
||||
: ("idle" as const),
|
||||
url: preview?.port ? `http://localhost:${preview.port}` : undefined,
|
||||
url: preview?.url ?? undefined,
|
||||
onStart: () =>
|
||||
startPreview.mutate({
|
||||
initiativeId,
|
||||
|
||||
165
docs/preview.md
165
docs/preview.md
@@ -4,27 +4,92 @@
|
||||
|
||||
## Overview
|
||||
|
||||
When a phase enters `pending_review`, reviewers can spin up the app at a specific branch in local Docker containers, accessible through a single port via a Caddy reverse proxy.
|
||||
Preview deployments let reviewers spin up a branch in local Docker containers. A single shared **Caddy gateway** handles subdomain routing for all active previews, accessed at `http://<previewId>.localhost:<gatewayPort>`.
|
||||
|
||||
Two modes:
|
||||
- **Preview mode**: Checks out target branch into a git worktree, builds Docker images, serves production-like output.
|
||||
- **Dev mode**: Mounts the agent's worktree into a container with a dev server image (e.g. `node:20-alpine`), enabling hot reload.
|
||||
|
||||
**Auto-start**: When a phase enters `pending_review`, a preview is automatically started for the phase's branch (if the initiative has exactly one project).
|
||||
|
||||
**Key design decision: No database table.** Docker IS the source of truth. Instead of persisting rows, we query Docker directly via compose project names, container labels, and `docker compose` CLI commands.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
.cw-previews/
|
||||
gateway/
|
||||
docker-compose.yml ← single Caddy container, one port
|
||||
Caddyfile ← regenerated on each preview add/remove
|
||||
<previewId>/
|
||||
docker-compose.yml ← per-preview stack (no published ports)
|
||||
source/ ← git worktree (preview mode only)
|
||||
|
||||
Docker:
|
||||
network: cw-preview-net ← external, shared by gateway + all previews
|
||||
cw-preview-gateway ← Caddy on one port, subdomain routing
|
||||
cw-preview-<id> ← per-preview compose project (services only)
|
||||
|
||||
Routing:
|
||||
<previewId>.localhost:<gatewayPort> → cw-preview-<id>-<service>:<port>
|
||||
```
|
||||
|
||||
```
|
||||
PreviewManager
|
||||
├── GatewayManager (shared Caddy gateway lifecycle + Caddyfile generation)
|
||||
├── ConfigReader (discover .cw-preview.yml / compose / Dockerfile)
|
||||
├── ComposeGenerator (generate docker-compose.yml + Caddyfile)
|
||||
├── DockerClient (thin wrapper around docker compose CLI)
|
||||
├── HealthChecker (poll service healthcheck endpoints)
|
||||
└── PortAllocator (find next available port 9100-9200)
|
||||
├── ComposeGenerator (generate per-preview docker-compose.yml)
|
||||
├── DockerClient (thin wrapper around docker compose CLI + network ops)
|
||||
├── HealthChecker (poll service healthcheck endpoints via subdomain URL)
|
||||
├── PortAllocator (find next available port 9100-9200 for gateway)
|
||||
└── Worktree helper (git worktree add/remove for preview mode)
|
||||
```
|
||||
|
||||
### Lifecycle
|
||||
|
||||
1. **Start**: discover config → allocate port → generate compose + Caddyfile → `docker compose up --build -d` → health check → emit `preview:ready`
|
||||
2. **Stop**: `docker compose down --volumes --remove-orphans` → clean up `.cw-previews/<id>/` → emit `preview:stopped`
|
||||
3. **List**: `docker compose ls --filter name=cw-preview` → parse container labels → reconstruct status
|
||||
4. **Shutdown**: `stopAll()` called on server shutdown to prevent orphaned containers
|
||||
1. **Start**: ensure gateway → discover config → create worktree (preview) or use provided path (dev) → generate compose → `docker compose up --build -d` → update gateway routes → health check → emit `preview:ready`
|
||||
2. **Stop**: `docker compose down --volumes --remove-orphans` → remove worktree → clean up `.cw-previews/<id>/` → update gateway routes → stop gateway if no more previews → emit `preview:stopped`
|
||||
3. **List**: `docker compose ls --filter name=cw-preview` → skip gateway project → parse container labels → reconstruct status
|
||||
4. **Shutdown**: `stopAll()` called on server shutdown — stops all previews, then stops gateway
|
||||
|
||||
### Gateway
|
||||
|
||||
The `GatewayManager` class manages a single shared Caddy container:
|
||||
|
||||
- **`ensureGateway()`** — idempotent. Creates the `cw-preview-net` Docker network, checks if gateway is already running, allocates a port (9100-9200) if needed, writes compose + Caddyfile, starts Caddy with `--watch` flag.
|
||||
- **`updateRoutes()`** — regenerates the full Caddyfile from all active previews. Caddy's `--watch` flag auto-reloads on file change (no `docker exec` needed).
|
||||
- **`stopGateway()`** — composes down the gateway, removes the Docker network, cleans up the gateway directory.
|
||||
|
||||
Gateway Caddyfile format:
|
||||
```
|
||||
{
|
||||
auto_https off
|
||||
}
|
||||
|
||||
abc123.localhost:9100 {
|
||||
handle_path /api/* {
|
||||
reverse_proxy cw-preview-abc123-backend:8080
|
||||
}
|
||||
handle {
|
||||
reverse_proxy cw-preview-abc123-frontend:3000
|
||||
}
|
||||
}
|
||||
|
||||
xyz789.localhost:9100 {
|
||||
handle {
|
||||
reverse_proxy cw-preview-xyz789-app:3000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Routes are sorted by specificity (longer paths first) to ensure correct matching.
|
||||
|
||||
### Subdomain Routing
|
||||
|
||||
Previews are accessed at `http://<previewId>.localhost:<gatewayPort>`.
|
||||
|
||||
- **Chrome / Firefox**: Resolve `*.localhost` to `127.0.0.1` natively. No DNS config needed.
|
||||
- **Safari**: Requires a `/etc/hosts` entry: `127.0.0.1 <previewId>.localhost` for each preview.
|
||||
|
||||
### Docker Labels
|
||||
|
||||
@@ -37,12 +102,21 @@ All preview containers get `cw.*` labels for metadata retrieval:
|
||||
| `cw.phase-id` | Phase ID (optional) |
|
||||
| `cw.project-id` | Project ID |
|
||||
| `cw.branch` | Branch name |
|
||||
| `cw.port` | Host port |
|
||||
| `cw.port` | Gateway port |
|
||||
| `cw.preview-id` | Nanoid for this deployment |
|
||||
| `cw.mode` | `"preview"` or `"dev"` |
|
||||
|
||||
### Compose Project Naming
|
||||
|
||||
Project names follow `cw-preview-<nanoid>` convention. This enables filtering via `docker compose ls --filter name=cw-preview`.
|
||||
- **Gateway**: `cw-preview-gateway` (single instance)
|
||||
- **Previews**: `cw-preview-<nanoid>` — filtered via `docker compose ls --filter name=cw-preview`, gateway excluded from listings
|
||||
- **Container names**: `cw-preview-<id>-<service>` — unique DNS names on the shared network
|
||||
|
||||
### Networking
|
||||
|
||||
- **`cw-preview-net`** — external Docker bridge network shared by gateway + all preview stacks
|
||||
- **`internal`** — per-preview bridge network for inter-service communication
|
||||
- Public services join both networks; internal services (e.g. databases) only join `internal`
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -65,6 +139,10 @@ services:
|
||||
retries: 10
|
||||
env:
|
||||
VITE_API_URL: /api
|
||||
dev:
|
||||
image: node:20-alpine
|
||||
command: npm run dev -- --host 0.0.0.0
|
||||
workdir: /app
|
||||
|
||||
backend:
|
||||
build:
|
||||
@@ -85,64 +163,68 @@ services:
|
||||
POSTGRES_PASSWORD: preview
|
||||
```
|
||||
|
||||
The `dev` section is optional per service. When present and mode is `dev`:
|
||||
- `image` (required) — Docker image to run
|
||||
- `command` — override entrypoint
|
||||
- `workdir` — container working directory (default `/app`)
|
||||
|
||||
In dev mode, the project directory is volume-mounted into the container and `node_modules` gets an anonymous volume to prevent host overwrite.
|
||||
|
||||
### 2. `docker-compose.yml` / `compose.yml` (existing compose passthrough)
|
||||
|
||||
If found, the existing compose file is wrapped with a Caddy sidecar.
|
||||
If found, the existing compose file is used with gateway network injection.
|
||||
|
||||
### 3. `Dockerfile` (single-service fallback)
|
||||
|
||||
If only a Dockerfile exists, creates a single `app` service building from `.` with port 3000.
|
||||
|
||||
## Reverse Proxy: Caddy
|
||||
|
||||
Caddy runs as a container in the same Docker network. Only Caddy publishes a port to the host. Generated Caddyfile:
|
||||
|
||||
```
|
||||
:80 {
|
||||
handle_path /api/* {
|
||||
reverse_proxy backend:8080
|
||||
}
|
||||
handle {
|
||||
reverse_proxy frontend:3000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Module Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `types.ts` | PreviewConfig, PreviewStatus, labels, constants |
|
||||
| `config-reader.ts` | Discovery + YAML parsing |
|
||||
| `compose-generator.ts` | Docker Compose YAML + Caddyfile generation |
|
||||
| `docker-client.ts` | Docker CLI wrapper (execa) |
|
||||
| `health-checker.ts` | Service readiness polling |
|
||||
| `port-allocator.ts` | Port 9100-9200 allocation with bind test |
|
||||
| `manager.ts` | PreviewManager class (start/stop/list/status/stopAll) |
|
||||
| `types.ts` | PreviewConfig, PreviewStatus, labels, constants, dev config types |
|
||||
| `config-reader.ts` | Discovery + YAML parsing (including `dev` section) |
|
||||
| `compose-generator.ts` | Per-preview Docker Compose YAML + label generation |
|
||||
| `gateway.ts` | GatewayManager class + Caddyfile generation |
|
||||
| `worktree.ts` | Git worktree create/remove helpers |
|
||||
| `docker-client.ts` | Docker CLI wrapper (execa) + network operations |
|
||||
| `health-checker.ts` | Service readiness polling via subdomain URL |
|
||||
| `port-allocator.ts` | Port 9100-9200 allocation with TCP bind test |
|
||||
| `manager.ts` | PreviewManager class (start/stop/list/status/stopAll + auto-start) |
|
||||
| `index.ts` | Barrel exports |
|
||||
|
||||
## Events
|
||||
|
||||
| Event | Payload |
|
||||
|-------|---------|
|
||||
| `preview:building` | `{previewId, initiativeId, branch, port}` |
|
||||
| `preview:ready` | `{previewId, initiativeId, branch, port, url}` |
|
||||
| `preview:building` | `{previewId, initiativeId, branch, gatewayPort, mode, phaseId?}` |
|
||||
| `preview:ready` | `{previewId, initiativeId, branch, gatewayPort, url, mode, phaseId?}` |
|
||||
| `preview:stopped` | `{previewId, initiativeId}` |
|
||||
| `preview:failed` | `{previewId, initiativeId, error}` |
|
||||
|
||||
## Auto-Start
|
||||
|
||||
`PreviewManager.setupEventListeners()` listens for `phase:pending_review` events:
|
||||
1. Loads the initiative and its projects
|
||||
2. If exactly one project: auto-starts a preview in `preview` mode
|
||||
3. Branch is derived from `phaseBranchName(initiative.branch, phase.name)`
|
||||
4. Errors are caught and logged (best-effort, never blocks the phase transition)
|
||||
|
||||
## tRPC Procedures
|
||||
|
||||
| Procedure | Type | Input |
|
||||
|-----------|------|-------|
|
||||
| `startPreview` | mutation | `{initiativeId, phaseId?, projectId, branch}` |
|
||||
| `startPreview` | mutation | `{initiativeId, phaseId?, projectId, branch, mode?, worktreePath?}` |
|
||||
| `stopPreview` | mutation | `{previewId}` |
|
||||
| `listPreviews` | query | `{initiativeId?}` |
|
||||
| `getPreviewStatus` | query | `{previewId}` |
|
||||
|
||||
`mode` defaults to `'preview'`. Set to `'dev'` with a `worktreePath` for dev mode.
|
||||
|
||||
## CLI Commands
|
||||
|
||||
```
|
||||
cw preview start --initiative <id> --project <id> --branch <branch> [--phase <id>]
|
||||
cw preview start --initiative <id> --project <id> --branch <branch> [--phase <id>] [--mode preview|dev]
|
||||
cw preview stop <previewId>
|
||||
cw preview list [--initiative <id>]
|
||||
cw preview status <previewId>
|
||||
@@ -150,17 +232,17 @@ cw preview status <previewId>
|
||||
|
||||
## Frontend
|
||||
|
||||
`PreviewPanel` component in the Review tab:
|
||||
The Review tab shows preview status inline:
|
||||
- **No preview**: "Start Preview" button
|
||||
- **Building**: Spinner + "Building preview..."
|
||||
- **Running**: Green dot + `http://localhost:<port>` link + Stop button
|
||||
- **Running**: Green dot + `http://<id>.localhost:<port>` link + Stop button
|
||||
- **Failed**: Error message + Retry button
|
||||
|
||||
Polls `getPreviewStatus` with `refetchInterval: 3000` while active.
|
||||
|
||||
## Container Wiring
|
||||
|
||||
- `PreviewManager` instantiated in `apps/server/container.ts` with `(projectRepository, eventBus, workspaceRoot)`
|
||||
- `PreviewManager` instantiated in `apps/server/container.ts` with `(projectRepository, eventBus, workspaceRoot, phaseRepository, initiativeRepository)`
|
||||
- Added to `Container` interface and `toContextDeps()`
|
||||
- `GracefulShutdown` calls `previewManager.stopAll()` during shutdown
|
||||
- `requirePreviewManager(ctx)` helper in `apps/server/trpc/routers/_helpers.ts`
|
||||
@@ -168,4 +250,5 @@ Polls `getPreviewStatus` with `refetchInterval: 3000` while active.
|
||||
## Dependencies
|
||||
|
||||
- `js-yaml` + `@types/js-yaml` — for parsing `.cw-preview.yml`
|
||||
- `simple-git` — for git worktree operations
|
||||
- Docker must be installed and running on the host
|
||||
|
||||
Reference in New Issue
Block a user