Files
Codewalkers/apps/server/preview/docker-client.ts
Lukas May 34578d39c6 refactor: Restructure monorepo to apps/server/ and apps/web/ layout
Move src/ → apps/server/ and packages/web/ → apps/web/ to adopt
standard monorepo conventions (apps/ for runnable apps, packages/
for reusable libraries). Update all config files, shared package
imports, test fixtures, and documentation to reflect new paths.

Key fixes:
- Update workspace config to ["apps/*", "packages/*"]
- Update tsconfig.json rootDir/include for apps/server/
- Add apps/web/** to vitest exclude list
- Update drizzle.config.ts schema path
- Fix ensure-schema.ts migration path detection (3 levels up in dev,
  2 levels up in dist)
- Fix tests/integration/cli-server.test.ts import paths
- Update packages/shared imports to apps/server/ paths
- Update all docs/ files with new paths
2026-03-03 11:22:53 +01:00

207 lines
4.8 KiB
TypeScript

/**
* Docker Client
*
* Thin wrapper around Docker CLI via execa for preview lifecycle management.
* No SDK dependency — uses `docker compose` subprocess calls.
*/
import { execa } from 'execa';
import { createModuleLogger } from '../logger/index.js';
import { COMPOSE_PROJECT_PREFIX, PREVIEW_LABELS } from './types.js';
const log = createModuleLogger('preview:docker');
/**
* Service status from `docker compose ps`.
*/
export interface ServiceStatus {
name: string;
state: string;
health: string;
}
/**
* Compose project from `docker compose ls`.
*/
export interface ComposeProject {
Name: string;
Status: string;
ConfigFiles: string;
}
/**
* Check if Docker is available and running.
*/
export async function isDockerAvailable(): Promise<boolean> {
try {
await execa('docker', ['info'], { timeout: 10000 });
return true;
} catch {
return false;
}
}
/**
* Start a compose project (build and run in background).
*/
export async function composeUp(composePath: string, projectName: string): Promise<void> {
log.info({ composePath, projectName }, 'starting compose project');
const cwd = composePath.substring(0, composePath.lastIndexOf('/'));
await execa('docker', [
'compose',
'-p', projectName,
'-f', composePath,
'up',
'--build',
'-d',
], {
cwd,
timeout: 600000, // 10 minutes for builds
});
}
/**
* Stop and remove a compose project.
*/
export async function composeDown(projectName: string): Promise<void> {
log.info({ projectName }, 'stopping compose project');
await execa('docker', [
'compose',
'-p', projectName,
'down',
'--volumes',
'--remove-orphans',
], {
timeout: 60000,
});
}
/**
* Get service statuses for a compose project.
*/
export async function composePs(projectName: string): Promise<ServiceStatus[]> {
try {
const result = await execa('docker', [
'compose',
'-p', projectName,
'ps',
'--format', 'json',
], {
timeout: 15000,
});
if (!result.stdout.trim()) {
return [];
}
// docker compose ps --format json outputs one JSON object per line
const lines = result.stdout.trim().split('\n');
return lines.map((line) => {
const container = JSON.parse(line);
return {
name: container.Service || container.Name || '',
state: container.State || 'unknown',
health: container.Health || 'none',
};
});
} catch (error) {
log.warn({ projectName, err: error }, 'failed to get compose ps');
return [];
}
}
/**
* List all preview compose projects.
*/
export async function listPreviewProjects(): Promise<ComposeProject[]> {
try {
const result = await execa('docker', [
'compose',
'ls',
'--filter', `name=${COMPOSE_PROJECT_PREFIX}`,
'--format', 'json',
], {
timeout: 15000,
});
if (!result.stdout.trim()) {
return [];
}
return JSON.parse(result.stdout);
} catch (error) {
log.warn({ err: error }, 'failed to list preview projects');
return [];
}
}
/**
* Get container labels for a compose project.
* Returns labels from the first container that has cw.preview=true.
*/
export async function getContainerLabels(projectName: string): Promise<Record<string, string>> {
try {
const result = await execa('docker', [
'ps',
'--filter', `label=${PREVIEW_LABELS.preview}=true`,
'--filter', `label=com.docker.compose.project=${projectName}`,
'--format', '{{json .Labels}}',
], {
timeout: 15000,
});
if (!result.stdout.trim()) {
return {};
}
// Parse the first line's label string: "key=val,key=val,..."
const firstLine = result.stdout.trim().split('\n')[0];
const labelStr = firstLine.replace(/^"|"$/g, '');
const labels: Record<string, string> = {};
for (const pair of labelStr.split(',')) {
const eqIdx = pair.indexOf('=');
if (eqIdx > 0) {
const key = pair.substring(0, eqIdx);
const value = pair.substring(eqIdx + 1);
if (key.startsWith('cw.')) {
labels[key] = value;
}
}
}
return labels;
} catch (error) {
log.warn({ projectName, err: error }, 'failed to get container labels');
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 [];
}
}