feat: CISandbox v1.0.0 — Interactive CI Pipeline Debugger with Local Env Sync
This commit is contained in:
Vendored
+198
@@ -0,0 +1,198 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { createInterface } from 'readline';
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
import { DockerManager } from './docker.js';
|
||||
import { normalizeRunsOn } from './parser.js';
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
function ask(q) {
|
||||
return new Promise(resolve => rl.question(q, resolve));
|
||||
}
|
||||
export async function runInteractive(ctx, options) {
|
||||
const docker = new DockerManager();
|
||||
const spinner = ora();
|
||||
// Load env file if provided
|
||||
let extraEnv = {};
|
||||
if (options.envFile) {
|
||||
const envContent = readFileSync(options.envFile, 'utf-8');
|
||||
for (const line of envContent.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#'))
|
||||
continue;
|
||||
const eq = trimmed.indexOf('=');
|
||||
if (eq > 0) {
|
||||
extraEnv[trimmed.slice(0, eq)] = trimmed.slice(eq + 1).replace(/^"|"$/g, '').replace(/^'|'$/g, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
const workspace = options.workspace ?? process.cwd();
|
||||
const image = normalizeRunsOn(ctx.job['runs-on']);
|
||||
const env = { ...ctx.workflow.env, ...ctx.job.env, ...extraEnv };
|
||||
console.log(chalk.bold.blue('\n⚡ CISandbox — Interactive CI Debugger\n'));
|
||||
console.log(chalk.gray(`Workflow: ${ctx.workflow.name}`));
|
||||
console.log(chalk.gray(`Job: ${ctx.jobName}`));
|
||||
console.log(chalk.gray(`Image: ${image}`));
|
||||
console.log(chalk.gray(`Workspace: ${workspace}\n`));
|
||||
if (options.dryRun) {
|
||||
console.log(chalk.yellow('DRY RUN mode — no containers will be created\n'));
|
||||
}
|
||||
// Show step preview
|
||||
console.log(chalk.bold('Steps:'));
|
||||
ctx.job.steps.forEach((step, i) => {
|
||||
const label = step.name ?? step.run ?? step.uses ?? `step ${i + 1}`;
|
||||
console.log(chalk.gray(` ${i + 1}. ${label}`));
|
||||
});
|
||||
console.log('');
|
||||
if (options.dryRun) {
|
||||
console.log(chalk.yellow('Dry run complete. Re-run without --dry-run to execute.\n'));
|
||||
return;
|
||||
}
|
||||
// Ensure image is available
|
||||
spinner.start(`Checking image: ${image}`);
|
||||
const exists = await docker.imageExists(image);
|
||||
if (!exists) {
|
||||
spinner.text = `Pulling image: ${image}`;
|
||||
try {
|
||||
await docker.pullImage(image);
|
||||
spinner.succeed(`Pulled image: ${image}`);
|
||||
}
|
||||
catch (err) {
|
||||
spinner.fail(`Failed to pull image: ${image}`);
|
||||
console.error(chalk.red(err instanceof Error ? err.message : String(err)));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
else {
|
||||
spinner.succeed(`Image ready: ${image}`);
|
||||
}
|
||||
// Create container
|
||||
spinner.start('Creating debug container');
|
||||
const containerId = await docker.createContainer(image, { workspace, env });
|
||||
ctx.containerId = containerId;
|
||||
spinner.succeed(`Container created: ${containerId.slice(0, 12)}`);
|
||||
console.log(chalk.bold.green('\n▶ Starting interactive execution\n'));
|
||||
console.log(chalk.gray('Controls: [Enter]=continue | [s]=skip | [m]=modify command | [e]=edit env | [q]=quit\n'));
|
||||
try {
|
||||
for (let i = 0; i < ctx.job.steps.length; i++) {
|
||||
const step = ctx.job.steps[i];
|
||||
const result = await runStepInteractive(docker, containerId, step, i, ctx);
|
||||
ctx.stepResults.push(result);
|
||||
if (result.status === 'failure') {
|
||||
console.log(chalk.bold.red(`\n✖ Step ${i + 1} FAILED (exit ${result.exitCode})`));
|
||||
const choice = await ask(chalk.yellow('Retry (r), modify+retry (m), skip (s), or quit (q)? [r/m/s/q]: '));
|
||||
if (choice.trim().toLowerCase() === 'r') {
|
||||
i--; // retry same step
|
||||
ctx.stepResults.pop();
|
||||
continue;
|
||||
}
|
||||
else if (choice.trim().toLowerCase() === 'm') {
|
||||
const newCmd = await ask(chalk.cyan('New command: '));
|
||||
const retryResult = await runStepInteractive(docker, containerId, step, i, ctx, newCmd);
|
||||
ctx.stepResults[ctx.stepResults.length - 1] = retryResult;
|
||||
if (retryResult.status === 'failure') {
|
||||
console.log(chalk.red('Modified command also failed. Moving on...\n'));
|
||||
}
|
||||
}
|
||||
else if (choice.trim().toLowerCase() === 's') {
|
||||
result.status = 'skipped';
|
||||
console.log(chalk.yellow('Skipped.\n'));
|
||||
}
|
||||
else {
|
||||
console.log(chalk.gray('Quitting...'));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
spinner.start('Stopping container');
|
||||
await docker.stopContainer(containerId);
|
||||
spinner.succeed('Container stopped');
|
||||
}
|
||||
// Summary
|
||||
console.log(chalk.bold.blue('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
|
||||
console.log(chalk.bold('Run Summary'));
|
||||
console.log(chalk.bold.blue('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'));
|
||||
for (const r of ctx.stepResults) {
|
||||
const icon = r.status === 'success' ? chalk.green('✔') : r.status === 'failure' ? chalk.red('✖') : chalk.yellow('○');
|
||||
const label = r.step.name ?? r.step.run ?? r.step.uses ?? `step ${r.index + 1}`;
|
||||
const dur = `${r.durationMs}ms`;
|
||||
console.log(`${icon} Step ${r.index + 1}: ${label} ${chalk.gray(`(${dur})`)}`);
|
||||
if (r.modifiedCommand) {
|
||||
console.log(chalk.cyan(` modified: ${r.modifiedCommand}`));
|
||||
}
|
||||
}
|
||||
const passed = ctx.stepResults.filter(r => r.status === 'success').length;
|
||||
const failed = ctx.stepResults.filter(r => r.status === 'failure').length;
|
||||
const skipped = ctx.stepResults.filter(r => r.status === 'skipped').length;
|
||||
console.log(chalk.bold.blue('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
|
||||
console.log(`${chalk.green(`${passed} passed`)} | ${chalk.red(`${failed} failed`)} | ${chalk.yellow(`${skipped} skipped`)}`);
|
||||
console.log(chalk.bold.blue('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'));
|
||||
rl.close();
|
||||
}
|
||||
async function runStepInteractive(docker, containerId, step, index, ctx, overrideCommand) {
|
||||
const label = step.name ?? step.run ?? step.uses ?? `step ${index + 1}`;
|
||||
console.log(chalk.bold(`\n▶ Step ${index + 1}: ${label}`));
|
||||
if (step.if) {
|
||||
console.log(chalk.gray(` condition: ${step.if}`));
|
||||
}
|
||||
if (step.uses) {
|
||||
console.log(chalk.gray(` uses: ${step.uses}`));
|
||||
}
|
||||
if (step.run) {
|
||||
console.log(chalk.gray(` run: ${step.run.slice(0, 120)}${step.run.length > 120 ? '...' : ''}`));
|
||||
}
|
||||
const action = await ask(chalk.gray(' [Enter]=run | s=skip | m=modify | q=quit: '));
|
||||
const choice = action.trim().toLowerCase();
|
||||
if (choice === 'q') {
|
||||
return { step, index, status: 'skipped', exitCode: null, stdout: '', stderr: '', durationMs: 0 };
|
||||
}
|
||||
if (choice === 's') {
|
||||
console.log(chalk.yellow(' → skipped'));
|
||||
return { step, index, status: 'skipped', exitCode: null, stdout: '', stderr: '', durationMs: 0 };
|
||||
}
|
||||
let cmd = overrideCommand;
|
||||
if (choice === 'm') {
|
||||
cmd = await ask(chalk.cyan(' New command: '));
|
||||
}
|
||||
const start = Date.now();
|
||||
const spinner = ora(' Running...').start();
|
||||
try {
|
||||
const result = await docker.execInContainer(containerId, step, cmd);
|
||||
const durationMs = Date.now() - start;
|
||||
spinner.stop();
|
||||
if (result.stdout) {
|
||||
console.log(chalk.gray(result.stdout.split('\n').map(l => ` │ ${l}`).join('\n')));
|
||||
}
|
||||
if (result.stderr) {
|
||||
console.log(chalk.red(result.stderr.split('\n').map(l => ` ✕ ${l}`).join('\n')));
|
||||
}
|
||||
if (result.exitCode === 0) {
|
||||
console.log(chalk.green(` ✔ success (${durationMs}ms)`));
|
||||
return {
|
||||
step, index, status: 'success',
|
||||
exitCode: result.exitCode,
|
||||
stdout: result.stdout, stderr: result.stderr,
|
||||
durationMs,
|
||||
modifiedCommand: cmd,
|
||||
};
|
||||
}
|
||||
else {
|
||||
console.log(chalk.red(` ✖ failed (exit ${result.exitCode}, ${durationMs}ms)`));
|
||||
return {
|
||||
step, index, status: 'failure',
|
||||
exitCode: result.exitCode,
|
||||
stdout: result.stdout, stderr: result.stderr,
|
||||
durationMs,
|
||||
modifiedCommand: cmd,
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
spinner.stop();
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.log(chalk.red(` ✖ error: ${msg}`));
|
||||
return { step, index, status: 'failure', exitCode: 1, stdout: '', stderr: msg, durationMs: Date.now() - start, modifiedCommand: cmd };
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=runner.js.map
|
||||
Reference in New Issue
Block a user