feat(config): add env var substitution in config values
Support ${VAR_NAME} syntax in any config string value, substituted at
config load time. Useful for referencing API keys and secrets from
environment variables without hardcoding them in the config file.
- Only uppercase env vars matched: [A-Z_][A-Z0-9_]*
- Missing/empty env vars throw MissingEnvVarError with path context
- Escape with $${VAR} to output literal ${VAR}
- Works with $include (included files also get substitution)
Closes #1009
This commit is contained in:
103
src/config/env-substitution.ts
Normal file
103
src/config/env-substitution.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Environment variable substitution for config values.
|
||||
*
|
||||
* Supports `${VAR_NAME}` syntax in string values, substituted at config load time.
|
||||
* - Only uppercase env vars are matched: `[A-Z_][A-Z0-9_]*`
|
||||
* - Escape with `$${}` to output literal `${}`
|
||||
* - Missing env vars throw `MissingEnvVarError` with context
|
||||
*
|
||||
* @example
|
||||
* ```json5
|
||||
* {
|
||||
* models: {
|
||||
* providers: {
|
||||
* "vercel-gateway": {
|
||||
* apiKey: "${VERCEL_GATEWAY_API_KEY}"
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Pattern for valid uppercase env var names: starts with letter or underscore,
|
||||
// followed by letters, numbers, or underscores (all uppercase)
|
||||
const ENV_VAR_PATTERN = /\$\{([A-Z_][A-Z0-9_]*)\}/g;
|
||||
|
||||
// Pattern for escaped env vars: $${...} -> ${...}
|
||||
const ESCAPED_PATTERN = /\$\$\{([A-Z_][A-Z0-9_]*)\}/g;
|
||||
|
||||
export class MissingEnvVarError extends Error {
|
||||
constructor(
|
||||
public readonly varName: string,
|
||||
public readonly configPath: string,
|
||||
) {
|
||||
super(`Missing env var "${varName}" referenced at config path: ${configPath}`);
|
||||
this.name = "MissingEnvVarError";
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
Object.prototype.toString.call(value) === "[object Object]"
|
||||
);
|
||||
}
|
||||
|
||||
function substituteString(value: string, env: NodeJS.ProcessEnv, configPath: string): string {
|
||||
// First pass: substitute real env vars
|
||||
const substituted = value.replace(ENV_VAR_PATTERN, (match, varName: string) => {
|
||||
// Check if this is actually an escaped var (preceded by $)
|
||||
// We handle this in the second pass, so check the original string
|
||||
const idx = value.indexOf(match);
|
||||
if (idx > 0 && value[idx - 1] === "$") {
|
||||
// This will be handled by ESCAPED_PATTERN, return as-is for now
|
||||
return match;
|
||||
}
|
||||
|
||||
const envValue = env[varName];
|
||||
if (envValue === undefined || envValue === "") {
|
||||
throw new MissingEnvVarError(varName, configPath);
|
||||
}
|
||||
return envValue;
|
||||
});
|
||||
|
||||
// Second pass: convert escaped $${VAR} to literal ${VAR}
|
||||
return substituted.replace(ESCAPED_PATTERN, (_, varName: string) => `\${${varName}}`);
|
||||
}
|
||||
|
||||
function substituteAny(value: unknown, env: NodeJS.ProcessEnv, path: string): unknown {
|
||||
if (typeof value === "string") {
|
||||
return substituteString(value, env, path);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item, index) => substituteAny(item, env, `${path}[${index}]`));
|
||||
}
|
||||
|
||||
if (isPlainObject(value)) {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
const childPath = path ? `${path}.${key}` : key;
|
||||
result[key] = substituteAny(val, env, childPath);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Primitives (number, boolean, null) pass through unchanged
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves `${VAR_NAME}` environment variable references in config values.
|
||||
*
|
||||
* @param obj - The parsed config object (after JSON5 parse and $include resolution)
|
||||
* @param env - Environment variables to use for substitution (defaults to process.env)
|
||||
* @returns The config object with env vars substituted
|
||||
* @throws {MissingEnvVarError} If a referenced env var is not set or empty
|
||||
*/
|
||||
export function resolveConfigEnvVars(obj: unknown, env: NodeJS.ProcessEnv = process.env): unknown {
|
||||
return substituteAny(obj, env, "");
|
||||
}
|
||||
Reference in New Issue
Block a user