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
207 lines
4.8 KiB
TypeScript
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 [];
|
|
}
|
|
}
|