Skip to content

Commit 7bbb580

Browse files
committed
fix: restore .env("GCL") and strip GCL_VARIABLE_* before yargs parse
Keep yargs .env("GCL") + .strictOptions() for standard options so that validation, coercion, precedence, and unknown-option rejection all work as before. Strip GCL_VARIABLE_* entries from process.env before yargs sees them (avoiding strictOptions conflict), then inject them into argv.variable in the handler.
1 parent 4dfcf2d commit 7bbb580

3 files changed

Lines changed: 62 additions & 159 deletions

File tree

src/argv.ts

Lines changed: 14 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -22,58 +22,29 @@ async function gitRootPath () {
2222
return stdout;
2323
}
2424

25-
export function injectGclVariableEnvVars (argv: {variable?: string[]; [key: string]: any}, env: Record<string, string | undefined>): void {
25+
export function stripGclVariableEnvVars (env: Record<string, string | undefined>): Record<string, string> {
2626
const prefix = "GCL_VARIABLE_";
27-
for (const [envKey, envValue] of Object.entries(env)) {
28-
if (!envKey.startsWith(prefix) || envValue == null) continue;
27+
const stripped: Record<string, string> = {};
28+
for (const key of Object.keys(env)) {
29+
if (!key.startsWith(prefix) || env[key] == null) continue;
30+
if (key.length <= prefix.length) continue;
31+
stripped[key] = env[key]!;
32+
delete env[key];
33+
}
34+
return stripped;
35+
}
36+
37+
export function injectGclVariableEnvVars (argv: {variable?: string[]; [key: string]: any}, gclVars: Record<string, string>): void {
38+
const prefix = "GCL_VARIABLE_";
39+
for (const [envKey, envValue] of Object.entries(gclVars)) {
2940
const varName = envKey.slice(prefix.length);
30-
if (varName.length === 0) continue;
3141
if (argv.variable == null) {
3242
argv.variable = [];
3343
}
3444
argv.variable.unshift(`${varName}=${envValue}`);
3545
}
3646
}
3747

38-
interface YargsOptionsMeta {
39-
"array": string[];
40-
"boolean": string[];
41-
"number": string[];
42-
"default": Record<string, any>;
43-
"key": Record<string, boolean>;
44-
}
45-
46-
export function injectGclEnvVars (
47-
argv: Record<string, any>,
48-
yargsOptions: YargsOptionsMeta,
49-
env: Record<string, string | undefined>,
50-
defaulted: Record<string, boolean> = {},
51-
): void {
52-
const arrays = new Set(yargsOptions.array.map(String));
53-
const booleans = new Set(yargsOptions.boolean.map(String));
54-
const numbers = new Set(yargsOptions.number.map(String));
55-
56-
for (const key of Object.keys(yargsOptions.key)) {
57-
if (key.includes("-") || key === "_" || key === "$0") continue;
58-
59-
const envKey = `GCL_${key.replace(/[A-Z]/g, c => `_${c}`).toUpperCase()}`;
60-
const envValue = env[envKey];
61-
if (envValue == null) continue;
62-
63-
if (arrays.has(key)) {
64-
const cliValues = Array.isArray(argv[key]) ? argv[key] : [];
65-
argv[key] = [...envValue.split(";"), ...cliValues];
66-
continue;
67-
}
68-
69-
if (argv[key] !== undefined && !defaulted[key]) continue;
70-
71-
if (booleans.has(key)) argv[key] = envValue === "true" || envValue === "1";
72-
else if (numbers.has(key)) argv[key] = Number(envValue);
73-
else argv[key] = envValue;
74-
}
75-
}
76-
7748
export class Argv {
7849
static readonly default = {
7950
"variablesFile": ".gitlab-ci-local-variables.yml",

src/index.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import * as state from "./state.js";
66
import {WriteStreamsProcess, WriteStreamsMock} from "./write-streams.js";
77
import {handler} from "./handler.js";
88
import {Executor} from "./executor.js";
9-
import {Argv, injectGclVariableEnvVars, injectGclEnvVars} from "./argv.js";
9+
import {Argv, stripGclVariableEnvVars, injectGclVariableEnvVars} from "./argv.js";
1010
import {AssertionError} from "assert";
1111
import {Job, cleanupJobResources} from "./job.js";
1212
import {GitlabRunnerPresetValues} from "./gitlab-preset.js";
@@ -33,6 +33,7 @@ process.on("SIGUSR2", async () => {
3333
});
3434

3535
(() => {
36+
const gclVariableEnvVars = stripGclVariableEnvVars(process.env);
3637
const yparser = yargs(process.argv.slice(2));
3738
yparser.parserConfiguration({"greedy-arrays": false})
3839
.showHelpOnFail(false)
@@ -41,9 +42,7 @@ process.on("SIGUSR2", async () => {
4142
.command({
4243
handler: async (argv) => {
4344
try {
44-
const defaulted: Record<string, boolean> = (yparser as any).parsed?.defaulted ?? {};
45-
injectGclVariableEnvVars(argv, process.env);
46-
injectGclEnvVars(argv, (yparser as any).getOptions(), process.env, defaulted);
45+
injectGclVariableEnvVars(argv, gclVariableEnvVars);
4746
await handler(argv, new WriteStreamsProcess(), jobs);
4847
const failedJobs = Executor.getFailed(jobs);
4948
process.exit(failedJobs.length > 0 ? 1 : 0);
@@ -77,6 +76,7 @@ process.on("SIGUSR2", async () => {
7776
})
7877
.usage("Find more information at https://github.com/firecow/gitlab-ci-local.\nNote: To negate an option use '--no-(option)'.")
7978
.strictOptions()
79+
.env("GCL")
8080
.option("manual", {
8181
type: "array",
8282
description: "One or more manual jobs to run during a pipeline",
@@ -366,9 +366,7 @@ process.on("SIGUSR2", async () => {
366366
if (current.startsWith("-")) {
367367
completionFilter();
368368
} else {
369-
const completionDefaulted: Record<string, boolean> = (yparser as any).parsed?.defaulted ?? {};
370-
injectGclVariableEnvVars(yargsArgv, process.env);
371-
injectGclEnvVars(yargsArgv, (yparser as any).getOptions(), process.env, completionDefaulted);
369+
injectGclVariableEnvVars(yargsArgv, gclVariableEnvVars);
372370
Argv.build({...yargsArgv, autoCompleting: true})
373371
.then(argv => state.getPipelineIid(argv.cwd, argv.stateDir).then(pipelineIid => ({argv, pipelineIid})))
374372
.then(({argv, pipelineIid}) => Parser.create(argv, new WriteStreamsMock(), pipelineIid, []))

tests/test-cases/gcl-variable-env/integration.test.ts

Lines changed: 43 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,56 @@
1-
import {injectGclVariableEnvVars, injectGclEnvVars} from "../../../src/argv.js";
1+
import {stripGclVariableEnvVars, injectGclVariableEnvVars} from "../../../src/argv.js";
22
import {execFile} from "child_process";
33
import {promisify} from "util";
44

55
const execFileAsync = promisify(execFile);
66

7-
describe("injectGclVariableEnvVars unit tests", () => {
8-
test("injects single GCL_VARIABLE_ entry", () => {
7+
describe("stripGclVariableEnvVars", () => {
8+
test("strips GCL_VARIABLE_* entries and returns them", () => {
9+
const env: Record<string, string | undefined> = {
10+
"HOME": "/home/user",
11+
"GCL_CWD": "/tmp",
12+
"GCL_VARIABLE_MY_VAR": "hello",
13+
"GCL_VARIABLE_OTHER": "world",
14+
};
15+
const stripped = stripGclVariableEnvVars(env);
16+
expect(stripped).toEqual({
17+
"GCL_VARIABLE_MY_VAR": "hello",
18+
"GCL_VARIABLE_OTHER": "world",
19+
});
20+
expect(env["GCL_VARIABLE_MY_VAR"]).toBeUndefined();
21+
expect(env["GCL_VARIABLE_OTHER"]).toBeUndefined();
22+
expect(env["HOME"]).toBe("/home/user");
23+
expect(env["GCL_CWD"]).toBe("/tmp");
24+
});
25+
26+
test("skips GCL_VARIABLE_ with empty name", () => {
27+
const env: Record<string, string | undefined> = {"GCL_VARIABLE_": "empty"};
28+
const stripped = stripGclVariableEnvVars(env);
29+
expect(stripped).toEqual({});
30+
expect(env["GCL_VARIABLE_"]).toBe("empty");
31+
});
32+
33+
test("skips null/undefined values", () => {
34+
const env: Record<string, string | undefined> = {"GCL_VARIABLE_FOO": undefined};
35+
const stripped = stripGclVariableEnvVars(env);
36+
expect(stripped).toEqual({});
37+
});
38+
39+
test("returns empty object when no matches", () => {
40+
const env: Record<string, string | undefined> = {"HOME": "/home/user", "GCL_CWD": "/tmp"};
41+
const stripped = stripGclVariableEnvVars(env);
42+
expect(stripped).toEqual({});
43+
});
44+
});
45+
46+
describe("injectGclVariableEnvVars", () => {
47+
test("injects single entry", () => {
948
const argv: {variable?: string[]} = {};
1049
injectGclVariableEnvVars(argv, {"GCL_VARIABLE_MY_VAR": "hello"});
1150
expect(argv.variable).toEqual(["MY_VAR=hello"]);
1251
});
1352

14-
test("injects multiple GCL_VARIABLE_ entries", () => {
53+
test("injects multiple entries", () => {
1554
const argv: {variable?: string[]} = {};
1655
injectGclVariableEnvVars(argv, {
1756
"GCL_VARIABLE_VAR1": "one",
@@ -34,28 +73,6 @@ describe("injectGclVariableEnvVars unit tests", () => {
3473
expect(argv.variable).toEqual(["SAME=from_env", "SAME=from_cli"]);
3574
});
3675

37-
test("skips non GCL_VARIABLE_ env vars", () => {
38-
const argv: {variable?: string[]} = {};
39-
injectGclVariableEnvVars(argv, {
40-
"GCL_CWD": "/tmp",
41-
"HOME": "/home/user",
42-
"GCL_VARIABLE_REAL": "yes",
43-
});
44-
expect(argv.variable).toEqual(["REAL=yes"]);
45-
});
46-
47-
test("skips GCL_VARIABLE_ with empty name", () => {
48-
const argv: {variable?: string[]} = {};
49-
injectGclVariableEnvVars(argv, {"GCL_VARIABLE_": "empty_name"});
50-
expect(argv.variable).toBeUndefined();
51-
});
52-
53-
test("skips null/undefined env values", () => {
54-
const argv: {variable?: string[]} = {};
55-
injectGclVariableEnvVars(argv, {"GCL_VARIABLE_FOO": undefined});
56-
expect(argv.variable).toBeUndefined();
57-
});
58-
5976
test("handles empty value", () => {
6077
const argv: {variable?: string[]} = {};
6178
injectGclVariableEnvVars(argv, {"GCL_VARIABLE_FOO": ""});
@@ -75,89 +92,6 @@ describe("injectGclVariableEnvVars unit tests", () => {
7592
});
7693
});
7794

78-
describe("injectGclEnvVars unit tests", () => {
79-
const baseOptions = {
80-
array: ["volume"],
81-
boolean: ["quiet"],
82-
number: ["concurrency"],
83-
default: {quiet: false, concurrency: 0, cwd: "."},
84-
key: {quiet: true, concurrency: true, cwd: true, volume: true, _: true, $0: true, "some-kebab": true},
85-
};
86-
87-
test("injects string env var", () => {
88-
const argv: Record<string, any> = {cwd: ".", quiet: false};
89-
injectGclEnvVars(argv, baseOptions, {"GCL_CWD": "/tmp/test"}, {cwd: true, quiet: true});
90-
expect(argv.cwd).toBe("/tmp/test");
91-
});
92-
93-
test("injects boolean env var", () => {
94-
const argv: Record<string, any> = {quiet: false};
95-
injectGclEnvVars(argv, baseOptions, {"GCL_QUIET": "true"}, {quiet: true});
96-
expect(argv.quiet).toBe(true);
97-
});
98-
99-
test("injects boolean env var from '1'", () => {
100-
const argv: Record<string, any> = {quiet: false};
101-
injectGclEnvVars(argv, baseOptions, {"GCL_QUIET": "1"}, {quiet: true});
102-
expect(argv.quiet).toBe(true);
103-
});
104-
105-
test("injects number env var", () => {
106-
const argv: Record<string, any> = {concurrency: 0};
107-
injectGclEnvVars(argv, baseOptions, {"GCL_CONCURRENCY": "4"}, {concurrency: true});
108-
expect(argv.concurrency).toBe(4);
109-
});
110-
111-
test("splits array env var on semicolons", () => {
112-
const argv: Record<string, any> = {volume: []};
113-
injectGclEnvVars(argv, baseOptions, {"GCL_VOLUME": "/a:/b;/c:/d"}, {volume: true});
114-
expect(argv.volume).toEqual(["/a:/b", "/c:/d"]);
115-
});
116-
117-
test("merges array env var with CLI values", () => {
118-
const argv: Record<string, any> = {volume: ["/cli:/path"]};
119-
injectGclEnvVars(argv, baseOptions, {"GCL_VOLUME": "/env:/path"}, {});
120-
expect(argv.volume).toEqual(["/env:/path", "/cli:/path"]);
121-
});
122-
123-
test("CLI explicit value takes precedence over env", () => {
124-
const argv: Record<string, any> = {concurrency: 8};
125-
injectGclEnvVars(argv, baseOptions, {"GCL_CONCURRENCY": "4"}, {});
126-
expect(argv.concurrency).toBe(8);
127-
});
128-
129-
test("CLI explicit value takes precedence even when matching default", () => {
130-
const argv: Record<string, any> = {concurrency: 0};
131-
injectGclEnvVars(argv, baseOptions, {"GCL_CONCURRENCY": "4"}, {});
132-
expect(argv.concurrency).toBe(0);
133-
});
134-
135-
test("env overrides when value was defaulted", () => {
136-
const argv: Record<string, any> = {concurrency: 0};
137-
injectGclEnvVars(argv, baseOptions, {"GCL_CONCURRENCY": "4"}, {concurrency: true});
138-
expect(argv.concurrency).toBe(4);
139-
});
140-
141-
test("skips keys with hyphens", () => {
142-
const argv: Record<string, any> = {};
143-
injectGclEnvVars(argv, baseOptions, {"GCL_SOME_KEBAB": "val"}, {});
144-
expect(argv["some-kebab"]).toBeUndefined();
145-
});
146-
147-
test("skips _ and $0 keys", () => {
148-
const argv: Record<string, any> = {_: [], $0: "bin"};
149-
injectGclEnvVars(argv, baseOptions, {"GCL__": "x", "GCL_$0": "y"}, {});
150-
expect(argv._).toEqual([]);
151-
expect(argv.$0).toBe("bin");
152-
});
153-
154-
test("skips undefined env values", () => {
155-
const argv: Record<string, any> = {quiet: false};
156-
injectGclEnvVars(argv, baseOptions, {"GCL_QUIET": undefined}, {quiet: true});
157-
expect(argv.quiet).toBe(false);
158-
});
159-
});
160-
16195
test("GCL_VARIABLE_* env vars are injected into job output via CLI", async () => {
16296
const {stdout} = await execFileAsync("bun", ["src/index.ts", "test-job", "--cwd", "tests/test-cases/gcl-variable-env"], {
16397
env: {

0 commit comments

Comments
 (0)