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