feat: CISandbox v1.0.0 — Interactive CI Pipeline Debugger with Local Env Sync
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
.venv/
|
||||||
|
*.log
|
||||||
|
.verdict
|
||||||
|
coverage/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
tmp-test/
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
export {};
|
||||||
|
//# sourceMappingURL=cli.d.ts.map
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}
|
||||||
Vendored
+45
@@ -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
|
||||||
Vendored
+1
@@ -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"}
|
||||||
Vendored
+17
@@ -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
|
||||||
Vendored
+1
@@ -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"}
|
||||||
Vendored
+103
@@ -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
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"docker.js","sourceRoot":"","sources":["../src/docker.ts"],"names":[],"mappings":"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"}
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
export {};
|
||||||
|
//# sourceMappingURL=docker.test.d.ts.map
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"docker.test.d.ts","sourceRoot":"","sources":["../src/docker.test.ts"],"names":[],"mappings":""}
|
||||||
Vendored
+73
@@ -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
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"docker.test.js","sourceRoot":"","sources":["../src/docker.test.ts"],"names":[],"mappings":"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"}
|
||||||
Vendored
+5
@@ -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
|
||||||
Vendored
+1
@@ -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"}
|
||||||
Vendored
+4
@@ -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
|
||||||
Vendored
+1
@@ -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"}
|
||||||
Vendored
+12
@@ -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
|
||||||
Vendored
+1
@@ -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"}
|
||||||
Vendored
+49
@@ -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
|
||||||
Vendored
+1
@@ -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"}
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
export {};
|
||||||
|
//# sourceMappingURL=parser.test.d.ts.map
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"parser.test.d.ts","sourceRoot":"","sources":["../src/parser.test.ts"],"names":[],"mappings":""}
|
||||||
Vendored
+108
@@ -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
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"parser.test.js","sourceRoot":"","sources":["../src/parser.test.ts"],"names":[],"mappings":"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"}
|
||||||
Vendored
+3
@@ -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
|
||||||
Vendored
+1
@@ -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"}
|
||||||
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
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+56
@@ -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
|
||||||
Vendored
+1
@@ -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"}
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
export {};
|
||||||
|
//# sourceMappingURL=types.js.map
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
|
||||||
Generated
+3057
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "cisandbox",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Interactive CI Pipeline Debugger with Local Env Sync",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"type": "module",
|
||||||
|
"bin": {
|
||||||
|
"cisandbox": "./dist/cli.js"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"test": "vitest run",
|
||||||
|
"dev": "tsx src/cli.ts"
|
||||||
|
},
|
||||||
|
"keywords": ["ci", "devops", "docker", "github-actions", "debugger"],
|
||||||
|
"author": "BunBun Labs",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"chalk": "^5.4.1",
|
||||||
|
"commander": "^13.1.0",
|
||||||
|
"dockerode": "^4.0.6",
|
||||||
|
"js-yaml": "^4.1.0",
|
||||||
|
"ora": "^8.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/dockerode": "^3.3.38",
|
||||||
|
"@types/js-yaml": "^4.0.9",
|
||||||
|
"@types/node": "^22.15.49",
|
||||||
|
"@vitest/coverage-v8": "^3.2.6",
|
||||||
|
"tsx": "^4.19.4",
|
||||||
|
"typescript": "^5.8.3",
|
||||||
|
"vitest": "^3.2.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import { parseWorkflow, pickJob } from './parser.js';
|
||||||
|
import { runInteractive } from './runner.js';
|
||||||
|
import type { RunContext } from './types.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: string, options: { job?: string; workspace?: string; envFile?: string; dryRun?: boolean }) => {
|
||||||
|
try {
|
||||||
|
const workflow = parseWorkflow(workflowFile);
|
||||||
|
const { name: jobName, job } = pickJob(workflow, options.job);
|
||||||
|
|
||||||
|
const ctx: RunContext = {
|
||||||
|
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();
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
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: string) => ({
|
||||||
|
exec: vi.fn(async (opts: { Cmd: string[] }) => {
|
||||||
|
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((_: unknown, cb: (err?: Error) => void) => 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
+127
@@ -0,0 +1,127 @@
|
|||||||
|
import Docker from 'dockerode';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import type { Step, DockerExecResult } from './types.js';
|
||||||
|
|
||||||
|
export class DockerManager {
|
||||||
|
docker: Docker;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.docker = new Docker();
|
||||||
|
}
|
||||||
|
|
||||||
|
async pullImage(image: string): Promise<void> {
|
||||||
|
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: string,
|
||||||
|
options: {
|
||||||
|
workspace: string;
|
||||||
|
env: Record<string, string>;
|
||||||
|
workingDir?: string;
|
||||||
|
}
|
||||||
|
): Promise<string> {
|
||||||
|
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: string,
|
||||||
|
step: Step,
|
||||||
|
modifiedCommand?: string
|
||||||
|
): Promise<DockerExecResult> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runShell(
|
||||||
|
container: Docker.Container,
|
||||||
|
cmd: string,
|
||||||
|
workingDir: string,
|
||||||
|
shell: string,
|
||||||
|
extraEnv?: Record<string, string>
|
||||||
|
): Promise<DockerExecResult> {
|
||||||
|
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: Buffer) => {
|
||||||
|
// 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: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const container = this.docker.getContainer(containerId);
|
||||||
|
await container.stop({ t: 2 });
|
||||||
|
} catch {
|
||||||
|
// Already stopped or doesn't exist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async imageExists(image: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await this.docker.getImage(image).inspect();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
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';
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { writeFileSync, unlinkSync, 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import yaml from 'js-yaml';
|
||||||
|
import type { Workflow, Job } from './types.js';
|
||||||
|
|
||||||
|
export function parseWorkflow(filePath: string): Workflow {
|
||||||
|
const fullPath = resolve(filePath);
|
||||||
|
const content = readFileSync(fullPath, 'utf-8');
|
||||||
|
const parsed = yaml.load(content) as Workflow;
|
||||||
|
|
||||||
|
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: Workflow): { name: string; job: Job }[] {
|
||||||
|
return Object.entries(workflow.jobs).map(([name, job]) => ({ name, job }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickJob(workflow: Workflow, jobName?: string): { name: string; job: Job } {
|
||||||
|
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: string): string {
|
||||||
|
const mapping: Record<string, string> = {
|
||||||
|
'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;
|
||||||
|
}
|
||||||
+224
@@ -0,0 +1,224 @@
|
|||||||
|
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';
|
||||||
|
import type { RunContext, StepResult, RunnerOptions, Step } from './types.js';
|
||||||
|
|
||||||
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
|
||||||
|
function ask(q: string): Promise<string> {
|
||||||
|
return new Promise(resolve => rl.question(q, resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runInteractive(ctx: RunContext, options: RunnerOptions): Promise<void> {
|
||||||
|
const docker = new DockerManager();
|
||||||
|
const spinner = ora();
|
||||||
|
|
||||||
|
// Load env file if provided
|
||||||
|
let extraEnv: Record<string, string> = {};
|
||||||
|
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: DockerManager,
|
||||||
|
containerId: string,
|
||||||
|
step: Step,
|
||||||
|
index: number,
|
||||||
|
ctx: RunContext,
|
||||||
|
overrideCommand?: string
|
||||||
|
): Promise<StepResult> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { readFileSync, readdirSync, statSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
const PATTERNS = [
|
||||||
|
/eyJ[0-9a-zA-Z_-]*\./,
|
||||||
|
/sk-[0-9a-zA-Z]{20,}/,
|
||||||
|
/api[_-]?key\s*=\s*['"]?[a-zA-Z0-9]{16,}/i,
|
||||||
|
/token\s*=\s*['"]?[a-zA-Z0-9]{20,}/i,
|
||||||
|
/password\s*=\s*['"]?[^\s'"]+/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
const FILES_TO_SCAN = [
|
||||||
|
'src/cli.ts', 'src/parser.ts', 'src/docker.ts', 'src/runner.ts', 'src/index.ts', 'src/types.ts',
|
||||||
|
'package.json', 'tsconfig.json', '.gitignore'
|
||||||
|
];
|
||||||
|
|
||||||
|
let found = false;
|
||||||
|
for (const file of FILES_TO_SCAN) {
|
||||||
|
try {
|
||||||
|
const content = readFileSync(file, 'utf-8');
|
||||||
|
for (const pattern of PATTERNS) {
|
||||||
|
const match = content.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
console.log(`SECRET SCAN FAIL: ${file} matched ${pattern}`);
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (found) {
|
||||||
|
console.log('SECRETS FOUND — abort');
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
console.log('Secret scan: clean');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user