#!/usr/bin/env node import chalk from "chalk-template"; import yargs from "yargs"; import camelCase from "camelcase"; import {splitSemicolonEnvVars} from "./argv.js"; import {Parser} from "./parser.js"; import * as state from "./state.js"; import {WriteStreamsProcess, WriteStreamsMock} from "./write-streams.js"; import {handler} from "./handler.js"; import {Executor} from "./executor.js"; import {Argv, stripGclVariableEnvVars, injectGclVariableEnvVars} from "./argv.js"; import {AssertionError} from "assert"; import {Job, cleanupJobResources} from "./job.js"; import {GitlabRunnerPresetValues} from "./gitlab-preset.js"; import packageJson from "../package.json"; const jobs: Job[] = []; let cleanupAndExitPromise: Promise | null = null; async function cleanupAndExit (code: number) { // First caller's exit code wins — subsequent callers join the in-flight cleanup. if (cleanupAndExitPromise) return cleanupAndExitPromise; cleanupAndExitPromise = cleanupJobResources(jobs).finally(() => process.exit(code)); return cleanupAndExitPromise; } process.on("SIGINT", () => cleanupAndExit(130)); process.on("SIGTERM", () => cleanupAndExit(143)); process.on("SIGHUP", () => cleanupAndExit(129)); // Graceful shutdown for nodemon process.on("SIGUSR2", async () => { if (!cleanupAndExitPromise) await cleanupJobResources(jobs); }); (() => { const gclVariableEnvVars = stripGclVariableEnvVars(process.env); const yparser = yargs(process.argv.slice(2)); yparser.parserConfiguration({"greedy-arrays": false}) .showHelpOnFail(false) .version(packageJson.version) .wrap(yparser.terminalWidth?.()) .command({ handler: async (argv) => { try { const arrayKeys = new Set((yparser as any).getOptions().array.map((k: string) => camelCase(k)) as string[]); splitSemicolonEnvVars(argv, arrayKeys, process.env); injectGclVariableEnvVars(argv, gclVariableEnvVars); await handler(argv, new WriteStreamsProcess(), jobs); const failedJobs = Executor.getFailed(jobs); process.exit(failedJobs.length > 0 ? 1 : 0); } catch (e: any) { if (e instanceof AssertionError) { process.stderr.write(chalk`{red ${e.message.trim()}}\n`); } else if (e instanceof AggregateError) { e.errors.forEach((aggE) => process.stderr.write(chalk`{red ${aggE.stack ?? aggE}}\n`)); } else { process.stderr.write(chalk`{red ${e.stack ?? e}}\n`); } await cleanupAndExit(1); } }, builder: (y: any) => { return y .positional("job", { describe: "Jobname's to execute", type: "string", // Type here is referring to each element of the positional args }) // by default yargs's positional options (args) can be used as options (flags) so this coerce is solely for // handling scenario when a single --job option flag is passed // Once https://github.com/yargs/yargs/issues/2196 is implemented, we can probably remove this .coerce("job", (args: string[]) => { if (!Array.isArray(args)) return [args]; return args; }); }, command: "$0 [job..]", describe: "Runs the entire pipeline or job's", }) .usage("Find more information at https://github.com/firecow/gitlab-ci-local.\nNote: To negate an option use '--no-(option)'.") .strictOptions() .env("GCL") .option("manual", { type: "array", description: "One or more manual jobs to run during a pipeline", requiresArg: true, }) .option("list", { type: "boolean", description: "List job information, when:never excluded", requiresArg: false, }) .option("list-all", { type: "boolean", description: "List job information, when:never included", requiresArg: false, }) .option("list-json", { type: "boolean", description: "List job information in json format, when:never included", requiresArg: false, }) .option("list-csv", { type: "boolean", description: "List job information in csv format, when:never excluded", requiresArg: false, }) .option("list-csv-all", { type: "boolean", description: "List job information in csv format, when:never included", requiresArg: false, }) .option("validate-dependency-chain", { type: "boolean", description: "Validate that jobs needed or dependent by active jobs under specified conditions are also active without actually running the jobs. Validates the dependency graph for the given conditions rather than one specific run, so when:manual jobs count as active and --manual is ignored. Uses fail-fast approach - stops at first validation error for both 'needs' and 'dependencies' keywords. If validation fails, use --list flag to see which jobs will run under specified conditions", requiresArg: false, }) .option("preview", { type: "boolean", description: "Print YML with defaults, includes, extends and reference's expanded", requiresArg: false, }) .option("cwd", { type: "string", description: "Path to a current working directory", requiresArg: true, }) .option("variables-file", { type: "string", description: "Path to the project file variables", requiresArg: true, default: Argv.default.variablesFile, }) .option("inputs-file", { type: "string", description: "Path to the component inputs file", requiresArg: true, default: Argv.default.inputsFile, }) .option("skip-input-validation", { type: "boolean", description: "Skip CI/CD input type, options, and regex validation", requiresArg: false, }) .option("ignores-file", { type: "string", description: "Path to an ignores file", requiresArg: true, default: Argv.default.ignoresFile, }) .option("completion", { type: "boolean", description: "Generate tab completion script", requiresArg: false, }) .option("evaluate-rule-changes", { type: "boolean", description: "Whether to evaluate rule:changes. If set to false, rules:changes will always evaluate to true", requiresArg: false, default: Argv.default.evaluateRuleChanges, }) .option("needs", { type: "boolean", description: "Run needed jobs, when executing specific jobs", requiresArg: false, }) .option("only-needs", { type: "boolean", description: "Run needed jobs, except the specified jobs themselves", requiresArg: false, }) .option("stage", { type: "string", description: "Run all jobs in a specific stage", requiresArg: false, }) .option("variable", { type: "array", description: "Add variable to all executed jobs (--variable HELLO=world)", requiresArg: false, }) .option("unset-variable", { type: "array", description: "Unsets a variable (--unset-variable HELLO)", requiresArg: false, }) .option("input", { type: "array", description: "Add input to component includes (--input KEY=value)", requiresArg: false, }) .option("remote-variables", { type: "array", description: "Fetch variables file from remote location", requiresArg: false, }) .option("state-dir", { type: "string", description: "Location of the .gitlab-ci-local state dir, relative to cwd, eg. (symfony/.gitlab-ci-local/)", requiresArg: false, }) .option("file", { type: "string", description: "Location of the .gitlab-ci.yml, relative to cwd, eg. (gitlab/.gitlab-ci.yml)", requiresArg: false, }) .option("home", { type: "string", description: "Location of the HOME .gitlab-ci-local folder ($HOME/.gitlab-ci-local/variables.yml)", requiresArg: false, }) .option("shell-isolation", { type: "boolean", description: "Enable artifact isolation for shell-executor jobs", requiresArg: false, }) .option("force-shell-executor", { type: "boolean", description: "Forces all jobs to be executed using the shell executor. (Only use this option for trusted job)", requiresArg: false, }) .option("shell-executor-no-image", { type: "boolean", description: "Whether to use shell executor when no image is specified.", requiresArg: false, }) .option("default-image", { type: "string", description: "When using --shell-executor-no-image=false which image to be used for the container. Defaults to docker.io/ruby:3.1 if not set.", requiresArg: false, }) .option("wait-image", { type: "string", description: "Which image to be used for the wait container. Defaults to docker.io/sumina46/wait-for-it:latest if not set.", requiresArg: false, }) .option("wait-for-services-timeout", { type: "number", description: "Timeout in seconds for service health checks. Defaults to 30.", requiresArg: false, }) .option("helper-image", { type: "string", description: "When using --shell-executor-no-image=false which image to be used for the utils container. Defaults to docker.io/firecow/gitlab-ci-local-util:latest if not set.", requiresArg: false, }) .option("mount-cache", { type: "boolean", description: "Enable docker mount based caching", requiresArg: false, }) .option("umask", { type: "boolean", description: "Sets docker user to 0:0", requiresArg: false, }) .option("userns", { type: "string", description: "Set docker executor userns option", requiresArg: false, }) .option("privileged", { type: "boolean", description: "Set docker executor to privileged mode", requiresArg: false, }) .option("device", { type: "array", description: "Add devices to docker executor", requiresArg: false, }) .option("ulimit", { type: "number", description: "Set docker executor ulimit", requiresArg: false, }) .option("shm-size", { type: "string", description: "Set docker executor shared memory size, docker default is 64m (e.g., '256m', '1g')", requiresArg: false, }) .option("network", { type: "array", description: "Add networks to docker executor", requiresArg: false, }) .option("volume", { type: "array", description: "Add volumes to docker executor", requiresArg: false, }) .option("extra-host", { type: "array", description: "Add extra docker host entries", requiresArg: false, }) .option("seccomp", { type: "string", description: "Set seccomp of container", requiresArg: false, }) .option("ca-file", { type: "string", description: "Path to custom CA certificate file to mount in containers", requiresArg: false, }) .option("pull-policy", { type: "string", description: "Set image pull-policy (always or if-not-present)", requiresArg: false, }) .option("fetch-includes", { type: "boolean", description: "Fetch all external includes one more time", requiresArg: false, }) .option("maximum-includes", { type: "number", description: "The maximum number of includes", requiresArg: false, }) .option("artifacts-to-source", { type: "boolean", description: "Copy the generated artifacts into cwd", requiresArg: false, }) .option("cleanup", { type: "boolean", description: "Remove docker resources after they've been used", requiresArg: false, }) .option("quiet", { type: "boolean", description: "Suppress all job output", requiresArg: false, }) .option("timestamps", { type: "boolean", description: "Show timestamps and job duration in the logs", requiresArg: false, }) .option("max-job-name-padding", { type: "number", description: "Maximum padding for job name (use <= 0 for no padding)", requiresArg: false, }) .option("json-schema-validation", { type: "boolean", description: "Whether to enable json schema validation", requiresArg: false, }) .option("ignore-schema-paths", { type: "array", requiresArg: false, default: Argv.default.ignoreSchemaPaths, description: "The json schema paths that will be ignored", }) .option("ignore-predefined-vars", { type: "string", coerce: (v) => v.split(","), requiresArg: false, describe: "Comma-seperated list of predefined pipeline variables for which warnings should be suppressed", }) .option("concurrency", { type: "number", description: "Limit the number of jobs that run simultaneously", requiresArg: false, }) .option("container-executable", { type: "string", description: "Command to start the container engine (docker or podman)", requiresArg: false, }) .option("container-mac-address", { type: "string", description: "Container MAC address (e.g., aa:bb:cc:dd:ee:ff)", requiresArg: false, }) .option("container-emulate", { type: "string", description: "The name, without the architecture, of a gitlab hosted runner to emulate. See here: https://docs.gitlab.com/ee/ci/runners/hosted_runners/linux.html#machine-types-available-for-linux---x86-64", choices: GitlabRunnerPresetValues, }) .option("gpus", { type: "string", description: "A list of GPU indexes or 'all'", requiresArg: true, }) .option("color", { requiresArg: false, default: true, description: "Enables color", }) .option("registry", { type: "boolean", requiresArg: false, description: "Start a local docker registry and configure gitlab-ci-local containers to use that by default", }) .completion("completion", false, (current: string, yargsArgv: any, completionFilter: any, done: (completions: string[]) => any) => { try { if (current.startsWith("-")) { completionFilter(); } else { injectGclVariableEnvVars(yargsArgv, gclVariableEnvVars); Argv.build({...yargsArgv, autoCompleting: true}) .then(argv => state.getPipelineIid(argv.cwd, argv.stateDir).then(pipelineIid => ({argv, pipelineIid}))) .then(({argv, pipelineIid}) => Parser.create(argv, new WriteStreamsMock(), pipelineIid, [])) .then((parser) => { const jobNames = [...parser.jobs.values()].filter((j) => j.when != "never").map((j) => j.name); done(jobNames); }) .catch(() => done(["Parser-Failed!"])); } } catch { return ["Parser-Failed!"]; } }) .parse(); })();