import fs from 'fs/promises'; import path from 'path'; import { promisify } from 'util'; import { parse as parseDotenv } from 'dotenv'; import * as docker from './docker.service'; import type { ExecutionDriver } from './execution-driver'; import { logger } from '../utils/logger'; /** * LocalDriver wraps existing docker.service.ts functions and filesystem operations. * This is a zero-behavior-change adapter — all existing local instance operations * pass through unchanged. */ export class LocalDriver implements ExecutionDriver { // ─── Docker Compose Operations ────────────────────────────── composeUp(projectDir: string, project: string, services?: string[]) { return docker.composeUp(projectDir, project, services); } composeDown(projectDir: string, project: string, removeVolumes?: boolean) { return docker.composeDown(projectDir, project, removeVolumes); } composeStop(projectDir: string, project: string) { return docker.composeStop(projectDir, project); } composeRestart(projectDir: string, project: string, service?: string) { return docker.composeRestart(projectDir, project, service); } composePull(projectDir: string, project: string) { return docker.composePull(projectDir, project); } composeBuild(projectDir: string, project: string) { return docker.composeBuild(projectDir, project); } composePs(projectDir: string, project: string) { return docker.composePs(projectDir, project); } composeLogs(projectDir: string, project: string, service?: string, tail?: number, since?: string) { return docker.composeLogs(projectDir, project, service, tail, since); } composeExec(projectDir: string, project: string, service: string, command: string, timeoutMs?: number, envVars?: Record) { return docker.composeExec(projectDir, project, service, command, timeoutMs, envVars); } // ─── Container Health ─────────────────────────────────────── waitForHealthy(containerName: string, timeoutMs?: number, pollIntervalMs?: number) { return docker.waitForHealthy(containerName, timeoutMs, pollIntervalMs); } waitForHttp(url: string, timeoutMs?: number, pollIntervalMs?: number) { return docker.waitForHttp(url, timeoutMs, pollIntervalMs); } // ─── Filesystem Operations ────────────────────────────────── async readEnvFile(basePath: string): Promise | null> { try { const content = await fs.readFile(path.join(basePath, '.env'), 'utf-8'); return parseDotenv(Buffer.from(content)); } catch { return null; } } async writeFiles(basePath: string, files: Array<{ relativePath: string; content: string }>) { for (const file of files) { const filePath = path.join(basePath, file.relativePath); await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.writeFile(filePath, file.content, 'utf-8'); logger.debug(`[local-driver] Wrote ${filePath}`); } } // Approach C pre-flight diff. Reads current file contents at basePath + // relativePath, returns per-file status + diff. Local implementation // mirrors the agent-side diffFiles helper. async diffFiles(basePath: string, files: Array<{ relativePath: string; content: string }>) { const results: Array<{ path: string; status: 'unchanged' | 'modified' | 'created'; diff: string | null; sizeBefore: number; sizeAfter: number }> = []; for (const file of files) { const filePath = path.join(basePath, file.relativePath); const sizeAfter = Buffer.byteLength(file.content, 'utf-8'); let current: string | null = null; try { current = await fs.readFile(filePath, 'utf-8'); } catch { current = null; } if (current === null) { results.push({ path: file.relativePath, status: 'created', diff: null, sizeBefore: 0, sizeAfter }); } else if (current === file.content) { results.push({ path: file.relativePath, status: 'unchanged', diff: null, sizeBefore: Buffer.byteLength(current), sizeAfter }); } else { // Minimal diff for local: full new content. Local mode is dev-only; // detailed diffs come from the agent-side implementation. results.push({ path: file.relativePath, status: 'modified', diff: `--- a/${file.relativePath}\n+++ b/${file.relativePath}\n(local-driver: showing new content only)\n${file.content}`, sizeBefore: Buffer.byteLength(current), sizeAfter, }); } } return results; } async mkdir(basePath: string, relativePath: string) { await fs.mkdir(path.join(basePath, relativePath), { recursive: true }); } async fileExists(basePath: string, relativePath: string): Promise { try { await fs.access(path.join(basePath, relativePath)); return true; } catch { return false; } } async deleteDirectory(dirPath: string) { await fs.rm(dirPath, { recursive: true, force: true }); } async cloneSource(basePath: string, _gitRepo: string, _gitBranch: string, excludes?: string[]) { // Local provisioning uses rsync from CML_SOURCE_PATH const { CML_SOURCE_PATH } = await import('../config/env').then((m) => m.env); if (!CML_SOURCE_PATH) { throw new Error('CML_SOURCE_PATH not configured — cannot clone source'); } // SECURITY: Validate exclude entries — reject anything with shell metacharacters const SAFE_EXCLUDE = /^[a-zA-Z0-9_.\/-]+$/; const safeExcludes = (excludes || [ 'node_modules', '.git', '.env', 'changemaker-control-panel', '.claude', 'api/dist', 'admin/dist', 'uploads', 'data', ]).filter((e) => SAFE_EXCLUDE.test(e)); // SECURITY: Use execFile with args array — no shell interpolation const { execFile: execFileCb } = await import('child_process'); const execFileAsync = promisify(execFileCb); const args = ['-a', ...safeExcludes.flatMap((e) => ['--exclude', e]), `${CML_SOURCE_PATH}/`, `${basePath}/`]; await execFileAsync('rsync', args, { timeout: 120_000 }); } } /** Singleton local driver instance. */ let _localDriver: LocalDriver | null = null; export function getLocalDriver(): LocalDriver { if (!_localDriver) { _localDriver = new LocalDriver(); } return _localDriver; }