135 lines
3.7 KiB
TypeScript
135 lines
3.7 KiB
TypeScript
/**
|
|
* 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_NAME_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
|
|
|
|
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 {
|
|
if (!value.includes("$")) {
|
|
return value;
|
|
}
|
|
|
|
const chunks: string[] = [];
|
|
|
|
for (let i = 0; i < value.length; i += 1) {
|
|
const char = value[i];
|
|
if (char !== "$") {
|
|
chunks.push(char);
|
|
continue;
|
|
}
|
|
|
|
const next = value[i + 1];
|
|
const afterNext = value[i + 2];
|
|
|
|
// Escaped: $${VAR} -> ${VAR}
|
|
if (next === "$" && afterNext === "{") {
|
|
const start = i + 3;
|
|
const end = value.indexOf("}", start);
|
|
if (end !== -1) {
|
|
const name = value.slice(start, end);
|
|
if (ENV_VAR_NAME_PATTERN.test(name)) {
|
|
chunks.push(`\${${name}}`);
|
|
i = end;
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Substitution: ${VAR} -> value
|
|
if (next === "{") {
|
|
const start = i + 2;
|
|
const end = value.indexOf("}", start);
|
|
if (end !== -1) {
|
|
const name = value.slice(start, end);
|
|
if (ENV_VAR_NAME_PATTERN.test(name)) {
|
|
const envValue = env[name];
|
|
if (envValue === undefined || envValue === "") {
|
|
throw new MissingEnvVarError(name, configPath);
|
|
}
|
|
chunks.push(envValue);
|
|
i = end;
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Leave untouched if not a recognized pattern
|
|
chunks.push(char);
|
|
}
|
|
|
|
return chunks.join("");
|
|
}
|
|
|
|
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, "");
|
|
}
|