Files
apophis-fastify/src/cli/core/workspace-runner.ts
T

202 lines
5.7 KiB
TypeScript

/**
* 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<WorkspaceResult> {
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');
}