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
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env node
export {};
//# sourceMappingURL=cli.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}
Vendored
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env node
import { Command } from 'commander';
import chalk from 'chalk';
import { parseWorkflow, pickJob } from './parser.js';
import { runInteractive } from './runner.js';
const program = new Command();
program
.name('cisandbox')
.description('Interactive CI Pipeline Debugger with Local Env Sync')
.version('1.0.0');
program
.argument('<workflow-file>', 'Path to GitHub Actions workflow YAML')
.option('-j, --job <name>', 'Job name to run (required if multiple jobs)')
.option('-w, --workspace <path>', 'Workspace directory to mount', process.cwd())
.option('-e, --env-file <path>', '.env file to load into container')
.option('-n, --dry-run', 'Preview steps without running containers')
.action(async (workflowFile, options) => {
try {
const workflow = parseWorkflow(workflowFile);
const { name: jobName, job } = pickJob(workflow, options.job);
const ctx = {
workflow,
jobName,
job,
stepResults: [],
containerId: null,
env: {},
};
await runInteractive(ctx, {
workflowFile,
jobName,
dryRun: options.dryRun,
workspace: options.workspace,
envFile: options.envFile,
});
const failed = ctx.stepResults.filter(r => r.status === 'failure').length;
process.exit(failed > 0 ? 1 : 0);
}
catch (err) {
console.error(chalk.red(`Error: ${err instanceof Error ? err.message : String(err)}`));
process.exit(1);
}
});
program.parse();
//# sourceMappingURL=cli.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,KAAK,MAAM,OAAO,CAAC;AAC1B,OAAO,EAAE,aAAa,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AACrD,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAG7C,MAAM,OAAO,GAAG,IAAI,OAAO,EAAE,CAAC;AAE9B,OAAO;KACJ,IAAI,CAAC,WAAW,CAAC;KACjB,WAAW,CAAC,sDAAsD,CAAC;KACnE,OAAO,CAAC,OAAO,CAAC,CAAC;AAEpB,OAAO;KACJ,QAAQ,CAAC,iBAAiB,EAAE,sCAAsC,CAAC;KACnE,MAAM,CAAC,kBAAkB,EAAE,6CAA6C,CAAC;KACzE,MAAM,CAAC,wBAAwB,EAAE,8BAA8B,EAAE,OAAO,CAAC,GAAG,EAAE,CAAC;KAC/E,MAAM,CAAC,uBAAuB,EAAE,kCAAkC,CAAC;KACnE,MAAM,CAAC,eAAe,EAAE,0CAA0C,CAAC;KACnE,MAAM,CAAC,KAAK,EAAE,YAAoB,EAAE,OAAiF,EAAE,EAAE;IACxH,IAAI,CAAC;QACH,MAAM,QAAQ,GAAG,aAAa,CAAC,YAAY,CAAC,CAAC;QAC7C,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,GAAG,EAAE,GAAG,OAAO,CAAC,QAAQ,EAAE,OAAO,CAAC,GAAG,CAAC,CAAC;QAE9D,MAAM,GAAG,GAAe;YACtB,QAAQ;YACR,OAAO;YACP,GAAG;YACH,WAAW,EAAE,EAAE;YACf,WAAW,EAAE,IAAI;YACjB,GAAG,EAAE,EAAE;SACR,CAAC;QAEF,MAAM,cAAc,CAAC,GAAG,EAAE;YACxB,YAAY;YACZ,OAAO;YACP,MAAM,EAAE,OAAO,CAAC,MAAM;YACtB,SAAS,EAAE,OAAO,CAAC,SAAS;YAC5B,OAAO,EAAE,OAAO,CAAC,OAAO;SACzB,CAAC,CAAC;QAEH,MAAM,MAAM,GAAG,GAAG,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,MAAM,CAAC;QAC1E,OAAO,CAAC,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IACnC,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,UAAU,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;QACvF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;AACH,CAAC,CAAC,CAAC;AAEL,OAAO,CAAC,KAAK,EAAE,CAAC"}
+17
View File
@@ -0,0 +1,17 @@
import Docker from 'dockerode';
import type { Step, DockerExecResult } from './types.js';
export declare class DockerManager {
docker: Docker;
constructor();
pullImage(image: string): Promise<void>;
createContainer(image: string, options: {
workspace: string;
env: Record<string, string>;
workingDir?: string;
}): Promise<string>;
execInContainer(containerId: string, step: Step, modifiedCommand?: string): Promise<DockerExecResult>;
private runShell;
stopContainer(containerId: string): Promise<void>;
imageExists(image: string): Promise<boolean>;
}
//# sourceMappingURL=docker.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"docker.d.ts","sourceRoot":"","sources":["../src/docker.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,WAAW,CAAC;AAE/B,OAAO,KAAK,EAAE,IAAI,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAEzD,qBAAa,aAAa;IACxB,MAAM,EAAE,MAAM,CAAC;;IAMT,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAUvC,eAAe,CACnB,KAAK,EAAE,MAAM,EACb,OAAO,EAAE;QACP,SAAS,EAAE,MAAM,CAAC;QAClB,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAC5B,UAAU,CAAC,EAAE,MAAM,CAAC;KACrB,GACA,OAAO,CAAC,MAAM,CAAC;IAiBZ,eAAe,CACnB,WAAW,EAAE,MAAM,EACnB,IAAI,EAAE,IAAI,EACV,eAAe,CAAC,EAAE,MAAM,GACvB,OAAO,CAAC,gBAAgB,CAAC;YAed,QAAQ;IA6ChB,aAAa,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IASjD,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;CAQnD"}
+103
View File
@@ -0,0 +1,103 @@
import Docker from 'dockerode';
import { resolve } from 'path';
export class DockerManager {
docker;
constructor() {
this.docker = new Docker();
}
async pullImage(image) {
const stream = await this.docker.pull(image);
return new Promise((resolve, reject) => {
this.docker.modem.followProgress(stream, (err) => {
if (err)
reject(err);
else
resolve();
});
});
}
async createContainer(image, options) {
const absWorkspace = resolve(options.workspace);
const container = await this.docker.createContainer({
Image: image,
Cmd: ['sleep', '3600'],
WorkingDir: options.workingDir || '/github/workspace',
HostConfig: {
Binds: [`${absWorkspace}:/github/workspace`],
AutoRemove: true,
},
Env: Object.entries(options.env).map(([k, v]) => `${k}=${v}`),
Entrypoint: ['sh', '-c', 'while true; do sleep 3600; done'],
});
await container.start();
return container.id;
}
async execInContainer(containerId, step, modifiedCommand) {
const container = this.docker.getContainer(containerId);
const cmd = modifiedCommand ?? step.run ?? 'true';
const shell = step.shell ?? 'bash';
const workingDir = step.workingDirectory ?? '/github/workspace';
// Handle 'uses' steps by converting to a comment
if (!modifiedCommand && step.uses) {
const usesCmd = `echo "[CISandbox] Action step: ${step.uses}" && echo "Note: 'uses' actions are not yet supported in CISandbox. Convert to 'run' or modify the step to debug."`;
return this.runShell(container, usesCmd, workingDir, shell, step.env);
}
return this.runShell(container, cmd, workingDir, shell, step.env);
}
async runShell(container, cmd, workingDir, shell, extraEnv) {
const exec = await container.exec({
Cmd: [shell, '-c', cmd],
WorkingDir: workingDir,
AttachStdout: true,
AttachStderr: true,
Env: extraEnv ? Object.entries(extraEnv).map(([k, v]) => `${k}=${v}`) : undefined,
});
const stream = await exec.start({ hijack: true, stdin: false });
let stdout = '';
let stderr = '';
return new Promise((resolve, reject) => {
stream.on('data', (chunk) => {
// Docker multiplexed stream: first byte is stream type
const type = chunk[0];
const payload = chunk.slice(8).toString('utf-8');
if (type === 1)
stdout += payload;
else
stderr += payload;
});
stream.on('end', async () => {
try {
const inspect = await exec.inspect();
resolve({
exitCode: inspect.ExitCode ?? 0,
stdout: stdout.trimEnd(),
stderr: stderr.trimEnd(),
});
}
catch (err) {
reject(err);
}
});
stream.on('error', reject);
});
}
async stopContainer(containerId) {
try {
const container = this.docker.getContainer(containerId);
await container.stop({ t: 2 });
}
catch {
// Already stopped or doesn't exist
}
}
async imageExists(image) {
try {
await this.docker.getImage(image).inspect();
return true;
}
catch {
return false;
}
}
}
//# sourceMappingURL=docker.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"docker.js","sourceRoot":"","sources":["../src/docker.ts"],"names":[],"mappings":"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"}
+2
View File
@@ -0,0 +1,2 @@
export {};
//# sourceMappingURL=docker.test.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"docker.test.d.ts","sourceRoot":"","sources":["../src/docker.test.ts"],"names":[],"mappings":""}
+73
View File
@@ -0,0 +1,73 @@
import { describe, it, expect, vi } from 'vitest';
import { DockerManager } from '../src/docker.js';
// Mock dockerode
vi.mock('dockerode', () => {
return {
default: class MockDocker {
getContainer = vi.fn((id) => ({
exec: vi.fn(async (opts) => {
const cmd = opts.Cmd.join(' ');
const isUsesWarning = cmd.includes('Action step');
const stdoutText = isUsesWarning
? '[CISandbox] Action step: actions/checkout@v4\nNote: \'uses\' actions are not yet supported in CISandbox. Convert to \'run\' or modify the step to debug.'
: 'hello';
return {
start: vi.fn(async () => {
const { EventEmitter } = await import('events');
const ee = new EventEmitter();
process.nextTick(() => {
const buf = Buffer.alloc(8 + stdoutText.length);
buf[0] = 1;
buf.write(stdoutText, 8);
ee.emit('data', buf);
ee.emit('end');
});
return ee;
}),
inspect: vi.fn(async () => ({ ExitCode: 0 })),
};
}),
stop: vi.fn(async () => { }),
}));
getImage = vi.fn(() => ({
inspect: vi.fn(async () => ({})),
}));
createContainer = vi.fn(async () => ({ id: 'abc123', start: vi.fn(async () => { }) }));
pull = vi.fn(async () => {
const { EventEmitter } = await import('events');
const ee = new EventEmitter();
process.nextTick(() => ee.emit('end'));
return ee;
});
modem = { followProgress: vi.fn((_, cb) => cb()) };
},
};
});
describe('DockerManager', () => {
it('checks image existence', async () => {
const dm = new DockerManager();
const exists = await dm.imageExists('node:20-slim');
expect(exists).toBe(true);
});
it('creates a container', async () => {
const dm = new DockerManager();
const id = await dm.createContainer('node:20-slim', {
workspace: '/tmp',
env: { FOO: 'bar' },
});
expect(id).toBe('abc123');
});
it('executes a step', async () => {
const dm = new DockerManager();
const result = await dm.execInContainer('abc123', { run: 'echo hello' });
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('hello');
});
it('handles uses steps with warning', async () => {
const dm = new DockerManager();
const result = await dm.execInContainer('abc123', { uses: 'actions/checkout@v4' });
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('not yet supported');
});
});
//# sourceMappingURL=docker.test.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"docker.test.js","sourceRoot":"","sources":["../src/docker.test.ts"],"names":[],"mappings":"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"}
+5
View File
@@ -0,0 +1,5 @@
export { parseWorkflow, listJobs, pickJob, normalizeRunsOn } from './parser.js';
export { DockerManager } from './docker.js';
export { runInteractive } from './runner.js';
export type { Workflow, Job, Step, StepResult, RunContext, RunnerOptions, DockerExecResult, } from './types.js';
//# sourceMappingURL=index.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,QAAQ,EAAE,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAChF,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAC7C,YAAY,EACV,QAAQ,EACR,GAAG,EACH,IAAI,EACJ,UAAU,EACV,UAAU,EACV,aAAa,EACb,gBAAgB,GACjB,MAAM,YAAY,CAAC"}
+4
View File
@@ -0,0 +1,4 @@
export { parseWorkflow, listJobs, pickJob, normalizeRunsOn } from './parser.js';
export { DockerManager } from './docker.js';
export { runInteractive } from './runner.js';
//# sourceMappingURL=index.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,QAAQ,EAAE,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAChF,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC"}
+12
View File
@@ -0,0 +1,12 @@
import type { Workflow, Job } from './types.js';
export declare function parseWorkflow(filePath: string): Workflow;
export declare function listJobs(workflow: Workflow): {
name: string;
job: Job;
}[];
export declare function pickJob(workflow: Workflow, jobName?: string): {
name: string;
job: Job;
};
export declare function normalizeRunsOn(runsOn: string): string;
//# sourceMappingURL=parser.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["../src/parser.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,QAAQ,EAAE,GAAG,EAAE,MAAM,YAAY,CAAC;AAEhD,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,QAAQ,CAaxD;AAED,wBAAgB,QAAQ,CAAC,QAAQ,EAAE,QAAQ,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,GAAG,CAAA;CAAE,EAAE,CAEzE;AAED,wBAAgB,OAAO,CAAC,QAAQ,EAAE,QAAQ,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,GAAG,CAAA;CAAE,CAsBxF;AAED,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAWtD"}
+49
View File
@@ -0,0 +1,49 @@
import { readFileSync } from 'fs';
import { resolve } from 'path';
import yaml from 'js-yaml';
export function parseWorkflow(filePath) {
const fullPath = resolve(filePath);
const content = readFileSync(fullPath, 'utf-8');
const parsed = yaml.load(content);
if (!parsed || typeof parsed !== 'object') {
throw new Error(`Invalid YAML in ${filePath}: not an object`);
}
if (!parsed.jobs || typeof parsed.jobs !== 'object') {
throw new Error(`Invalid workflow in ${filePath}: missing 'jobs' field`);
}
return parsed;
}
export function listJobs(workflow) {
return Object.entries(workflow.jobs).map(([name, job]) => ({ name, job }));
}
export function pickJob(workflow, jobName) {
const jobs = listJobs(workflow);
if (jobs.length === 0) {
throw new Error('Workflow has no jobs');
}
if (jobName) {
const found = jobs.find(j => j.name === jobName);
if (!found) {
throw new Error(`Job "${jobName}" not found. Available: ${jobs.map(j => j.name).join(', ')}`);
}
return found;
}
// Return first job if only one exists
if (jobs.length === 1) {
return jobs[0];
}
throw new Error(`Multiple jobs found: ${jobs.map(j => j.name).join(', ')}. Use --job to specify one.`);
}
export function normalizeRunsOn(runsOn) {
const mapping = {
'ubuntu-latest': 'node:20-slim',
'ubuntu-24.04': 'node:20-slim',
'ubuntu-22.04': 'node:20-slim',
'ubuntu-20.04': 'node:18-slim',
'node-latest': 'node:22-slim',
'node-20': 'node:20-slim',
'node-18': 'node:18-slim',
};
return mapping[runsOn] ?? runsOn;
}
//# sourceMappingURL=parser.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"parser.js","sourceRoot":"","sources":["../src/parser.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,IAAI,CAAC;AAClC,OAAO,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AAC/B,OAAO,IAAI,MAAM,SAAS,CAAC;AAG3B,MAAM,UAAU,aAAa,CAAC,QAAgB;IAC5C,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IACnC,MAAM,OAAO,GAAG,YAAY,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC;IAChD,MAAM,MAAM,GAAG,IAAI,CAAC,IAAI,CAAC,OAAO,CAAa,CAAC;IAE9C,IAAI,CAAC,MAAM,IAAI,OAAO,MAAM,KAAK,QAAQ,EAAE,CAAC;QAC1C,MAAM,IAAI,KAAK,CAAC,mBAAmB,QAAQ,iBAAiB,CAAC,CAAC;IAChE,CAAC;IACD,IAAI,CAAC,MAAM,CAAC,IAAI,IAAI,OAAO,MAAM,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;QACpD,MAAM,IAAI,KAAK,CAAC,uBAAuB,QAAQ,wBAAwB,CAAC,CAAC;IAC3E,CAAC;IAED,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,MAAM,UAAU,QAAQ,CAAC,QAAkB;IACzC,OAAO,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,GAAG,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,GAAG,EAAE,CAAC,CAAC,CAAC;AAC7E,CAAC;AAED,MAAM,UAAU,OAAO,CAAC,QAAkB,EAAE,OAAgB;IAC1D,MAAM,IAAI,GAAG,QAAQ,CAAC,QAAQ,CAAC,CAAC;IAChC,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACtB,MAAM,IAAI,KAAK,CAAC,sBAAsB,CAAC,CAAC;IAC1C,CAAC;IAED,IAAI,OAAO,EAAE,CAAC;QACZ,MAAM,KAAK,GAAG,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,OAAO,CAAC,CAAC;QACjD,IAAI,CAAC,KAAK,EAAE,CAAC;YACX,MAAM,IAAI,KAAK,CAAC,QAAQ,OAAO,2BAA2B,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QAChG,CAAC;QACD,OAAO,KAAK,CAAC;IACf,CAAC;IAED,sCAAsC;IACtC,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACtB,OAAO,IAAI,CAAC,CAAC,CAAC,CAAC;IACjB,CAAC;IAED,MAAM,IAAI,KAAK,CACb,wBAAwB,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,6BAA6B,CACtF,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,eAAe,CAAC,MAAc;IAC5C,MAAM,OAAO,GAA2B;QACtC,eAAe,EAAE,cAAc;QAC/B,cAAc,EAAE,cAAc;QAC9B,cAAc,EAAE,cAAc;QAC9B,cAAc,EAAE,cAAc;QAC9B,aAAa,EAAE,cAAc;QAC7B,SAAS,EAAE,cAAc;QACzB,SAAS,EAAE,cAAc;KAC1B,CAAC;IACF,OAAO,OAAO,CAAC,MAAM,CAAC,IAAI,MAAM,CAAC;AACnC,CAAC"}
+2
View File
@@ -0,0 +1,2 @@
export {};
//# sourceMappingURL=parser.test.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"parser.test.d.ts","sourceRoot":"","sources":["../src/parser.test.ts"],"names":[],"mappings":""}
+108
View File
@@ -0,0 +1,108 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { writeFileSync, mkdirSync, rmSync } from 'fs';
import { join } from 'path';
import { parseWorkflow, listJobs, pickJob, normalizeRunsOn } from '../src/parser.js';
const TMP = join(process.cwd(), 'tmp-test');
describe('parser', () => {
beforeEach(() => {
mkdirSync(TMP, { recursive: true });
});
afterEach(() => {
rmSync(TMP, { recursive: true, force: true });
});
it('parses a basic workflow', () => {
const path = join(TMP, 'workflow.yml');
writeFileSync(path, `
name: Test Workflow
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo hello
`);
const wf = parseWorkflow(path);
expect(wf.name).toBe('Test Workflow');
expect(wf.jobs.build['runs-on']).toBe('ubuntu-latest');
expect(wf.jobs.build.steps).toHaveLength(1);
});
it('lists all jobs', () => {
const path = join(TMP, 'workflow.yml');
writeFileSync(path, `
name: Multi Job
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo build
test:
runs-on: ubuntu-latest
steps:
- run: echo test
`);
const wf = parseWorkflow(path);
const jobs = listJobs(wf);
expect(jobs).toHaveLength(2);
expect(jobs.map(j => j.name)).toEqual(['build', 'test']);
});
it('picks specified job', () => {
const path = join(TMP, 'workflow.yml');
writeFileSync(path, `
name: Multi Job
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo build
test:
runs-on: ubuntu-latest
steps:
- run: echo test
`);
const wf = parseWorkflow(path);
const picked = pickJob(wf, 'test');
expect(picked.name).toBe('test');
});
it('picks first job when only one exists', () => {
const path = join(TMP, 'workflow.yml');
writeFileSync(path, `
name: Single Job
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo build
`);
const wf = parseWorkflow(path);
const picked = pickJob(wf);
expect(picked.name).toBe('build');
});
it('throws when multiple jobs and no name given', () => {
const path = join(TMP, 'workflow.yml');
writeFileSync(path, `
name: Multi Job
on: push
jobs:
a:
runs-on: ubuntu-latest
steps: []
b:
runs-on: ubuntu-latest
steps: []
`);
const wf = parseWorkflow(path);
expect(() => pickJob(wf)).toThrow('Use --job');
});
it('throws on missing file', () => {
expect(() => parseWorkflow(join(TMP, 'nonexistent.yml'))).toThrow();
});
it('normalizes runs-on values', () => {
expect(normalizeRunsOn('ubuntu-latest')).toBe('node:20-slim');
expect(normalizeRunsOn('ubuntu-22.04')).toBe('node:20-slim');
expect(normalizeRunsOn('custom-image')).toBe('custom-image');
});
});
//# sourceMappingURL=parser.test.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"parser.test.js","sourceRoot":"","sources":["../src/parser.test.ts"],"names":[],"mappings":"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"}
+3
View File
@@ -0,0 +1,3 @@
import type { RunContext, RunnerOptions } from './types.js';
export declare function runInteractive(ctx: RunContext, options: RunnerOptions): Promise<void>;
//# sourceMappingURL=runner.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"runner.d.ts","sourceRoot":"","sources":["../src/runner.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,UAAU,EAAc,aAAa,EAAQ,MAAM,YAAY,CAAC;AAQ9E,wBAAsB,cAAc,CAAC,GAAG,EAAE,UAAU,EAAE,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAkI3F"}
+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
+1
View File
File diff suppressed because one or more lines are too long
+56
View File
@@ -0,0 +1,56 @@
export interface Workflow {
name: string;
on: string | string[] | Record<string, unknown>;
jobs: Record<string, Job>;
env?: Record<string, string>;
}
export interface Job {
name?: string;
'runs-on': string;
steps: Step[];
env?: Record<string, string>;
needs?: string | string[];
if?: string;
}
export interface Step {
name?: string;
uses?: string;
run?: string;
with?: Record<string, string | number | boolean>;
env?: Record<string, string>;
if?: string;
workingDirectory?: string;
shell?: string;
id?: string;
}
export interface StepResult {
step: Step;
index: number;
status: 'pending' | 'running' | 'success' | 'failure' | 'skipped' | 'modified';
exitCode: number | null;
stdout: string;
stderr: string;
durationMs: number;
modifiedCommand?: string;
}
export interface RunContext {
workflow: Workflow;
jobName: string;
job: Job;
stepResults: StepResult[];
containerId: string | null;
env: Record<string, string>;
}
export interface DockerExecResult {
exitCode: number;
stdout: string;
stderr: string;
}
export interface RunnerOptions {
workflowFile: string;
jobName?: string;
dryRun?: boolean;
workspace?: string;
envFile?: string;
}
//# sourceMappingURL=types.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAChD,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IAC1B,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC9B;AAED,MAAM,WAAW,GAAG;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,IAAI,EAAE,CAAC;IACd,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC7B,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC1B,EAAE,CAAC,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,IAAI;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,CAAC;IACjD,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC7B,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,EAAE,CAAC,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,IAAI,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,SAAS,GAAG,SAAS,GAAG,SAAS,GAAG,SAAS,GAAG,SAAS,GAAG,UAAU,CAAC;IAC/E,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,UAAU;IACzB,QAAQ,EAAE,QAAQ,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,EAAE,GAAG,CAAC;IACT,WAAW,EAAE,UAAU,EAAE,CAAC;IAC1B,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC7B;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,aAAa;IAC5B,YAAY,EAAE,MAAM,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB"}
+2
View File
@@ -0,0 +1,2 @@
export {};
//# sourceMappingURL=types.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}