/** * Workspace runner for APOPHIS CLI commands. * * Responsibilities: * - Fan out a command across all workspace packages * - Collect per-package artifacts with package attribution * - Aggregate results into a single workspace result * - Support json, ndjson, and human output formats * - Preserve exit codes: fail if any package fails * * Architecture: * - Dependency injection: all dependencies passed explicitly * - No optional imports — everything is required or injected * - Inline comments for documentation */ import type { CliContext } from './context.js'; import { findWorkspacePackages } from './config-loader.js'; import type { Artifact, WorkspaceRun, WorkspaceResult, ExitCode } from './types.js'; import { SUCCESS } from './exit-codes.js'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export type RunCommandFn = (ctx: CliContext) => Promise<{ exitCode: number; artifact?: Artifact; warnings?: string[] }>; export interface WorkspaceRunnerDeps { runCommand: RunCommandFn; findPackages?: (cwd: string) => string[]; } // --------------------------------------------------------------------------- // Workspace package discovery // --------------------------------------------------------------------------- /** * Discover workspace packages using config-loader. * Falls back to empty array if no workspaces found. */ function discoverPackages(cwd: string, findPackages?: (cwd: string) => string[]): string[] { if (findPackages) { return findPackages(cwd); } return findWorkspacePackages(cwd); } // --------------------------------------------------------------------------- // Package name extraction // --------------------------------------------------------------------------- /** * Extract package name from absolute path. * Uses basename of the directory. */ function getPackageName(pkgPath: string): string { const parts = pkgPath.split('/'); return parts[parts.length - 1] || 'unknown'; } // --------------------------------------------------------------------------- // Workspace execution // --------------------------------------------------------------------------- /** * Run a command across all workspace packages. * * Flow: * 1. Discover workspace packages * 2. Run command for each package with package-attributed context * 3. Collect artifacts and warnings * 4. Determine overall exit code (fail if any package fails) * 5. Return workspace result with all runs */ export async function runWorkspace( deps: WorkspaceRunnerDeps, ctx: CliContext, ): Promise { const packages = discoverPackages(ctx.cwd, deps.findPackages); if (packages.length === 0) { return { exitCode: SUCCESS as ExitCode, runs: [], message: 'No workspace packages found.', }; } const runs: WorkspaceRun[] = []; let overallExitCode = SUCCESS; const allWarnings: string[] = []; for (const pkgPath of packages) { const pkgName = getPackageName(pkgPath); // Create a context scoped to this package's directory const pkgCtx: CliContext = { ...ctx, cwd: pkgPath, }; const pkgResult = await deps.runCommand(pkgCtx); if (pkgResult.artifact) { // Attach package name to artifact for attribution const attributedArtifact: Artifact = { ...pkgResult.artifact, package: pkgName, }; runs.push({ package: pkgName, cwd: pkgPath, artifact: attributedArtifact, }); } if (pkgResult.exitCode !== SUCCESS) { overallExitCode = pkgResult.exitCode as ExitCode; } if (pkgResult.warnings) { allWarnings.push(...pkgResult.warnings.map(w => `[${pkgName}] ${w}`)); } } return { exitCode: overallExitCode as ExitCode, runs, warnings: allWarnings.length > 0 ? allWarnings : undefined, }; } // --------------------------------------------------------------------------- // Output formatting // --------------------------------------------------------------------------- /** * Format workspace results for human-readable output. * Shows per-package summary with pass/fail status. */ export function formatWorkspaceHuman(result: WorkspaceResult): string { const lines: string[] = []; lines.push('Workspace results'); lines.push(''); for (const run of result.runs) { const a = run.artifact; const status = a.exitReason === 'success' ? '✓' : '✗'; lines.push(` ${status} ${run.package}: ${a.summary.passed}/${a.summary.total} passed`); if (a.summary.failed > 0) { lines.push(` ${a.summary.failed} failed`); } } lines.push(''); lines.push(`Overall: ${result.exitCode === SUCCESS ? 'passed' : 'failed'}`); return lines.join('\n'); } /** * Format workspace results as JSON. * Includes all runs with full artifacts. */ export function formatWorkspaceJson(result: WorkspaceResult): string { return JSON.stringify({ exitCode: result.exitCode, runs: result.runs.map(r => ({ package: r.package, cwd: r.cwd, artifact: r.artifact, })), warnings: result.warnings, }, null, 2); } /** * Format workspace results as NDJSON. * Emits one event per package plus a completion event. */ export function formatWorkspaceNdjson(result: WorkspaceResult): string { const lines: string[] = []; for (const run of result.runs) { lines.push(JSON.stringify({ type: 'workspace.run.completed', package: run.package, cwd: run.cwd, summary: run.artifact.summary, exitReason: run.artifact.exitReason, })); } lines.push(JSON.stringify({ type: 'workspace.completed', exitCode: result.exitCode, packages: result.runs.length, })); return lines.join('\n'); }