diff --git a/.bumpy/array-value-types.md b/.bumpy/array-value-types.md new file mode 100644 index 000000000..ab37a0578 --- /dev/null +++ b/.bumpy/array-value-types.md @@ -0,0 +1,7 @@ +--- +"@env-spec/parser": minor +varlock: minor +env-spec-language: minor +--- + +Add `@type=array(...)` for list-valued config items with per-element validation, array literal values, JSON and comma-separated input, and `T[]` type generation. diff --git a/packages/env-spec-parser/grammar.peggy b/packages/env-spec-parser/grammar.peggy index 1a5b03076..8c7d3d44a 100644 --- a/packages/env-spec-parser/grammar.peggy +++ b/packages/env-spec-parser/grammar.peggy @@ -44,7 +44,7 @@ ConfigItem = ConfigItemKey = $([a-zA-Z_] [a-zA-Z0-9_.-]*) -ConfigItemValue = FunctionCall / multiLineString / quotedString / unquotedString +ConfigItemValue = FunctionCall / ArrayLiteral / multiLineString / quotedString / unquotedString CommentBlock = // not sure if we want to treat these as decorators if within comment block? diff --git a/packages/env-spec-parser/src/classes.ts b/packages/env-spec-parser/src/classes.ts index d9d91f174..1e775dd10 100644 --- a/packages/env-spec-parser/src/classes.ts +++ b/packages/env-spec-parser/src/classes.ts @@ -378,11 +378,11 @@ export class ParsedEnvSpecBlankLine { export class ParsedEnvSpecConfigItem { - value: ParsedEnvSpecStaticValue | ParsedEnvSpecFunctionCall | undefined; + value: ParsedEnvSpecStaticValue | ParsedEnvSpecFunctionCall | ParsedEnvSpecArrayLiteral | undefined; constructor(public data: { key: string; - value: ParsedEnvSpecStaticValue | ParsedEnvSpecFunctionCall | undefined; + value: ParsedEnvSpecStaticValue | ParsedEnvSpecFunctionCall | ParsedEnvSpecArrayLiteral | undefined; preComments: Array; postComment: ParsedEnvSpecDecoratorComment | ParsedEnvSpecComment | undefined; _location?: any; @@ -393,8 +393,8 @@ export class ParsedEnvSpecConfigItem { throw new Error('Nested key-value pair found in config item'); } else if (expanded instanceof ParsedEnvSpecFunctionArgs) { throw new Error('Top-level config item cannot be a bare function args'); - } else if (expanded instanceof ParsedEnvSpecObjectLiteral || expanded instanceof ParsedEnvSpecArrayLiteral) { - throw new Error('Top-level config item value cannot be a bare object or array literal'); + } else if (expanded instanceof ParsedEnvSpecObjectLiteral) { + throw new Error('Top-level config item value cannot be a bare object literal'); } this.value = expanded; } diff --git a/packages/env-spec-parser/src/simple-resolver.ts b/packages/env-spec-parser/src/simple-resolver.ts index e586a16f8..1ca943d2f 100644 --- a/packages/env-spec-parser/src/simple-resolver.ts +++ b/packages/env-spec-parser/src/simple-resolver.ts @@ -1,9 +1,11 @@ import { execSync } from 'node:child_process'; import { ParsedEnvSpecFile, ParsedEnvSpecFunctionCall, ParsedEnvSpecKeyValuePair, - ParsedEnvSpecStaticValue, + ParsedEnvSpecStaticValue, ParsedEnvSpecArrayLiteral, } from './classes.js'; +type SimpleResolvedValue = string | Array | undefined; + /** * very simple resolver meant to be used for testing * not currently exposed as part of public API @@ -18,9 +20,20 @@ export function simpleResolver( const resolved = {} as Record; function valueResolver( - valOrFn: ParsedEnvSpecStaticValue | ParsedEnvSpecFunctionCall, - ): string | undefined { + valOrFn: ParsedEnvSpecStaticValue | ParsedEnvSpecFunctionCall | ParsedEnvSpecArrayLiteral, + ): SimpleResolvedValue { if (valOrFn instanceof ParsedEnvSpecStaticValue) return valOrFn.unescapedValue; + if (valOrFn instanceof ParsedEnvSpecArrayLiteral) { + return valOrFn.values.map((v) => { + if (v instanceof ParsedEnvSpecStaticValue || v instanceof ParsedEnvSpecFunctionCall) { + return valueResolver(v); + } + if (v instanceof ParsedEnvSpecArrayLiteral) { + return valueResolver(v); + } + throw new Error('Unsupported array literal element'); + }); + } if (valOrFn instanceof ParsedEnvSpecFunctionCall) { if (valOrFn.name === 'ref') { const args = valOrFn.simplifiedArgs; @@ -38,11 +51,13 @@ export function simpleResolver( return valueResolver(i); } else if (i instanceof ParsedEnvSpecFunctionCall) { return valueResolver(i); + } else if (i instanceof ParsedEnvSpecArrayLiteral) { + return valueResolver(i); } else { throw new Error('Invalid concat args'); } }); - return resolvedArgs.join(''); + return resolvedArgs.map((v) => (v === undefined ? '' : String(v))).join(''); } else if (valOrFn.name === 'exec') { const args = valOrFn.simplifiedArgs; if (Array.isArray(args)) { @@ -71,6 +86,7 @@ export function simpleResolver( if ( !(args[0] instanceof ParsedEnvSpecStaticValue) && !(args[0] instanceof ParsedEnvSpecFunctionCall) + && !(args[0] instanceof ParsedEnvSpecArrayLiteral) ) throw new Error('Expected first arg to be a static value or function call'); const val = valueResolver(args[0]); const remainingArgs = args.slice(1); diff --git a/packages/env-spec-parser/test/values.test.ts b/packages/env-spec-parser/test/values.test.ts index 2a1b58f3a..a19598cf7 100644 --- a/packages/env-spec-parser/test/values.test.ts +++ b/packages/env-spec-parser/test/values.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import ansis from 'ansis'; import { ParsedEnvSpecFunctionCall, ParsedEnvSpecKeyValuePair, - ParsedEnvSpecStaticValue, parseEnvSpecDotEnvFile, + ParsedEnvSpecStaticValue, ParsedEnvSpecArrayLiteral, parseEnvSpecDotEnvFile, } from '../src'; import { expectInstanceOf } from './test-utils'; @@ -225,3 +225,19 @@ describe('regex-like strings and paths with slashes', () => { expect(args[0].value.value).toEqual('/^https:\\/\\//'); }); }); + +describe('array literal item values', () => { + it('parses [a, b] as an array literal', () => { + const result = parseEnvSpecDotEnvFile('VAL=[a, b]'); + const valNode = result.configItems[0].value; + expectInstanceOf(valNode, ParsedEnvSpecArrayLiteral); + expect(valNode.simplifiedValue).toEqual(['a', 'b']); + }); + + it('parses quoted email strings in array literals', () => { + const result = parseEnvSpecDotEnvFile('VAL=["a@example.com", "b@example.com"]'); + const valNode = result.configItems[0].value; + expectInstanceOf(valNode, ParsedEnvSpecArrayLiteral); + expect(valNode.simplifiedValue).toEqual(['a@example.com', 'b@example.com']); + }); +}); diff --git a/packages/varlock-website/src/content/docs/env-spec/reference.mdx b/packages/varlock-website/src/content/docs/env-spec/reference.mdx index cdf3da31a..a9617971a 100644 --- a/packages/varlock-website/src/content/docs/env-spec/reference.mdx +++ b/packages/varlock-website/src/content/docs/env-spec/reference.mdx @@ -215,7 +215,8 @@ Standalone objects and arrays use distinct brackets, keeping them unambiguous fr - `[ value, ... ]` is an **array** - they may be nested, and used as decorator values or as function-call arguments - they are parsed using the common value-handling rules, and may span multiple lines (see [Multi-line literals](#multi-line-literals) below) -- they are **not yet supported as standalone config item values** (e.g. `ITEM={...}`) — for now an item value that starts with `{`/`[` is still treated as a string +- **array literals** are supported as standalone config item values (e.g. `ITEM=[a, b]`) when paired with `@type=array(...)` +- **object literals** are **not yet supported** as standalone config item values (e.g. `ITEM={...}`) — for now an item value that starts with `{` is still treated as a string ```env-spec # @dec={ key1=v1, key2="v2", nested={ a=1 } } diff --git a/packages/varlock-website/src/content/docs/reference/data-types.mdx b/packages/varlock-website/src/content/docs/reference/data-types.mdx index 1fada918a..022067a76 100644 --- a/packages/varlock-website/src/content/docs/reference/data-types.mdx +++ b/packages/varlock-website/src/content/docs/reference/data-types.mdx @@ -254,4 +254,34 @@ POLL_INTERVAL=15m Same parser is used by `cache(..., ttl=...)` and the plugin `cacheTtl` option, so any string that works there also works here. For cache mode behavior and troubleshooting, see the [Caching guide](/guides/caching/). + +
+### `array` + +Validates a list of values, coercing and checking each element with an inner type. + +**Element type** — first positional argument: a type name (`string`, `email`, …) or a nested call for types with positional args (`enum(dev, staging, prod)`). + +**Array options:** +- `minLength` / `maxLength` — element count bounds +- `unique` (boolean) — reject duplicate elements +- `separator` (string) — split string input (e.g. `","` for comma-separated `.env` values) +- `allowEmpty` (boolean) — allow `[]` (default: false) + +Named options not listed above are forwarded to the element type (e.g. `normalize=true` on `array(email, …)`). + +```env-spec +# @type=array(email, normalize=true) +ALLOWED_EMAILS=[admin@example.com, support@example.com] + +# @type=array(enum(dev, staging, prod)) +APP_MODES=[dev, staging] + +# Comma-separated in .env +# @type=array(email, separator=",") +ALLOWED_EMAILS=one@example.com, two@example.com +``` + +Array values are available as typed arrays via `ENV` (e.g. `string[]`). In `process.env` they are JSON-serialized. +
diff --git a/packages/varlock/src/cli/commands/run.command.ts b/packages/varlock/src/cli/commands/run.command.ts index 7fe9d6ef5..6ddb79705 100644 --- a/packages/varlock/src/cli/commands/run.command.ts +++ b/packages/varlock/src/cli/commands/run.command.ts @@ -7,6 +7,7 @@ import { loadVarlockEnvGraph } from '../../lib/load-graph'; import { checkForConfigErrors, checkForNoEnvFiles, checkForSchemaErrors } from '../helpers/error-checks'; import { type TypedGunshiCommandFn } from '../helpers/gunshi-type-utils'; import { CliExitError } from '../helpers/exit-error'; +import { serializeEnvValueForProcessEnv } from '../../lib/serialize-env-value'; export const commandSpec = define({ name: 'run', @@ -203,6 +204,10 @@ export const commandFn: TypedGunshiCommandFn = async (ctx) = // (e.g. a nested `varlock run` whose own resolution needs a secret-zero token) const includeInternal = !!ctx.values['include-internal']; const resolvedEnv = envGraph.getResolvedEnvObject({ includeInternal }); + const serializedEnvForProcess: Record = {}; + for (const [key, value] of Object.entries(resolvedEnv)) { + serializedEnvForProcess[key] = serializeEnvValueForProcessEnv(value); + } const serializedGraph = envGraph.getSerializedGraph(); const { resetRedactionMap } = await import('../../runtime/env'); const { createRedactedStreamWriter } = await import('../../runtime/lib/redact-stream'); @@ -224,7 +229,7 @@ export const commandFn: TypedGunshiCommandFn = async (ctx) = const fullInjectedEnv: NodeJS.ProcessEnv = { ...process.env, - ...(injectVars ? resolvedEnv : {}), + ...(injectVars ? serializedEnvForProcess : {}), __VARLOCK_RUN: '1', // flag for a child process to detect it is running via `varlock run` ...(injectBlob ? { __VARLOCK_ENV: JSON.stringify(serializedGraph) } : {}), }; diff --git a/packages/varlock/src/env-graph/lib/config-item.ts b/packages/varlock/src/env-graph/lib/config-item.ts index 0b6417bc9..5ce1f6888 100644 --- a/packages/varlock/src/env-graph/lib/config-item.ts +++ b/packages/varlock/src/env-graph/lib/config-item.ts @@ -16,13 +16,17 @@ import { EnvGraphDataSource } from './data-source'; import { convertParsedValueToResolvers, type ResolvedValue, Resolver, StaticValueResolver, } from './resolver'; +import { + createDataTypeFromParsedType, + parseTypeDecoratorValue, +} from './type-decorator'; import { ItemDecoratorInstance } from './decorators'; export type ConfigItemDef = { description?: string; // TODO: translate parser decorator class into our own generic version parsedDecorators?: Array; - parsedValue: ParsedEnvSpecStaticValue | ParsedEnvSpecFunctionCall | undefined; + parsedValue: ParsedEnvSpecStaticValue | ParsedEnvSpecFunctionCall | ParsedEnvSpecArrayLiteral | undefined; resolver?: Resolver; decorators?: Array; @@ -321,30 +325,30 @@ export class ConfigItem { } const typeDec = this.getDec('type'); - let dataTypeName: string | undefined; - let dataTypeArgs: any; // TODO: this will not currently support any resolver functions within type settings const typeDecParsedValue = typeDec?.parsedDecorator.value; - if (typeDecParsedValue instanceof ParsedEnvSpecStaticValue) { - dataTypeName = typeDecParsedValue.value; - } else if (typeDecParsedValue instanceof ParsedEnvSpecFunctionCall) { - dataTypeName = typeDecParsedValue.name; - dataTypeArgs = typeDecParsedValue.simplifiedArgs; - } - // if no type is set explicitly, we can try to use inferred type from the resolver - // currently only static value resolver does this - but you can imagine another resolver knowing the type ahead of time - // (maybe we only want to do this if the value is set in a schema file? or if all inferred types match?) + const parsedType = parseTypeDecoratorValue(typeDecParsedValue); + let dataTypeName = parsedType?.name; if (!dataTypeName && this.valueResolver?.inferredType) { dataTypeName = this.valueResolver.inferredType; } dataTypeName ||= 'string'; - dataTypeArgs ||= []; if (!(dataTypeName in this.envGraph.dataTypesRegistry)) { this._schemaErrors.push(new SchemaError(`unknown data type: ${dataTypeName}`)); + } else if (parsedType) { + try { + this.dataType = createDataTypeFromParsedType(this.envGraph.dataTypesRegistry, parsedType); + } catch (err) { + if (err instanceof SchemaError) { + this._schemaErrors.push(err); + } else { + throw err; + } + } } else { const dataTypeFactory = this.envGraph.dataTypesRegistry[dataTypeName]; - this.dataType = dataTypeFactory(..._.isPlainObject(dataTypeArgs) ? [dataTypeArgs] : dataTypeArgs); + this.dataType = dataTypeFactory(); } } diff --git a/packages/varlock/src/env-graph/lib/data-types.ts b/packages/varlock/src/env-graph/lib/data-types.ts index d5e62d790..ee93371cc 100644 --- a/packages/varlock/src/env-graph/lib/data-types.ts +++ b/packages/varlock/src/env-graph/lib/data-types.ts @@ -6,6 +6,22 @@ import { parseDuration, convertDurationFromMs, type DurationUnit, } from '../../lib/duration'; +export const ARRAY_TYPE_OPTION_KEYS = new Set([ + 'minLength', + 'maxLength', + 'unique', + 'separator', + 'allowEmpty', +]); + +export type ArrayDataTypeSettings = { + minLength?: number; + maxLength?: number; + unique?: boolean; + separator?: string; + allowEmpty?: boolean; +}; + type MaybePromise = T | Promise; type EnvGraphDataTypeDef> = { @@ -52,6 +68,12 @@ type EnvGraphDataTypeDef; + _elementTypeName?: string; + _elementDataType?: EnvGraphDataType; + _arraySettings?: ArrayDataTypeSettings; }; @@ -601,6 +623,151 @@ const DurationDataType = createEnvGraphDataType( ); +function coerceRawValueToArray( + rawVal: unknown, + settings: ArrayDataTypeSettings, +): Array | CoercionError { + let arr: Array; + if (Array.isArray(rawVal)) { + arr = rawVal; + } else if (_.isString(rawVal)) { + const trimmed = rawVal.trim(); + if (trimmed === '' && settings.allowEmpty) { + arr = []; + } else if (trimmed.startsWith('[')) { + try { + const parsed = JSON.parse(trimmed); + if (!Array.isArray(parsed)) return new CoercionError('Value must be an array'); + arr = parsed; + } catch { + return new CoercionError('Error parsing JSON array string'); + } + } else if (settings.separator) { + arr = trimmed === '' + ? [] + : trimmed.split(settings.separator).map((part) => part.trim()).filter((part) => part !== ''); + } else { + return new CoercionError('Value must be an array'); + } + } else { + return new CoercionError('Value must be an array'); + } + + if (arr.length === 0 && !settings.allowEmpty) { + return new CoercionError('Array must not be empty'); + } + return arr; +} + +function indexedCoercionError(index: number, err: CoercionError): CoercionError { + const wrapped = new CoercionError(`[${index}]: ${err.message}`, { err }); + return wrapped; +} + +function indexedValidationError(index: number, err: ValidationError): ValidationError { + return new ValidationError(`[${index}]: ${err.message}`, { err }); +} + +// Registered so `array` appears in the type registry; real instances are built via buildArrayDataType. +const ArrayDataTypePlaceholder = createEnvGraphDataType({ + name: 'array', + icon: 'bi:brackets', + coerce() { + throw new CoercionError('array type requires an element type argument'); + }, +}); + +export function buildArrayDataType( + elementType: EnvGraphDataType, + elementTypeName: string, + arraySettingsInput: Record, +): EnvGraphDataType { + const arraySettings = arraySettingsInput as ArrayDataTypeSettings; + const elementTypeRef = elementType; + + return new EnvGraphDataType({ + name: 'array', + icon: 'bi:brackets', + typeDescription: `array of ${elementTypeName}`, + coerce(rawVal) { + const arrResult = coerceRawValueToArray(rawVal, arraySettings); + if (arrResult instanceof CoercionError) return arrResult; + + const coerced: Array = []; + for (let i = 0; i < arrResult.length; i++) { + const coerceResult = elementTypeRef.coerce(arrResult[i]); + if (coerceResult instanceof Error) { + return indexedCoercionError( + i, + coerceResult instanceof CoercionError + ? coerceResult + : new CoercionError(coerceResult.message), + ); + } + coerced.push(coerceResult); + } + return coerced; + }, + async validate(val) { + const errors: Array = []; + const arr = val as Array; + + if (arr.length === 0 && !arraySettings.allowEmpty) { + errors.push(new ValidationError('Array must not be empty')); + } + if (arraySettings.minLength !== undefined && arr.length < arraySettings.minLength) { + errors.push(new ValidationError(`Array must have at least ${arraySettings.minLength} elements`)); + } + if (arraySettings.maxLength !== undefined && arr.length > arraySettings.maxLength) { + errors.push(new ValidationError(`Array must have at most ${arraySettings.maxLength} elements`)); + } + if (arraySettings.unique) { + const seen = new Set(); + for (let i = 0; i < arr.length; i++) { + const key = JSON.stringify(arr[i]); + if (seen.has(key)) { + errors.push(new ValidationError(`[${i}]: Duplicate value`)); + } + seen.add(key); + } + } + + for (let i = 0; i < arr.length; i++) { + try { + const validateResult = await elementTypeRef.validate(arr[i]); + if (validateResult instanceof ValidationError) { + errors.push(indexedValidationError(i, validateResult)); + } else if (_.isArray(validateResult)) { + for (const nestedErr of validateResult) { + if (nestedErr instanceof ValidationError) { + errors.push(indexedValidationError(i, nestedErr)); + } + } + } + } catch (err) { + if (err instanceof ValidationError) { + errors.push(indexedValidationError(i, err)); + } else if (_.isArray(err)) { + for (const nestedErr of err) { + if (nestedErr instanceof ValidationError) { + errors.push(indexedValidationError(i, nestedErr)); + } + } + } else { + throw err; + } + } + } + + return errors.length ? errors : true; + }, + _elementTypeName: elementTypeName, + _elementDataType: elementTypeRef, + _arraySettings: arraySettings, + }, ArrayDataTypePlaceholder); +} + + export const BaseDataTypes: Array = [ StringDataType, NumberDataType, @@ -616,4 +783,5 @@ export const BaseDataTypes: Array = [ UuidDataType, Md5DataType, DurationDataType, + ArrayDataTypePlaceholder, ]; diff --git a/packages/varlock/src/env-graph/lib/decorators.ts b/packages/varlock/src/env-graph/lib/decorators.ts index 3d4794d05..05f2e37c4 100644 --- a/packages/varlock/src/env-graph/lib/decorators.ts +++ b/packages/varlock/src/env-graph/lib/decorators.ts @@ -129,6 +129,12 @@ export abstract class DecoratorInstance { ); } + // @type is resolved in ConfigItem.process() via parseTypeDecoratorValue — nested + // element specs like array(enum(a, b)) are not resolver functions. + if (this.name === 'type') { + return; + } + // this is so we can deal with @type, where each data type is not a real resolver // so instead we just make a new dummy resolver holding the args if ( @@ -171,6 +177,13 @@ export abstract class DecoratorInstance { if (this.isResolved) return this.resolvedValue; await this.process(); + + // @type is parsed in ConfigItem.process() — no value resolver to resolve + if (this.name === 'type') { + this.isResolved = true; + return this.resolvedValue; + } + if (!this.decValueResolver) { // process() already recorded schema errors, don't throw again if (this._errors.length) return; diff --git a/packages/varlock/src/env-graph/lib/type-decorator.ts b/packages/varlock/src/env-graph/lib/type-decorator.ts new file mode 100644 index 000000000..8d96a9510 --- /dev/null +++ b/packages/varlock/src/env-graph/lib/type-decorator.ts @@ -0,0 +1,120 @@ +import { + ParsedEnvSpecFunctionCall, + ParsedEnvSpecKeyValuePair, + ParsedEnvSpecStaticValue, + type ParsedEnvSpecDecorator, +} from '@env-spec/parser'; +import { + buildArrayDataType, + ARRAY_TYPE_OPTION_KEYS, + EnvGraphDataType, + type EnvGraphDataTypeFactory, +} from './data-types'; +import { SchemaError } from './errors'; + +export type ParsedTypeDecorator = { + name: string; + positional: Array; + settings: Record; +}; + +export function parseTypeDecoratorValue( + value: ParsedEnvSpecDecorator['value'], +): ParsedTypeDecorator | undefined { + if (!value) return undefined; + if (value instanceof ParsedEnvSpecStaticValue) { + return { name: value.value as string, positional: [], settings: {} }; + } + if (value instanceof ParsedEnvSpecFunctionCall) { + const positional: ParsedTypeDecorator['positional'] = []; + const settings: Record = {}; + for (const arg of value.data.args.values) { + if (arg instanceof ParsedEnvSpecKeyValuePair) { + if (arg.value instanceof ParsedEnvSpecStaticValue) { + settings[arg.key] = arg.value.value; + } + } else if ( + arg instanceof ParsedEnvSpecStaticValue + || arg instanceof ParsedEnvSpecFunctionCall + ) { + positional.push(arg); + } + } + return { name: value.name, positional, settings }; + } + return undefined; +} + +function pickArraySettings(settings: Record) { + const arraySettings: Record = {}; + const elementSettings: Record = {}; + for (const [key, val] of Object.entries(settings)) { + if (ARRAY_TYPE_OPTION_KEYS.has(key)) arraySettings[key] = val; + else elementSettings[key] = val; + } + return { arraySettings, elementSettings }; +} + +function createDataTypeFromParsedTypeImpl( + registry: Record, + parsed: ParsedTypeDecorator, +): EnvGraphDataType { + function createElementDataType( + spec: ParsedEnvSpecStaticValue | ParsedEnvSpecFunctionCall, + forwardedSettings: Record, + ): { elementType: EnvGraphDataType; elementTypeName: string } { + if (spec instanceof ParsedEnvSpecFunctionCall) { + const inner = parseTypeDecoratorValue(spec); + if (!inner) throw new SchemaError('invalid nested element type'); + const elementType = createDataTypeFromParsedTypeImpl(registry, inner); + return { elementType, elementTypeName: inner.name }; + } + const elementTypeName = spec.value as string; + if (!(elementTypeName in registry)) { + throw new SchemaError(`unknown element data type: ${elementTypeName}`); + } + const factory = registry[elementTypeName]; + const elementType = Object.keys(forwardedSettings).length + ? factory(forwardedSettings) + : factory(); + return { elementType, elementTypeName }; + } + + const { name, positional, settings } = parsed; + + if (name === 'array') { + if (!positional.length) { + throw new SchemaError('array type requires an element type as first argument'); + } + const { arraySettings, elementSettings } = pickArraySettings(settings); + const { elementType, elementTypeName } = createElementDataType( + positional[0], + elementSettings, + ); + return buildArrayDataType(elementType, elementTypeName, arraySettings); + } + + if (!(name in registry)) { + throw new SchemaError(`unknown data type: ${name}`); + } + const factory = registry[name]; + + if (name === 'enum') { + const enumValues = positional + .filter((arg) => arg instanceof ParsedEnvSpecStaticValue) + .map((arg) => (arg as ParsedEnvSpecStaticValue).value); + return factory(...enumValues); + } + + if (Object.keys(settings).length) { + return factory(settings); + } + return factory(); +} + +export function createDataTypeFromParsedType( + registry: Record, + parsed: ParsedTypeDecorator, +): EnvGraphDataType { + return createDataTypeFromParsedTypeImpl(registry, parsed); +} diff --git a/packages/varlock/src/env-graph/lib/type-generation.ts b/packages/varlock/src/env-graph/lib/type-generation.ts index 84373df73..9ed7794c6 100644 --- a/packages/varlock/src/env-graph/lib/type-generation.ts +++ b/packages/varlock/src/env-graph/lib/type-generation.ts @@ -3,6 +3,7 @@ import fs from 'node:fs'; import _ from '@env-spec/utils/my-dash'; import type { EnvGraph } from './env-graph'; import type { TypeGenItemInfo } from './config-item'; +import type { EnvGraphDataType } from './data-types'; import { isVarlockReservedKey } from './reserved-vars'; @@ -80,6 +81,18 @@ function getItemTsType(info: TypeGenItemInfo): string { const dataTypeName = dataType?.name; if (dataType) { + if (dataTypeName === 'array') { + const rawDef = dataType._rawDef as { + _elementDataType?: EnvGraphDataType; + }; + const elementType = rawDef._elementDataType; + if (elementType) { + const inner = getItemTsType({ ...info, dataType: elementType }); + if (inner.includes('|')) return `(${inner})[]`; + return `${inner}[]`; + } + return 'unknown[]'; + } if (dataTypeName === 'number' || dataTypeName === 'port') return 'number'; if (dataTypeName === 'boolean') return 'boolean'; if (dataTypeName === 'simple-object') return 'Record'; 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 ec4cb66e3..a8cf6d198 100644 --- a/packages/varlock/src/env-graph/test/data-types.test.ts +++ b/packages/varlock/src/env-graph/test/data-types.test.ts @@ -303,3 +303,239 @@ describe('duration data type', () => { }); }); }); + +describe('array data type', () => { + it('coerces array literal string values', async () => { + const g = await loadAndResolve(outdent` + # @type=array(string) + ITEM=[alpha, beta] + `); + expect(g.configSchema.ITEM.isValid).toBe(true); + expect(g.configSchema.ITEM.resolvedValue).toEqual(['alpha', 'beta']); + }); + + it('validates each element with element type', async () => { + const g = await loadAndResolve(outdent` + # @type=array(email) + ITEM=[good@example.com, not-an-email] + `); + expect(g.configSchema.ITEM.isValid).toBe(false); + expect(g.configSchema.ITEM.validationErrors?.[0]?.message).toContain('[1]'); + }); + + it('normalizes emails when element options are forwarded', async () => { + const g = await loadAndResolve(outdent` + # @type=array(email, normalize=true) + ITEM=[User@Example.com] + `); + expect(g.configSchema.ITEM.isValid).toBe(true); + expect(g.configSchema.ITEM.resolvedValue).toEqual(['user@example.com']); + }); + + it('supports nested enum element type', async () => { + const g = await loadAndResolve(outdent` + # @type=array(enum(sandbox, whitelist, production)) + ITEM=[sandbox, whitelist] + `); + expect(g.configSchema.ITEM.isValid).toBe(true); + expect(g.configSchema.ITEM.resolvedValue).toEqual(['sandbox', 'whitelist']); + }); + + it('rejects invalid enum values in array', async () => { + const g = await loadAndResolve(outdent` + # @type=array(enum(sandbox, whitelist, production)) + ITEM=[sandbox, invalid] + `); + expect(g.configSchema.ITEM.isValid).toBe(false); + }); + + it('parses comma-separated input with separator option', async () => { + const g = await loadAndResolve(outdent` + # @type=array(email, separator=",") + ITEM=one@example.com, two@example.com + `); + expect(g.configSchema.ITEM.isValid).toBe(true); + expect(g.configSchema.ITEM.resolvedValue).toEqual(['one@example.com', 'two@example.com']); + }); + + it('parses JSON array strings', async () => { + const g = await loadAndResolve(outdent` + # @type=array(string) + ITEM=["a", "b"] + `); + expect(g.configSchema.ITEM.isValid).toBe(true); + expect(g.configSchema.ITEM.resolvedValue).toEqual(['a', 'b']); + }); + + it('rejects empty arrays by default', async () => { + const g = await loadAndResolve(outdent` + # @type=array(string) + ITEM=[] + `); + expect(g.configSchema.ITEM.isValid).toBe(false); + }); + + it('allows empty arrays when allowEmpty=true', async () => { + const g = await loadAndResolve(outdent` + # @type=array(string, allowEmpty=true) + ITEM=[] + `); + expect(g.configSchema.ITEM.isValid).toBe(true); + expect(g.configSchema.ITEM.resolvedValue).toEqual([]); + }); + + it('enforces minLength and maxLength', async () => { + const g = await loadAndResolve(outdent` + # @type=array(string, minLength=2, maxLength=3) + ITEM=[a, b, c, d] + `); + expect(g.configSchema.ITEM.isValid).toBe(false); + }); + + it('enforces unique elements', async () => { + const g = await loadAndResolve(outdent` + # @type=array(string, unique=true) + ITEM=[dup, dup] + `); + expect(g.configSchema.ITEM.isValid).toBe(false); + }); + + it('validates array of emails with element options and conditional required', async () => { + const g = await loadAndResolve(outdent` + # @type=enum(sandbox, restricted, production) + APP_MODE=sandbox + # @type=array(email, normalize=true) + # @required=eq($APP_MODE, restricted) + ALLOWED_EMAILS=[admin@example.com, support@example.com] + `); + expect(g.configSchema.ALLOWED_EMAILS.isValid).toBe(true); + expect(g.configSchema.ALLOWED_EMAILS.resolvedValue).toEqual([ + 'admin@example.com', + 'support@example.com', + ]); + }); + + describe('non-string element types', () => { + it('coerces array of numbers from literals', async () => { + const g = await loadAndResolve(outdent` + # @type=array(number) + PORTS=[8080, 3000, 443] + `); + expect(g.configSchema.PORTS.isValid).toBe(true); + expect(g.configSchema.PORTS.resolvedValue).toEqual([8080, 3000, 443]); + }); + + it('coerces array of numbers from JSON array strings', async () => { + const g = await loadAndResolve(outdent` + # @type=array(number) + SCORES="[10, 20, 30]" + `); + expect(g.configSchema.SCORES.isValid).toBe(true); + expect(g.configSchema.SCORES.resolvedValue).toEqual([10, 20, 30]); + }); + + it('forwards number element options (min/max)', async () => { + const g = await loadAndResolve(outdent` + # @type=array(number, min=0, max=100) + PERCENTS=[0, 50, 100] + `); + expect(g.configSchema.PERCENTS.isValid).toBe(true); + expect(g.configSchema.PERCENTS.resolvedValue).toEqual([0, 50, 100]); + }); + + it('rejects numbers outside forwarded element bounds', async () => { + const g = await loadAndResolve(outdent` + # @type=array(number, min=0, max=100) + PERCENTS=[50, 150] + `); + expect(g.configSchema.PERCENTS.isValid).toBe(false); + expect(g.configSchema.PERCENTS.validationErrors?.[0]?.message).toContain('[1]'); + }); + + it('coerces array of booleans from literals', async () => { + const g = await loadAndResolve(outdent` + # @type=array(boolean) + FLAGS=[true, false, 1, 0] + `); + expect(g.configSchema.FLAGS.isValid).toBe(true); + expect(g.configSchema.FLAGS.resolvedValue).toEqual([true, false, true, false]); + }); + + it('coerces array of booleans from JSON array strings', async () => { + const g = await loadAndResolve(outdent` + # @type=array(boolean) + FLAGS="[true, false]" + `); + expect(g.configSchema.FLAGS.isValid).toBe(true); + expect(g.configSchema.FLAGS.resolvedValue).toEqual([true, false]); + }); + + it('validates array of ports with element constraints', async () => { + const g = await loadAndResolve(outdent` + # @type=array(port, min=1024, max=9999) + PORTS=[8080, 3000] + `); + expect(g.configSchema.PORTS.isValid).toBe(true); + expect(g.configSchema.PORTS.resolvedValue).toEqual([8080, 3000]); + }); + + it('rejects ports outside element port range', async () => { + const g = await loadAndResolve(outdent` + # @type=array(port, min=1024) + PORTS=[8080, 80] + `); + expect(g.configSchema.PORTS.isValid).toBe(false); + expect(g.configSchema.PORTS.validationErrors?.[0]?.message).toContain('[1]'); + }); + + it('validates array of UUIDs', async () => { + const g = await loadAndResolve(outdent` + # @type=array(uuid) + IDS=[ + 123e4567-e89b-12d3-a456-426614174000, + 00000000-0000-4000-8000-000000000000, + ] + `); + expect(g.configSchema.IDS.isValid).toBe(true); + expect(g.configSchema.IDS.resolvedValue).toEqual([ + '123e4567-e89b-12d3-a456-426614174000', + '00000000-0000-4000-8000-000000000000', + ]); + }); + + it('rejects invalid UUIDs with indexed errors', async () => { + const g = await loadAndResolve(outdent` + # @type=array(uuid) + IDS=[123e4567-e89b-12d3-a456-426614174000, not-a-uuid] + `); + expect(g.configSchema.IDS.isValid).toBe(false); + expect(g.configSchema.IDS.validationErrors?.[0]?.message).toContain('[1]'); + }); + + it('supports nested enum of non-string values', async () => { + const g = await loadAndResolve(outdent` + # @type=array(enum(1, 2, 3)) + LEVELS=[1, 2, 3] + `); + expect(g.configSchema.LEVELS.isValid).toBe(true); + expect(g.configSchema.LEVELS.resolvedValue).toEqual([1, 2, 3]); + }); + + it('rejects invalid entries in nested numeric enum array', async () => { + const g = await loadAndResolve(outdent` + # @type=array(enum(1, 2, 3)) + LEVELS=[1, 4] + `); + expect(g.configSchema.LEVELS.isValid).toBe(false); + }); + + it('parses comma-separated numbers with separator option', async () => { + const g = await loadAndResolve(outdent` + # @type=array(number, separator=",") + VALUES=10, 20, 30 + `); + expect(g.configSchema.VALUES.isValid).toBe(true); + expect(g.configSchema.VALUES.resolvedValue).toEqual([10, 20, 30]); + }); + }); +}); diff --git a/packages/varlock/src/env-graph/test/type-generation.test.ts b/packages/varlock/src/env-graph/test/type-generation.test.ts index 2a8dd2949..9d50dcb7e 100644 --- a/packages/varlock/src/env-graph/test/type-generation.test.ts +++ b/packages/varlock/src/env-graph/test/type-generation.test.ts @@ -266,6 +266,41 @@ describe('type generation', () => { expect(infos.APP_ENV.dataType?.name).toBe('enum'); }); + test('array type gets correct TypeGenItemInfo and TS output', async () => { + const g = await loadGraph({ + envFile: outdent` + # @defaultRequired=false + # --- + # @type=array(email, normalize=true) + ALLOWED_EMAILS=[a@example.com, b@example.com] + # @type=array(number, min=0, max=100) + SCORES=[1, 2, 3] + # @type=array(boolean) + FLAGS=[true, false] + # @type=array(enum(dev, staging, prod)) + APP_MODES=[dev, staging] + `, + }); + + const infos = await getTypeGenInfoMap(g); + expect(infos.ALLOWED_EMAILS.dataType?.name).toBe('array'); + expect(infos.SCORES.dataType?.name).toBe('array'); + expect(infos.FLAGS.dataType?.name).toBe('array'); + expect(infos.APP_MODES.dataType?.name).toBe('array'); + + const emailSrc = await generateTsTypesSrc([await g.configSchema.ALLOWED_EMAILS.getTypeGenInfo()]); + expect(emailSrc).toContain('ALLOWED_EMAILS?: string[];'); + + const numberSrc = await generateTsTypesSrc([await g.configSchema.SCORES.getTypeGenInfo()]); + expect(numberSrc).toContain('SCORES?: number[];'); + + const boolSrc = await generateTsTypesSrc([await g.configSchema.FLAGS.getTypeGenInfo()]); + expect(boolSrc).toContain('FLAGS?: boolean[];'); + + const enumSrc = await generateTsTypesSrc([await g.configSchema.APP_MODES.getTypeGenInfo()]); + expect(enumSrc).toContain('APP_MODES?: ("dev" | "staging" | "prod")[];'); + }); + test('boolean type gets correct TypeGenInfo', async () => { const g = await loadGraph({ envFile: outdent` diff --git a/packages/varlock/src/lib/serialize-env-value.test.ts b/packages/varlock/src/lib/serialize-env-value.test.ts new file mode 100644 index 000000000..10726d9b1 --- /dev/null +++ b/packages/varlock/src/lib/serialize-env-value.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import { serializeEnvValueForProcessEnv } from './serialize-env-value'; + +describe('serializeEnvValueForProcessEnv', () => { + it('serializes arrays as JSON', () => { + expect(serializeEnvValueForProcessEnv(['a@x.com', 'b@x.com'])) + .toBe('["a@x.com","b@x.com"]'); + }); + + it('serializes plain objects as JSON', () => { + expect(serializeEnvValueForProcessEnv({ key: 'value' })) + .toBe('{"key":"value"}'); + }); + + it('serializes primitives with String()', () => { + expect(serializeEnvValueForProcessEnv(42)).toBe('42'); + expect(serializeEnvValueForProcessEnv(true)).toBe('true'); + expect(serializeEnvValueForProcessEnv('hello')).toBe('hello'); + }); + + it('returns empty string for undefined', () => { + expect(serializeEnvValueForProcessEnv(undefined)).toBe(''); + }); +}); diff --git a/packages/varlock/src/lib/serialize-env-value.ts b/packages/varlock/src/lib/serialize-env-value.ts new file mode 100644 index 000000000..d393adc1d --- /dev/null +++ b/packages/varlock/src/lib/serialize-env-value.ts @@ -0,0 +1,8 @@ +import _ from '@env-spec/utils/my-dash'; + +/** Serialize a resolved config value for injection into `process.env`. */ +export function serializeEnvValueForProcessEnv(value: unknown): string { + if (value === undefined) return ''; + if (Array.isArray(value) || _.isPlainObject(value)) return JSON.stringify(value); + return String(value); +} diff --git a/packages/varlock/src/runtime/env.ts b/packages/varlock/src/runtime/env.ts index a3d297606..d7631232e 100644 --- a/packages/varlock/src/runtime/env.ts +++ b/packages/varlock/src/runtime/env.ts @@ -3,6 +3,7 @@ import { redactString } from './lib/redaction'; import type { SerializedEnvGraph } from '../env-graph'; import { isBrowser } from '../lib/detect-runtime'; import { debug } from './lib/debug'; +import { serializeEnvValueForProcessEnv } from '../lib/serialize-env-value'; // TODO: would like to move all of the redaction utils out of this file // but its complicated since it is imported by code that may be run in the backend and frontend @@ -321,7 +322,7 @@ export function initVarlockEnv(opts?: { varlockInjectedProcessEnvKeys?.push(itemKey); // when re-injecting into process.env, we treat undefined as empty string // this more closely matches expected behaviour from other .env loaders - process.env[itemKey] = itemValue === undefined ? '' : String(itemValue); + process.env[itemKey] = serializeEnvValueForProcessEnv(itemValue); } } initializedEnv = true; diff --git a/packages/vscode-plugin/src/intellisense-catalog.ts b/packages/vscode-plugin/src/intellisense-catalog.ts index 953726ba5..bfe69092e 100644 --- a/packages/vscode-plugin/src/intellisense-catalog.ts +++ b/packages/vscode-plugin/src/intellisense-catalog.ts @@ -263,6 +263,19 @@ export const DATA_TYPES: Array = [ documentation: 'Requires explicit options, for example `@type=enum(dev, preview, prod)`.', insertText: 'enum(${1:development}, ${2:preview}, ${3:production})', }, + { + name: 'array', + summary: 'Array of values with per-element validation.', + documentation: 'Example: `@type=array(email, normalize=true)` or `@type=array(enum(dev, staging, prod))`.', + insertText: 'array(${1|string,email,number,boolean,enum|})', + optionSnippets: [ + { name: 'minLength', insertText: 'minLength=${1:1}', documentation: 'Minimum number of elements.' }, + { name: 'maxLength', insertText: 'maxLength=${1:10}', documentation: 'Maximum number of elements.' }, + { name: 'unique', insertText: 'unique=true', documentation: 'Reject duplicate elements.' }, + { name: 'separator', insertText: 'separator=${1:","}', documentation: 'Split string input on this separator.' }, + { name: 'allowEmpty', insertText: 'allowEmpty=true', documentation: 'Allow an empty array.' }, + ], + }, { name: 'email', summary: 'Email address.',