feat: CISandbox v1.0.0 — Interactive CI Pipeline Debugger with Local Env Sync

This commit is contained in:
Bun Bun
2026-06-16 06:24:19 +00:00
commit af0d21c025
45 changed files with 4584 additions and 0 deletions
+198
View File
@@ -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