diff --git a/.bumpy/fix-enum-env-string-coerce.md b/.bumpy/fix-enum-env-string-coerce.md new file mode 100644 index 000000000..70a9867dc --- /dev/null +++ b/.bumpy/fix-enum-env-string-coerce.md @@ -0,0 +1,5 @@ +--- +varlock: patch +--- + +Coerce string env overrides to numeric/boolean enum members diff --git a/packages/varlock/src/env-graph/lib/data-types.ts b/packages/varlock/src/env-graph/lib/data-types.ts index ecf7fb680..948289b2e 100644 --- a/packages/varlock/src/env-graph/lib/data-types.ts +++ b/packages/varlock/src/env-graph/lib/data-types.ts @@ -468,8 +468,21 @@ const EnumDataType = createEnvGraphDataType( icon: 'material-symbols-light:category', // a few shapes... not sure about this one coercedType: { enum: enumOptions }, coerce(val) { - if (_.isString(val) || _.isNumber(val) || _.isBoolean(val)) return val; - return new CoercionError('Value must be a string, number, or boolean'); + if (_.isNumber(val) || _.isBoolean(val)) return val; + if (!_.isString(val)) { + return new CoercionError('Value must be a string, number, or boolean'); + } + // Exact string member (e.g. enum(dev, prod) + "dev") + if (enumOptions.includes(val)) return val; + // process.env / overrideValues are always strings. Schema file values like + // LEVEL=2 are auto-coerced to numbers by the parser, but CI overrides stay + // as "2" / "true" and must still match numeric/boolean members. + for (const opt of enumOptions) { + if (_.isNumber(opt) && String(opt) === val) return opt; + if (opt === true && val === 'true') return true; + if (opt === false && val === 'false') return false; + } + return val; }, validate(val) { const possibleValues: Array = enumOptions || []; diff --git a/packages/varlock/src/env-graph/test/data-types.test.ts b/packages/varlock/src/env-graph/test/data-types.test.ts index 2aac08f93..58fa7dba3 100644 --- a/packages/varlock/src/env-graph/test/data-types.test.ts +++ b/packages/varlock/src/env-graph/test/data-types.test.ts @@ -10,8 +10,12 @@ import { describe, it, expect } from 'vitest'; import { outdent } from 'outdent'; import { DotEnvFileDataSource, EnvGraph, CoercionError } from '../index'; -async function loadAndResolve(envFileContent: string) { +async function loadAndResolve( + envFileContent: string, + opts?: { overrideValues?: Record }, +) { const g = new EnvGraph(); + if (opts?.overrideValues) g.overrideValues = opts.overrideValues; const testDataSource = new DotEnvFileDataSource('.env.schema', { overrideContents: outdent` # @defaultRequired=false @@ -44,6 +48,35 @@ describe('number data type - Infinity coercion', () => { }); }); +describe('enum data type - process.env string overrides', () => { + it('accepts numeric enum members from schema file values', async () => { + const g = await loadAndResolve(outdent` + # @type=enum(1, 2, 3) + LEVEL=2 + `); + expect(g.configSchema.LEVEL.isValid).toBe(true); + expect(g.configSchema.LEVEL.resolvedValue).toBe(2); + }); + + it('accepts numeric enum members from string overrides', async () => { + const g = await loadAndResolve(outdent` + # @type=enum(1, 2, 3) + LEVEL=2 + `, { overrideValues: { LEVEL: '1' } }); + expect(g.configSchema.LEVEL.isValid).toBe(true); + expect(g.configSchema.LEVEL.resolvedValue).toBe(1); + }); + + it('accepts boolean enum members from string overrides', async () => { + const g = await loadAndResolve(outdent` + # @type=enum(true, false) + FLAG=false + `, { overrideValues: { FLAG: 'true' } }); + expect(g.configSchema.FLAG.isValid).toBe(true); + expect(g.configSchema.FLAG.resolvedValue).toBe(true); + }); +}); + describe('url data type', () => { describe('prependHttps', () => { it('prepends https:// when missing', async () => {