202 lines
5.7 KiB
TypeScript
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');
|
||
|
|
}
|