ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: mos-dt-0 <[email protected]>
203 lines
7.8 KiB
TypeScript
203 lines
7.8 KiB
TypeScript
import { existsSync, readFileSync } from 'node:fs';
|
|
|
|
import type { Command } from 'commander';
|
|
|
|
import { runGates } from './gate-runner.js';
|
|
import { MACPCapabilityError, type MacpErrorCode } from './errors.js';
|
|
|
|
/**
|
|
* Load gates from a spec: an existing file (JSON gates array, a JSON object
|
|
* with `quality_gates`, a JSON gate object, or one command per line) or an
|
|
* inline command string. Fails closed with a typed capability error when the
|
|
* spec contains no executable gate definition.
|
|
*/
|
|
function loadGateSpec(spec: string): unknown[] {
|
|
if (existsSync(spec)) {
|
|
const raw = readFileSync(spec, 'utf-8');
|
|
try {
|
|
const parsed = JSON.parse(raw) as unknown;
|
|
if (Array.isArray(parsed)) {
|
|
if (parsed.length === 0) {
|
|
throw new MACPCapabilityError(
|
|
'MACP_NO_COMMAND',
|
|
'gate-spec',
|
|
`gate spec file '${spec}' contains an empty gates array`,
|
|
);
|
|
}
|
|
return parsed;
|
|
}
|
|
if (typeof parsed === 'object' && parsed !== null) {
|
|
const obj = parsed as Record<string, unknown>;
|
|
if (Array.isArray(obj['quality_gates'])) {
|
|
return obj['quality_gates'];
|
|
}
|
|
return [parsed];
|
|
}
|
|
throw new MACPCapabilityError(
|
|
'MACP_NO_COMMAND',
|
|
'gate-spec',
|
|
`gate spec file '${spec}' parsed to ${typeof parsed} — expected a gates array, a task with quality_gates, or a gate object`,
|
|
);
|
|
} catch (exc) {
|
|
if (exc instanceof MACPCapabilityError) throw exc;
|
|
// Not JSON — treat each non-empty line as a command gate.
|
|
const lines = raw
|
|
.split('\n')
|
|
.map((l) => l.trim())
|
|
.filter((l) => l.length > 0);
|
|
if (lines.length > 0) return lines;
|
|
throw new MACPCapabilityError(
|
|
'MACP_NO_COMMAND',
|
|
'gate-spec',
|
|
`gate spec file '${spec}' contains no gates`,
|
|
);
|
|
}
|
|
}
|
|
if (spec.trim().length > 0) return [spec];
|
|
throw new MACPCapabilityError('MACP_NO_COMMAND', 'gate-spec', 'gate spec is empty');
|
|
}
|
|
|
|
/** Print a typed not-implemented failure and exit nonzero (RI-N2 fail-closed). */
|
|
function notImplemented(subcommand: string, capability: string, hint: string): void {
|
|
const err = new MACPCapabilityError(
|
|
'MACP_NOT_IMPLEMENTED',
|
|
capability,
|
|
`${subcommand} is not implemented in @mosaicstack/macp yet (${capability} capability absent) — ${hint}`,
|
|
);
|
|
console.error(`[macp] ${subcommand}: ${err.message} [${err.code}]`);
|
|
process.exitCode = 1;
|
|
}
|
|
|
|
/**
|
|
* Register macp subcommands on an existing Commander program.
|
|
* This avoids cross-package Commander version mismatches by using the
|
|
* caller's Command instance directly.
|
|
*/
|
|
export function registerMacpCommand(parent: Command): void {
|
|
const macp = parent.command('macp').description('MACP task and gate management');
|
|
|
|
// ─── tasks ───────────────────────────────────────────────────────────────
|
|
|
|
const tasks = macp.command('tasks').description('Manage MACP tasks');
|
|
|
|
tasks
|
|
.command('list')
|
|
.description('List MACP tasks')
|
|
.option(
|
|
'--status <status>',
|
|
'Filter by task status (pending|running|gated|completed|failed|escalated)',
|
|
)
|
|
.option(
|
|
'--type <type>',
|
|
'Filter by task type (coding|deploy|research|review|documentation|infrastructure)',
|
|
)
|
|
.action((opts: { status?: string; type?: string }) => {
|
|
// unimplemented capability — a failure, never a success (RI-N2)
|
|
if (opts.status) {
|
|
console.log(` status filter: ${opts.status}`);
|
|
}
|
|
if (opts.type) {
|
|
console.log(` type filter: ${opts.type}`);
|
|
}
|
|
notImplemented('tasks list', 'task-persistence', 'use the macp package programmatically');
|
|
});
|
|
|
|
// ─── submit ──────────────────────────────────────────────────────────────
|
|
|
|
macp
|
|
.command('submit <path>')
|
|
.description('Submit a task from a JSON/YAML spec file')
|
|
.action((specPath: string) => {
|
|
// unimplemented capability — a failure, never a success (RI-N2)
|
|
console.log(` spec path: ${specPath}`);
|
|
console.log(' task id: (unavailable — no MACP server connected)');
|
|
console.log(' status: (unavailable — no MACP server connected)');
|
|
notImplemented('submit', 'macp-server', 'use the macp package programmatically');
|
|
});
|
|
|
|
// ─── gate ────────────────────────────────────────────────────────────────
|
|
|
|
macp
|
|
.command('gate <spec>')
|
|
.description('Run a gate from a spec string or file path (wraps runGate/runGates)')
|
|
.option('--fail-on <mode>', 'Gate fail-on mode: ai|fail|both|none', 'fail')
|
|
.option('--cwd <path>', 'Working directory for gate execution', process.cwd())
|
|
.option('--log <path>', 'Path to write gate log output', '/tmp/macp-gate.log')
|
|
.option('--timeout <seconds>', 'Gate timeout in seconds', '60')
|
|
.option(
|
|
'--simulate',
|
|
'Simulate gates instead of executing them; results are typed simulated and never satisfy a check',
|
|
)
|
|
.action(
|
|
(
|
|
spec: string,
|
|
opts: { failOn: string; cwd: string; log: string; timeout: string; simulate?: boolean },
|
|
) => {
|
|
let gates: unknown[];
|
|
try {
|
|
gates = loadGateSpec(spec);
|
|
} catch (exc) {
|
|
if (exc instanceof MACPCapabilityError) {
|
|
console.error(`[macp] gate: ${exc.message} [${exc.code}]`);
|
|
} else {
|
|
console.error(`[macp] gate: ${String(exc)}`);
|
|
}
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
const timeoutSec = Number.parseInt(opts.timeout, 10) || 60;
|
|
const eventsPath = `${opts.log}.events.ndjson`;
|
|
const { state, gateResults } = runGates(
|
|
gates,
|
|
opts.cwd,
|
|
opts.log,
|
|
timeoutSec,
|
|
eventsPath,
|
|
'macp-cli-gate',
|
|
{
|
|
simulate: opts.simulate,
|
|
},
|
|
);
|
|
|
|
for (const r of gateResults) {
|
|
const label = r.command || r.type;
|
|
const reason = r.reason ? ` — ${r.reason}` : '';
|
|
console.log(`[macp] gate ${r.status}: ${label}${reason}`);
|
|
}
|
|
if (opts.simulate) {
|
|
console.log(
|
|
'[macp] SIMULATED run — every result is typed simulated and can never satisfy a gate, dependency, or release check',
|
|
);
|
|
}
|
|
|
|
// Simulated runs may complete (exit 0) only because the caller
|
|
// explicitly passed --simulate; the typed state stays 'simulated'.
|
|
process.exitCode = state === 'passed' || state === 'simulated' ? 0 : 1;
|
|
},
|
|
);
|
|
|
|
// ─── events ──────────────────────────────────────────────────────────────
|
|
|
|
const events = macp.command('events').description('Stream MACP events');
|
|
|
|
events
|
|
.command('tail')
|
|
.description('Tail MACP events from the event log (wraps event emitter)')
|
|
.option('--file <path>', 'Path to the MACP events NDJSON file')
|
|
.option('--follow', 'Follow the file for new events (like tail -f)')
|
|
.action((opts: { file?: string; follow?: boolean }) => {
|
|
// unimplemented capability — a failure, never a success (RI-N2)
|
|
if (opts.file) {
|
|
console.log(` file: ${opts.file}`);
|
|
}
|
|
if (opts.follow) {
|
|
console.log(' mode: follow');
|
|
}
|
|
notImplemented('events tail', 'event-source', 'use the macp package programmatically');
|
|
});
|
|
}
|
|
|
|
// Re-export so CLI consumers can surface typed capability codes.
|
|
export type { MacpErrorCode };
|