Add typed envconfig getters. (#34858)

* Add typed envconfig getters.

* Adjust method descriptions a bit.

* Simplify EnvConfig value type validation.

* Cleanup EnvConfig more.

* Display json parse error.

* Few Log message improvements.

* Adjust a test case.
This commit is contained in:
Aleksei Khoroshilov
2026-04-03 11:08:21 +02:00
committed by GitHub
parent ae3be4ccc2
commit 784ea81047
3 changed files with 459 additions and 412 deletions
+79 -64
View File
@@ -19,21 +19,14 @@ const braveCoreDir = path.join(rootDir, 'src', 'brave')
const envConfig = new EnvConfig(braveCoreDir)
/**
* @param {string[]} keyPath
* @param {any} defaultValue
* @returns {any}
*/
const getEnvConfig = (keyPath, defaultValue = undefined) => {
return envConfig.get(keyPath, defaultValue)
}
export class Config {
constructor() {
this.internalDepsUrl =
'https://vhemnu34de4lf5cj6bx2wwshyy0egdxk.lambda-url.us-west-2.on.aws'
this.defaultBuildConfig =
getEnvConfig(['default_build_config']) || 'Component'
this.defaultBuildConfig = envConfig.getString(
['default_build_config'],
'Component',
)
this.buildConfig = this.defaultBuildConfig
this.buildTargets = ['brave']
this.rootDir = rootDir
@@ -42,7 +35,7 @@ export class Config {
this.scriptDir = path.join(this.rootDir, 'scripts')
this.srcDir = path.join(this.rootDir, 'src')
this.chromeVersion = this.getProjectVersion('chrome')
this.chromiumRepo = getEnvConfig([
this.chromiumRepo = envConfig.requireString([
'projects',
'chrome',
'repository',
@@ -51,18 +44,20 @@ export class Config {
this.braveCoreDir = braveCoreDir
this.buildToolsDir = path.join(this.srcDir, 'build')
this.resourcesDir = path.join(this.rootDir, 'resources')
this.depotToolsDir = envConfig.getPath(['projects', 'depot_tools', 'dir'])
assert(this.depotToolsDir, 'depot_tools dir must be set')
this.depotToolsRepo = getEnvConfig([
this.depotToolsDir = envConfig.requirePath([
'projects',
'depot_tools',
'dir',
])
this.depotToolsRepo = envConfig.requireString([
'projects',
'depot_tools',
'repository',
'url',
])
assert(this.depotToolsRepo, 'depot_tools repository url must be set')
this.gclientFile = path.join(this.rootDir, '.gclient')
this.gclientVerbose = getEnvConfig(['gclient_verbose']) || false
this.disableGclientConfigUpdate = getEnvConfig(
this.gclientVerbose = envConfig.getBoolean(['gclient_verbose'], false)
this.disableGclientConfigUpdate = envConfig.getBoolean(
['disable_gclient_config_update'],
false,
)
@@ -70,31 +65,44 @@ export class Config {
'gclient',
'global_vars',
])
this.targetOS = getEnvConfig(['target_os'], this.hostOS)
this.targetArch = getEnvConfig(['target_arch']) || process.arch
this.targetEnvironment = getEnvConfig(['target_environment'])
this.targetOS = envConfig.getString(['target_os'], this.hostOS)
this.targetArch = envConfig.getString(['target_arch'], process.arch)
this.targetEnvironment = envConfig.getString(['target_environment'])
this.gypTargetArch = 'x64'
this.ignorePatchVersionNumber =
!this.isBraveReleaseBuild()
&& getEnvConfig(['ignore_patch_version_number'], !isCI)
this.useDummyLastchange = getEnvConfig(['use_dummy_lastchange'], true)
&& envConfig.getBoolean(['ignore_patch_version_number'], !isCI)
this.useDummyLastchange = envConfig.getBoolean(
['use_dummy_lastchange'],
true,
)
this.braveVersion = this.#getBraveVersion()
this.braveIOSMarketingPatchVersion =
getEnvConfig(['brave_ios_marketing_version_patch']) || ''
this.braveIOSMarketingPatchVersion = envConfig.getString(
['brave_ios_marketing_version_patch'],
'',
)
this.androidOverrideVersionName = this.braveVersion
this.releaseTag = this.braveVersion.split('+')[0]
this.mac_signing_identifier = getEnvConfig(['mac_signing_identifier'])
this.mac_installer_signing_identifier =
getEnvConfig(['mac_installer_signing_identifier']) || ''
this.mac_signing_keychain =
getEnvConfig(['mac_signing_keychain']) || 'login'
this.notary_user = getEnvConfig(['notary_user'])
this.notary_password = getEnvConfig(['notary_password'])
this.mac_signing_identifier = envConfig.getString([
'mac_signing_identifier',
])
this.mac_installer_signing_identifier = envConfig.getString(
['mac_installer_signing_identifier'],
'',
)
this.mac_signing_keychain = envConfig.getString(
['mac_signing_keychain'],
'login',
)
this.notary_user = envConfig.getString(['notary_user'])
this.notary_password = envConfig.getString(['notary_password'])
this.channel = 'development'
this.isBraveOriginBranded = getEnvConfig(['is_brave_origin_branded'])
this.isBraveOriginBranded = envConfig.getBoolean([
'is_brave_origin_branded',
])
this.gitCachePath =
envConfig.getPath(['git_cache_path']) || process.env.GIT_CACHE_PATH
this.rbeService = getEnvConfig(['rbe_service']) || ''
this.rbeService = envConfig.getString(['rbe_service'], '')
this.rbeTlsClientAuthCert = envConfig.getPath(['rbe_tls_client_auth_cert'])
this.rbeTlsClientAuthKey = envConfig.getPath(['rbe_tls_client_auth_key'])
this.realRewrapperDir =
@@ -113,51 +121,55 @@ export class Config {
'signature_generator.py',
) || ''
this.extraGnArgs = {}
this.extraGnGenOpts = getEnvConfig(['brave_extra_gn_gen_opts']) || ''
this.extraGnGenOpts = envConfig.getString(['brave_extra_gn_gen_opts'], '')
this.extraNinjaOpts = []
this.sisoJobsLimit = undefined
this.sisoCacheDir = envConfig.getPath(['siso_cache_dir'])
this.braveAndroidSafeBrowsingApiKey = getEnvConfig([
this.braveAndroidSafeBrowsingApiKey = envConfig.getString([
'brave_safebrowsing_api_key',
])
this.braveAndroidDeveloperOptionsCode = getEnvConfig([
this.braveAndroidDeveloperOptionsCode = envConfig.getString([
'brave_android_developer_options_code',
])
this.braveAndroidKeystorePath = getEnvConfig([
this.braveAndroidKeystorePath = envConfig.getString([
'brave_android_keystore_path',
])
this.braveAndroidKeystoreName = getEnvConfig([
this.braveAndroidKeystoreName = envConfig.getString([
'brave_android_keystore_name',
])
this.braveAndroidKeystorePassword = getEnvConfig([
this.braveAndroidKeystorePassword = envConfig.getString([
'brave_android_keystore_password',
])
this.braveAndroidKeyPassword = getEnvConfig(['brave_android_key_password'])
this.braveAndroidKeyPassword = envConfig.getString([
'brave_android_key_password',
])
this.braveAndroidPkcs11Provider = ''
this.braveAndroidPkcs11Alias = ''
this.nativeRedirectCCDir = path.join(this.srcDir, 'out', 'redirect_cc')
this.useRemoteExec = getEnvConfig(['use_remoteexec'], false)
this.useSiso = getEnvConfig(['use_siso'], true)
this.useReclient = getEnvConfig(
this.useRemoteExec = envConfig.getBoolean(['use_remoteexec'], false)
this.useSiso = envConfig.getBoolean(['use_siso'], true)
this.useReclient = envConfig.getBoolean(
['use_reclient'],
this.useRemoteExec && !this.useSiso,
)
this.offline = getEnvConfig(['offline'], false)
this.offline = envConfig.getBoolean(['offline'], false)
this.use_libfuzzer = false
this.androidAabToApk = false
this.useBraveHermeticToolchain = getEnvConfig(
this.useBraveHermeticToolchain = envConfig.getBoolean(
['use_brave_hermetic_toolchain'],
this.rbeService.includes('.brave.com:'),
)
this.braveIOSDeveloperOptionsCode = getEnvConfig([
this.braveIOSDeveloperOptionsCode = envConfig.getString([
'brave_ios_developer_options_code',
])
this.skip_download_rust_toolchain_aux =
getEnvConfig(['skip_download_rust_toolchain_aux']) || false
this.is_asan = getEnvConfig(['is_asan'])
this.is_msan = getEnvConfig(['is_msan'])
this.is_ubsan = getEnvConfig(['is_ubsan'])
this.use_no_gn_gen = getEnvConfig(['use_no_gn_gen'])
this.skip_download_rust_toolchain_aux = envConfig.getBoolean(
['skip_download_rust_toolchain_aux'],
false,
)
this.is_asan = envConfig.getBoolean(['is_asan'])
this.is_msan = envConfig.getBoolean(['is_msan'])
this.is_ubsan = envConfig.getBoolean(['is_ubsan'])
this.use_no_gn_gen = envConfig.getBoolean(['use_no_gn_gen'])
this.chromiumCustomDeps = envConfig.getMergedObject([
'projects',
@@ -182,7 +194,9 @@ export class Config {
}
isBraveReleaseBuild() {
const isBraveReleaseBuildValue = getEnvConfig(['is_brave_release_build'])
const isBraveReleaseBuildValue = envConfig.getNumber([
'is_brave_release_build',
])
if (isBraveReleaseBuildValue !== undefined) {
assert(
isBraveReleaseBuildValue === 0 || isBraveReleaseBuildValue === 1,
@@ -307,24 +321,24 @@ export class Config {
getProjectVersion(projectName) {
return (
getEnvConfig(['projects', projectName, 'revision'])
|| getEnvConfig(['projects', projectName, 'tag'])
|| getEnvConfig(['projects', projectName, 'branch'])
envConfig.getString(['projects', projectName, 'revision'])
|| envConfig.getString(['projects', projectName, 'tag'])
|| envConfig.getString(['projects', projectName, 'branch'])
)
}
getProjectRef(projectName, defaultValue = 'origin/master') {
const revision = getEnvConfig(['projects', projectName, 'revision'])
const revision = envConfig.getString(['projects', projectName, 'revision'])
if (revision) {
return revision
}
const tag = getEnvConfig(['projects', projectName, 'tag'])
const tag = envConfig.getString(['projects', projectName, 'tag'])
if (tag) {
return `refs/tags/${tag}`
}
let branch = getEnvConfig(['projects', projectName, 'branch'])
let branch = envConfig.getString(['projects', projectName, 'branch'])
if (branch) {
return `origin/${branch}`
}
@@ -570,7 +584,7 @@ export class Config {
forwardEnvConfigVarsToObject(vars, obj) {
for (const v of vars) {
obj[v] = getEnvConfig([v])
obj[v] = envConfig.getAny([v])
}
}
@@ -676,7 +690,7 @@ export class Config {
// low-CPU machines.
const kExecutorCount = 1200
const kRemoteLimit = Math.min(
getEnvConfig(['rbe_jobs_limit'], kExecutorCount * 1.2),
envConfig.getNumber(['rbe_jobs_limit'], kExecutorCount * 1.2),
getSisoBuiltinRemoteLimit(),
)
@@ -831,7 +845,7 @@ export class Config {
return this.#targetOS ?? this.hostOS
}
set targetOS(value) {
set targetOS(/** @type {string} */ value) {
const supportedTargetOS = ['android', 'ios', 'linux', 'mac', 'win']
if (!supportedTargetOS.includes(value)) {
Log.error(
@@ -839,7 +853,8 @@ export class Config {
)
process.exit(1)
}
this.#targetOS = value
this.#targetOS =
/** @type {'android' | 'ios' | 'linux' | 'mac' | 'win'} */ (value)
}
}
+184 -175
View File
@@ -10,6 +10,8 @@ import path from 'node:path'
import EnvConfig from './envConfig.ts'
import * as Log from './log.ts'
/* eslint jest/expect-expect: ["error", { "assertFunctionNames": ["expect*"] }] */
// Mock the logging module
jest.mock('./log.ts', () => ({
error: jest.fn(),
@@ -21,6 +23,28 @@ describe('EnvConfig', () => {
const envPath = path.join(configDir, '.env')
let mockFiles = {}
let envConfig: EnvConfig
const expectInvalidTypeError = (fn: () => unknown) => {
expect(fn).toThrow('process.exit called')
expect(Log.error).toHaveBeenCalledWith(
expect.stringContaining('invalid config value'),
)
}
const expectInvalidJsonError = (fn: () => unknown) => {
expect(fn).toThrow('process.exit called')
expect(Log.error).toHaveBeenCalledWith(
expect.stringContaining('value is not JSON-parseable'),
)
}
const expectRequiredNotFound = (fn: () => unknown) => {
expect(fn).toThrow('process.exit called')
expect(Log.error).toHaveBeenCalledWith(
expect.stringMatching(/Required config value .* is not set/),
)
}
beforeEach(() => {
jest.clearAllMocks()
@@ -81,8 +105,10 @@ describe('EnvConfig', () => {
const envConfig = new EnvConfig(configDir)
expect(envConfig.getPackageVersion()).toBe('1.2.3')
expect(envConfig.get(['projects', 'chrome', 'version'])).toBe('120.0.0')
expect(envConfig.get(['TEST', 'VALUE'])).toBe('hello')
expect(envConfig.getString(['projects', 'chrome', 'version'])).toBe(
'120.0.0',
)
expect(envConfig.getString(['TEST', 'VALUE'])).toBe('hello')
})
it('should create .env file if it does not exist', () => {
@@ -101,48 +127,115 @@ describe('EnvConfig', () => {
})
})
describe('get without .env', () => {
let envConfig
function runCommonConfigTests() {
it('should retrieve version', () => {
expect(envConfig.getPackageVersion()).toBe('1.2.3')
})
it('should retrieve config values', () => {
expect(envConfig.getBoolean(['boolean'])).toBe(true)
expect(envConfig.getNumber(['number'])).toBe(42)
expect(envConfig.getString(['string'])).toBe('hello')
expect(envConfig.getArray(['array'])).toEqual(['a', 'b', 'c'])
expect(envConfig.getObject(['object'])).toEqual({ key: 'value' })
expect(envConfig.getString(['object', 'key'])).toBe('value')
expect(envConfig.requireBoolean(['boolean'])).toBe(true)
expect(envConfig.requireNumber(['number'])).toBe(42)
expect(envConfig.requireString(['string'])).toBe('hello')
expect(envConfig.requireArray(['array'])).toEqual(['a', 'b', 'c'])
expect(envConfig.requireObject(['object'])).toEqual({ key: 'value' })
expect(envConfig.requireString(['object', 'key'])).toBe('value')
expect(envConfig.getAny(['boolean'])).toBe(true)
expect(envConfig.getAny(['number'])).toBe(42)
expect(envConfig.getAny(['string'])).toBe('hello')
expect(envConfig.getAny(['array'])).toEqual(['a', 'b', 'c'])
expect(envConfig.getAny(['object'])).toEqual({ key: 'value' })
expect(envConfig.getAny(['object', 'key'])).toBe('value')
expect(envConfig.getAny(['null_value'])).toBe(null)
})
it('non-existent config values', () => {
expect(envConfig.getString(['a'])).toBeUndefined()
expect(envConfig.getString(['a', 'b'])).toBeUndefined()
expect(envConfig.getString(['object', 'b'])).toBeUndefined()
expectRequiredNotFound(() => envConfig.requireString(['a']))
expectRequiredNotFound(() => envConfig.requireString(['a', 'b']))
expectRequiredNotFound(() => envConfig.requireString(['object', 'b']))
expect(envConfig.getAny(['a'])).toBeUndefined()
expect(envConfig.getAny(['a', 'b'])).toBeUndefined()
expect(envConfig.getAny(['object', 'b'])).toBeUndefined()
})
it('default config values', () => {
expect(envConfig.getBoolean(['a'], true)).toBe(true)
expect(envConfig.getNumber(['a'], 0)).toBe(0)
expect(envConfig.getString(['a'], 'default')).toBe('default')
expect(envConfig.getArray(['a'], ['default'])).toEqual(['default'])
expect(envConfig.getObject(['a'], { key: 'default' })).toEqual({
key: 'default',
})
})
it('should throw if type does not match', () => {
expectInvalidTypeError(() => envConfig.getBoolean(['number']))
expectInvalidTypeError(() => envConfig.getNumber(['boolean']))
expectInvalidTypeError(() => envConfig.getArray(['boolean']))
expectInvalidTypeError(() => envConfig.getObject(['boolean']))
expectInvalidTypeError(() => envConfig.requireBoolean(['number']))
expectInvalidTypeError(() => envConfig.requireNumber(['boolean']))
expectInvalidTypeError(() => envConfig.requireArray(['boolean']))
expectInvalidTypeError(() => envConfig.requireObject(['boolean']))
})
it('should return the same value for the same key', () => {
const result1 = envConfig.getObject(['object'])
const result2 = envConfig.getObject(['object'])
expect(result1).toEqual(result2)
})
}
describe('without .env', () => {
beforeEach(() => {
mockFiles[packageJsonPath] = {
version: '1.2.3',
config: {
projects: {
chrome: {
tag: '120.0.0',
},
boolean: true,
number: 42,
string: 'hello',
array: ['a', 'b', 'c'],
object: {
key: 'value',
},
null_value: null,
},
}
envConfig = new EnvConfig(configDir)
})
it('should retrieve version', () => {
const result = envConfig.getPackageVersion()
expect(result).toBe('1.2.3')
})
runCommonConfigTests()
it('should retrieve config values', () => {
const result = envConfig.get(['projects', 'chrome', 'tag'])
expect(result).toBe('120.0.0')
})
it('should return undefined for non-existent keys', () => {
const result = envConfig.get(['nonexistent', 'key'])
expect(result).toBeUndefined()
})
it('should return undefined when path traverses non-object', () => {
const result = envConfig.get(['config', 'nested'])
expect(result).toBeUndefined()
it('should throw if type does not match', () => {
expectInvalidTypeError(() => envConfig.getBoolean(['number']))
expectInvalidTypeError(() => envConfig.getNumber(['boolean']))
expectInvalidTypeError(() => envConfig.getString(['boolean']))
expectInvalidTypeError(() => envConfig.getArray(['boolean']))
expectInvalidTypeError(() => envConfig.getObject(['boolean']))
expectInvalidTypeError(() => envConfig.requireBoolean(['number']))
expectInvalidTypeError(() => envConfig.requireNumber(['boolean']))
expectInvalidTypeError(() => envConfig.requireString(['boolean']))
expectInvalidTypeError(() => envConfig.requireArray(['boolean']))
expectInvalidTypeError(() => envConfig.requireObject(['boolean']))
})
})
describe('get', () => {
let envConfig
describe('with .env', () => {
beforeEach(() => {
mockFiles[packageJsonPath] = {
version: '1.2.3',
@@ -152,104 +245,52 @@ describe('EnvConfig', () => {
}
mockFiles[envPath] = [
'version=4.5.6',
'STRING_VALUE=hello',
'NUMBER_VALUE=42',
'BOOL_VALUE=true',
'ARRAY_VALUE=["a","b","c"]',
'OBJECT_VALUE={"key":"value"}',
'EMPTY_STRING_VALUE=""',
'EMPTY_NUMBER_VALUE=0',
'EMPTY_BOOL_VALUE=false',
'EMPTY_ARRAY_VALUE=[]',
'EMPTY_OBJECT_VALUE={}',
'boolean=true',
'number=42',
'string=hello',
'array=["a","b","c"]',
'object={"key":"value"}',
'object_key=value',
'null_value=null',
'invalid_json={"a": asd}',
]
envConfig = new EnvConfig(configDir)
})
it('version should not override package.json version', () => {
const result = envConfig.getPackageVersion()
expect(result).toBe('1.2.3')
runCommonConfigTests()
it('getString/requireString should always return string values', () => {
expect(envConfig.getString(['boolean'])).toBe('true')
expect(envConfig.getString(['number'])).toBe('42')
expect(envConfig.getString(['string'])).toBe('hello')
expect(envConfig.getString(['array'])).toEqual('["a","b","c"]')
expect(envConfig.getString(['object'])).toEqual('{"key":"value"}')
expect(envConfig.getString(['object', 'key'])).toBe('value')
expect(envConfig.getString(['null_value'])).toBe('null')
expect(envConfig.getString(['invalid_json'])).toBe('{"a": asd}')
expect(envConfig.requireString(['boolean'])).toBe('true')
expect(envConfig.requireString(['number'])).toBe('42')
expect(envConfig.requireString(['string'])).toBe('hello')
expect(envConfig.requireString(['array'])).toEqual('["a","b","c"]')
expect(envConfig.requireString(['object'])).toEqual('{"key":"value"}')
expect(envConfig.requireString(['object', 'key'])).toBe('value')
expect(envConfig.requireString(['null_value'])).toBe('null')
expect(envConfig.requireString(['invalid_json'])).toBe('{"a": asd}')
})
it('should retrieve string values from .env', () => {
const result = envConfig.get(['STRING', 'VALUE'])
expect(result).toBe('hello')
})
it('should parse JSON values when default is not provided', () => {
expect(envConfig.get(['NUMBER', 'VALUE'])).toBe(42)
expect(envConfig.get(['BOOL', 'VALUE'])).toBe(true)
expect(envConfig.get(['ARRAY', 'VALUE'])).toEqual(['a', 'b', 'c'])
expect(envConfig.get(['OBJECT', 'VALUE'])).toEqual({
key: 'value',
})
expect(envConfig.get(['EMPTY', 'STRING', 'VALUE'])).toBe('')
expect(envConfig.get(['EMPTY', 'NUMBER', 'VALUE'])).toBe(0)
expect(envConfig.get(['EMPTY', 'BOOL', 'VALUE'])).toBe(false)
expect(envConfig.get(['EMPTY', 'ARRAY', 'VALUE'])).toEqual([])
expect(envConfig.get(['EMPTY', 'OBJECT', 'VALUE'])).toEqual({})
})
it('should parse typed values', () => {
expect(envConfig.get(['STRING', 'VALUE'], '')).toBe('hello')
expect(envConfig.get(['NUMBER', 'VALUE'], 0)).toBe(42)
expect(envConfig.get(['BOOL', 'VALUE'], false)).toBe(true)
expect(envConfig.get(['ARRAY', 'VALUE'], [])).toEqual(['a', 'b', 'c'])
expect(envConfig.get(['OBJECT', 'VALUE'], {})).toEqual({
key: 'value',
})
})
it('should use string value as-is when default is string', () => {
expect(envConfig.get(['NUMBER', 'VALUE'], 'default')).toBe('42')
expect(envConfig.get(['BOOL', 'VALUE'], 'default')).toBe('true')
expect(envConfig.get(['ARRAY', 'VALUE'], 'default')).toEqual(
JSON.stringify(['a', 'b', 'c']),
)
expect(envConfig.get(['OBJECT', 'VALUE'], 'default')).toEqual(
JSON.stringify({
key: 'value',
}),
)
it('should throw if invalid JSON', () => {
expectInvalidJsonError(() => envConfig.getBoolean(['invalid_json']))
expectInvalidJsonError(() => envConfig.requireBoolean(['invalid_json']))
})
it('should fall back to package config when .env value not found', () => {
const result = envConfig.get(['fallback_value'])
expect(result).toBe('from_package')
})
it('should use default value when neither .env nor package config exist', () => {
const result = envConfig.get(['nonexistent', 'key'], 'default_val')
expect(result).toBe('default_val')
})
it('should return the same value for the same key', () => {
const result1 = envConfig.get(['OBJECT', 'VALUE'])
const result2 = envConfig.get(['OBJECT', 'VALUE'])
expect(result1).toEqual(result2)
})
it('should throw error when called with different default values for same key', () => {
envConfig.get(['TEST', 'KEY'], 'default1')
expect(() => {
envConfig.get(['TEST', 'KEY'], 'default2')
}).toThrow(/was requested with a different defaultValue/)
})
it('should allow same key with same default value', () => {
const result1 = envConfig.get(['TEST', 'KEY'], 'default')
const result2 = envConfig.get(['TEST', 'KEY'], 'default')
expect(result1).toBe(result2)
expect(envConfig.getString(['fallback_value'])).toBe('from_package')
})
})
describe('getMergedObject', () => {
let envConfig
beforeEach(() => {
mockFiles[packageJsonPath] = {
version: '1.2.3',
@@ -319,45 +360,6 @@ describe('EnvConfig', () => {
})
})
describe('type validation', () => {
let envConfig
beforeEach(() => {
mockFiles[packageJsonPath] = {
version: '1.0.0',
config: {},
}
mockFiles[envPath] = ['INVALID_JSON=not json', 'WRONG_TYPE=42']
envConfig = new EnvConfig(configDir)
})
it('should throw error when JSON parsing fails with non-string default', () => {
expect(() => {
envConfig.get(['INVALID', 'JSON'], 123)
}).toThrow('process.exit called')
expect(Log.error).toHaveBeenCalledWith(
expect.stringContaining('not JSON-parseable'),
)
})
it('should throw error when type does not match default type', () => {
expect(() => {
envConfig.get(['WRONG', 'TYPE'], [])
}).toThrow('process.exit called')
expect(Log.error).toHaveBeenCalledWith(
expect.stringContaining('value type is invalid'),
)
})
it('should not throw when type matches', () => {
const result = envConfig.get(['WRONG', 'TYPE'], 0)
expect(result).toBe(42)
})
})
describe('include_env directive', () => {
it('should handle include_env directive in .env files', () => {
const includedEnvPath = path.resolve(configDir, 'included.env')
@@ -378,9 +380,9 @@ describe('EnvConfig', () => {
const envConfig = new EnvConfig(configDir)
expect(envConfig.get(['MAIN', 'VALUE'])).toBe('main')
expect(envConfig.get(['INCLUDED', 'VALUE'])).toBe('included')
expect(envConfig.get(['OVERRIDE', 'VALUE'])).toBe('from_main')
expect(envConfig.getString(['MAIN', 'VALUE'])).toBe('main')
expect(envConfig.getString(['INCLUDED', 'VALUE'])).toBe('included')
expect(envConfig.getString(['OVERRIDE', 'VALUE'])).toBe('from_main')
})
it('should handle nested include_env directives', () => {
@@ -397,9 +399,9 @@ describe('EnvConfig', () => {
const envConfig = new EnvConfig(configDir)
expect(envConfig.get(['MAIN'])).toBe(1)
expect(envConfig.get(['LEVEL1'])).toBe(2)
expect(envConfig.get(['LEVEL2'])).toBe(3)
expect(envConfig.getNumber(['MAIN'])).toBe(1)
expect(envConfig.getNumber(['LEVEL1'])).toBe(2)
expect(envConfig.getNumber(['LEVEL2'])).toBe(3)
})
it('should handle include_env with comments', () => {
@@ -417,8 +419,8 @@ describe('EnvConfig', () => {
const envConfig = new EnvConfig(configDir)
expect(envConfig.get(['VALUE'])).toBe('main')
expect(envConfig.get(['INCLUDED'])).toBe('value')
expect(envConfig.getString(['VALUE'])).toBe('main')
expect(envConfig.getString(['INCLUDED'])).toBe('value')
})
it('should throw error when included file does not exist', () => {
@@ -484,7 +486,7 @@ describe('EnvConfig', () => {
const envConfig = new EnvConfig(configDir)
expect(envConfig.get(['NESTED'])).toBe('value')
expect(envConfig.getString(['NESTED'])).toBe('value')
})
})
@@ -498,7 +500,7 @@ describe('EnvConfig', () => {
const envConfig = new EnvConfig(configDir)
const result = envConfig.get(['key'], 'default')
const result = envConfig.getString(['key'], 'default')
expect(result).toBe('default')
})
@@ -515,7 +517,7 @@ describe('EnvConfig', () => {
const envConfig = new EnvConfig(configDir)
expect(() => envConfig.get([])).toThrow('keyPath must not be empty')
expect(() => envConfig.getString([])).toThrow('keyPath must not be empty')
})
it('should handle undefined and null values correctly', () => {
@@ -527,12 +529,12 @@ describe('EnvConfig', () => {
const envConfig = new EnvConfig(configDir)
const nullResult = envConfig.get(['NULL', 'VALUE'])
expect(nullResult).toBeNull()
const nullResult = envConfig.getString(['NULL', 'VALUE'])
expect(nullResult).toBe('null')
// When no env or package config exists, return the default value
// (undefined)
const undefResult = envConfig.get(['NONEXISTENT', 'VALUE'])
const undefResult = envConfig.getString(['NONEXISTENT', 'VALUE'])
expect(undefResult).toBeUndefined()
})
@@ -546,14 +548,12 @@ describe('EnvConfig', () => {
const envConfig = new EnvConfig(configDir)
expect(envConfig.get(['TEST', 'VALUE'])).toBe('value')
expect(envConfig.get(['SECOND', 'VALUE'])).toBe('second')
expect(envConfig.getString(['TEST', 'VALUE'])).toBe('value')
expect(envConfig.getString(['SECOND', 'VALUE'])).toBe('second')
})
})
describe('getPath', () => {
let envConfig
describe('path', () => {
beforeEach(() => {
mockFiles[packageJsonPath] = {
version: '1.0.0',
@@ -574,28 +574,33 @@ describe('EnvConfig', () => {
envConfig = new EnvConfig(configDir)
})
it('should return undefined for non-existent path', () => {
const result = envConfig.getPath(['nonexistent', 'path'])
expect(result).toBeUndefined()
it('non-existent path', () => {
expect(envConfig.getPath(['a'])).toBeUndefined()
expect(envConfig.getPath(['a', 'b'])).toBeUndefined()
expectRequiredNotFound(() => envConfig.requirePath(['a']))
expectRequiredNotFound(() => envConfig.requirePath(['a', 'b']))
})
it('should return undefined for empty path value', () => {
const result = envConfig.getPath(['empty_path'])
expect(result).toBeUndefined()
expect(envConfig.getPath(['empty_path'])).toBeUndefined()
})
it('should resolve relative paths to absolute paths', () => {
const result = envConfig.getPath(['relative_path'])
assert(typeof result === 'string')
const expected = path.resolve(configDir, 'subdir/file.txt')
expect(result).toBe(expected)
expect(path.isAbsolute(result)).toBe(true)
expect(envConfig.requirePath(['relative_path'])).toBe(expected)
})
it('should resolve relative paths from .env to absolute paths', () => {
const result = envConfig.getPath(['ENV', 'RELATIVE', 'PATH'])
assert(typeof result === 'string')
const expected = path.resolve(configDir, 'relative/path.txt')
expect(result).toBe(expected)
expect(path.isAbsolute(result)).toBe(true)
expect(envConfig.requirePath(['ENV', 'RELATIVE', 'PATH'])).toBe(expected)
})
if (process.platform === 'win32') {
@@ -625,6 +630,7 @@ describe('EnvConfig', () => {
const result = envConfig.getPath(['relative_path'])
const expected = path.resolve(configDir, 'env/override.txt')
expect(result).toBe(expected)
expect(envConfig.requirePath(['relative_path'])).toBe(expected)
})
it('should expand ~ to home directory', () => {
@@ -641,6 +647,7 @@ describe('EnvConfig', () => {
expect(result).toBe(expected)
assert(typeof result === 'string')
expect(path.isAbsolute(result)).toBe(true)
expect(envConfig.requirePath(['home_path'])).toBe(expected)
})
it('should expand ~ from .env to home directory', () => {
@@ -652,6 +659,7 @@ describe('EnvConfig', () => {
expect(result).toBe(expected)
assert(typeof result === 'string')
expect(path.isAbsolute(result)).toBe(true)
expect(envConfig.requirePath(['HOME', 'PATH'])).toBe(expected)
})
it('should handle ~ as exact home directory', () => {
@@ -661,6 +669,7 @@ describe('EnvConfig', () => {
const result = envConfig.getPath(['HOME', 'DIR'])
const expectedPosix = os.homedir()
expect(result).toBe(expectedPosix)
expect(envConfig.requirePath(['HOME', 'DIR'])).toBe(expectedPosix)
})
})
})
+196 -173
View File
@@ -26,8 +26,6 @@ export default class EnvConfig {
#packageJson: Record<string, any>
// Raw variables from .env files.
#dotenvConfig: NodeJS.Dict<string>
// Stored default values for assertions on the same key requests.
#seenDefaultValues: Record<string, any>
/**
* Creates a new EnvConfig instance and loads all configuration files.
@@ -38,74 +36,123 @@ export default class EnvConfig {
this.#configDir = configDir
this.#packageJson = EnvConfig.#loadPackageJson(configDir)
this.#dotenvConfig = EnvConfig.#loadDotenvConfig(configDir)
this.#seenDefaultValues = {}
}
/**
* Retrieves a configuration value from .env files or package.json with type
* validation.
*
* The method looks up configuration in this order:
* 1. .env file values (key parts joined with '_', e.g.,
* 'projects_chrome_tag')
* 2. package.json values (using the key array as a path)
* 3. The provided defaultValue
*
* Type handling:
* - If no defaultValue is provided, attempts JSON parsing or returns string
* - If defaultValue is a string, returns .env value as-is
* - For other types, parses .env value as JSON and validates type matches
*
* @param keyPath - Array of keys forming the config path
* @param defaultValue - Default value if config not found; also determines expected type
* @returns The configuration value with appropriate type
* Returns the value if it exists and has the expected type; otherwise returns
* `defaultValue` or `undefined` if missing, and errors on type mismatch.
*/
get(keyPath: string[], defaultValue: boolean): boolean
get(keyPath: string[], defaultValue: number): number
get(keyPath: string[], defaultValue: string): string
get(keyPath: string[], defaultValue: any[]): any[]
get(keyPath: string[], defaultValue: Record<string, any>): Record<string, any>
get(keyPath: string[], defaultValue?: undefined): any
get(keyPath: string[], defaultValue?: any): any {
assert.notEqual(keyPath.length, 0, 'keyPath must not be empty')
const keyJoined = keyPath.join('_')
getBoolean(keyPath: string[]): boolean | undefined
getBoolean(keyPath: string[], defaultValue: boolean): boolean
getBoolean(keyPath: string[], defaultValue?: boolean): boolean | undefined {
return this.#getOfType(keyPath, 'Boolean') ?? defaultValue
}
this.#assertDefaultValueIsSame(keyJoined, defaultValue)
const expectedValueType = EnvConfig.#getValueType(defaultValue)
/**
* Returns a required boolean config value, errors if the value is missing.
*/
requireBoolean(keyPath: string[]): boolean {
return this.#requireValue(keyPath, this.getBoolean(keyPath))
}
const dotenvConfigValue = this.#getDotenvConfig(
keyJoined,
expectedValueType,
)
if (dotenvConfigValue !== undefined) {
return dotenvConfigValue
}
/**
* Returns the value if it exists and has the expected type; otherwise returns
* `defaultValue` or `undefined` if missing, and errors on type mismatch.
*/
getNumber(keyPath: string[]): number | undefined
getNumber(keyPath: string[], defaultValue: number): number
getNumber(keyPath: string[], defaultValue?: number): number | undefined {
return this.#getOfType(keyPath, 'Number') ?? defaultValue
}
const packageConfigValue = this.#getPackageConfig(
keyPath,
expectedValueType,
)
if (packageConfigValue !== undefined) {
return packageConfigValue
}
/**
* Returns a required number config value, errors if the value is missing.
*/
requireNumber(keyPath: string[]): number {
return this.#requireValue(keyPath, this.getNumber(keyPath))
}
return defaultValue
/**
* Returns the value if it exists and has the expected type; otherwise returns
* `defaultValue` or `undefined` if missing, and errors on type mismatch.
*/
getString(keyPath: string[]): string | undefined
getString(keyPath: string[], defaultValue: string): string
getString(keyPath: string[], defaultValue?: string): string | undefined {
return this.#getOfType(keyPath, 'String') ?? defaultValue
}
/**
* Returns a required string config value, errors if the value is missing.
*/
requireString(keyPath: string[]): string {
return this.#requireValue(keyPath, this.getString(keyPath))
}
/**
* Returns the value if it exists and has the expected type; otherwise returns
* `defaultValue` or `undefined` if missing, and errors on type mismatch.
*/
getArray(keyPath: string[]): any[] | undefined
getArray(keyPath: string[], defaultValue: any[]): any[]
getArray(keyPath: string[], defaultValue?: any[]): any[] | undefined {
return this.#getOfType(keyPath, 'Array') ?? defaultValue
}
/**
* Returns a required array config value, errors if the value is missing.
*/
requireArray(keyPath: string[]): any[] {
return this.#requireValue(keyPath, this.getArray(keyPath))
}
/**
* Returns the value if it exists and has the expected type; otherwise returns
* `defaultValue` or `undefined` if missing, and errors on type mismatch.
*/
getObject(keyPath: string[]): Record<string, any> | undefined
getObject(
keyPath: string[],
defaultValue: Record<string, any>,
): Record<string, any>
getObject(
keyPath: string[],
defaultValue?: Record<string, any>,
): Record<string, any> | undefined {
return this.#getOfType(keyPath, 'Object') ?? defaultValue
}
/**
* Returns a required object config value, errors if the value is missing.
*/
requireObject(keyPath: string[]): Record<string, any> {
return this.#requireValue(keyPath, this.getObject(keyPath))
}
/**
* Returns a config value of any type (parsed as JSON if possible, otherwise
* returned as a string) if present.
*/
getAny(keyPath: string[]): any {
return this.#getOfType(keyPath, 'Any')
}
/**
* Returns a merged object from .env files and package.json.
*
* @param keyPath - Array of keys forming the path to the config value (e.g.,
* ['projects', 'chrome', 'custom_deps'])
* @returns The merged object
*/
getMergedObject(keyPath: string[]): Record<string, any> {
assert.notEqual(keyPath.length, 0, 'keyPath must not be empty')
const keyJoined = keyPath.join('_')
const dotenvConfigValue = this.#getDotenvConfig(keyJoined, 'Object') || {}
const packageConfigValue = this.#getPackageConfig(keyPath, 'Object') || {}
const keyJoined = EnvConfig.#joinKeyPath(keyPath)
const dotenvConfigValue = EnvConfig.#convertToValueType(
this.#get(keyPath, 'dotenv') ?? {},
'Object',
keyPath,
)
const packageConfigValue = EnvConfig.#convertToValueType(
this.#get(keyPath, 'package') ?? {},
'Object',
keyPath,
)
const mergedObject = { ...packageConfigValue, ...dotenvConfigValue }
for (const [key, value] of Object.entries(this.#dotenvConfig)) {
@@ -126,12 +173,9 @@ export default class EnvConfig {
*
* Values from `include_env` configs are resolved relative to the same
* *initial config directory*, not the included file's location.
*
* @param keyPath - Array of keys forming the config path
* @returns The resolved absolute path, or undefined
*/
getPath(keyPath: string[]): string | undefined {
let pathValue = this.get(keyPath, '')
let pathValue = this.getString(keyPath)
if (!pathValue) {
return undefined
}
@@ -150,6 +194,19 @@ export default class EnvConfig {
return path.normalize(pathValue)
}
/**
* Returns a required absolute path from a configuration value, errors if the
* value is missing. Relative paths are resolved relative to the *initial
* config directory*. Paths starting with `~` are expanded to the user's home
* directory.
*
* Values from `include_env` configs are resolved relative to the same
* *initial config directory*, not the included file's location.
*/
requirePath(keyPath: string[]): string {
return this.#requireValue(keyPath, this.getPath(keyPath))
}
/**
* Returns the package version from package.json.
*
@@ -160,80 +217,86 @@ export default class EnvConfig {
}
/**
* Retrieves a value from package.json "config" value.
*
* @param keyPath - Array of keys forming the path to the config value (e.g.,
* ['projects', 'chrome', 'tag'])
* @param expectedValueType - Expected type of the value
* @returns The config value, or undefined if not found
* Returns a typed config value if present in any source.
*/
#getPackageConfig(
keyPath: string[],
expectedValueType: ConfigValueType,
): any {
const packageConfigValue = keyPath.reduce(
(obj, subkey) => obj?.[subkey],
this.#packageJson.config,
)
if (packageConfigValue === undefined) {
return undefined
#getOfType(keyPath: string[], expectedValueType: ConfigValueType): any {
const value = this.#get(keyPath)
if (value !== undefined) {
return EnvConfig.#convertToValueType(value, expectedValueType, keyPath)
}
EnvConfig.#validateValueType(
packageConfigValue,
expectedValueType,
() => `${keyPath.join('_')} (from package.json)`,
)
return packageConfigValue
}
/**
* Retrieves and parses a value from .env configuration.
*
* @param keyJoined - The joined key path (e.g., 'projects_chrome_tag')
* @param expectedValueType - Expected type of the value
* @returns The parsed configuration value, or undefined if not found
* Returns a value if present, errors if the value is missing.
*/
#getDotenvConfig(keyJoined: string, expectedValueType: ConfigValueType): any {
const dotenvConfigValue = this.#dotenvConfig[keyJoined]
if (dotenvConfigValue === undefined) {
return undefined
#requireValue<T>(keyPath: string[], value: T | undefined): T {
if (value !== undefined) {
return value
}
Log.error(
`Required config value ${EnvConfig.#joinKeyPath(keyPath)} is not set.`,
)
process.exit(1)
}
/**
* Returns a typed config value if present in any source.
*/
#get(keyPath: string[], source: 'dotenv' | 'package' | 'all' = 'all'): any {
const keyJoined = EnvConfig.#joinKeyPath(keyPath)
switch (source) {
case 'dotenv':
return this.#dotenvConfig[keyJoined]
case 'package':
return keyPath.reduce(
(obj, subkey) => obj?.[subkey],
this.#packageJson.config,
)
case 'all':
return (
this.#dotenvConfig[keyJoined]
?? keyPath.reduce(
(obj, subkey) => obj?.[subkey],
this.#packageJson.config,
)
)
default:
assert.fail(`Invalid source: ${source}.`)
}
}
/**
* Converts a value to the expected value type.
*/
static #convertToValueType(
value: any,
expectedValueType: ConfigValueType,
keyPath: string[],
): any {
if (EnvConfig.#getValueType(value) === expectedValueType) {
return value
}
// Parse as JSON or return a string if no default value is provided.
if (expectedValueType === 'Undefined') {
return EnvConfig.#parseJsonOrKeepString(dotenvConfigValue)
if (expectedValueType === 'Any') {
return EnvConfig.#parseJsonOrKeepString(value)
}
// Use the value as is if the expected value type is a string.
if (expectedValueType === 'String') {
return dotenvConfigValue
}
// Parse as JSON if the expected value type is not a string.
let dotenvConfigValueParsed: any
try {
dotenvConfigValueParsed = JSON.parse(dotenvConfigValue)
const parsedValue = JSON.parse(value)
EnvConfig.#validateValueType(parsedValue, expectedValueType, () =>
EnvConfig.#joinKeyPath(keyPath),
)
return parsedValue
} catch (e) {
Log.error(
`${keyJoined} value is not JSON-parseable: ${dotenvConfigValue}\n${e.message}`,
`${EnvConfig.#joinKeyPath(keyPath)} config value is not JSON-parseable:\n${e.message}`,
)
process.exit(1)
}
EnvConfig.#validateValueType(
dotenvConfigValueParsed,
expectedValueType,
() => `${keyJoined} (from .env)`,
)
return dotenvConfigValueParsed
}
/**
* Loads package.json file from the specified directory.
*
* @param configDir - Directory containing package.json
* @returns The parsed package.json
*/
static #loadPackageJson(configDir: string): Record<string, any> {
const packageJsonPath = path.join(configDir, 'package.json')
@@ -256,9 +319,6 @@ export default class EnvConfig {
* Loads .env configuration from the specified directory.
* Supports include_env directives for composing multiple .env files.
* Creates a placeholder .env file if none exists.
*
* @param configDir - Directory containing .env file
* @returns The parsed .env file
*/
static #loadDotenvConfig(configDir: string): NodeJS.Dict<string> {
let dotenvConfig: NodeJS.Dict<string> = {}
@@ -284,23 +344,20 @@ export default class EnvConfig {
* Included files are processed recursively, allowing nested includes.
*
* Format: include_env=path/to/file.env
*
* @param envPath - Path to the main .env file to parse
* @returns The parsed .env file
*/
static #parseEnvFileWithIncludes(envPath: string): NodeJS.Dict<string> {
const seenFiles = new Set<string>()
function readEnvFile(filePath: string, fromFile: string): string {
if (seenFiles.has(filePath)) {
Log.error(
`Circular include_env directive detected: ${filePath} from ${fromFile}`,
`Circular include_env directive detected: ${filePath} from ${fromFile}.`,
)
process.exit(1)
}
seenFiles.add(filePath)
if (!fs.existsSync(filePath)) {
Log.error(`Error loading .env (not found) from: ${filePath}`)
Log.error(`Error loading .env (not found) from: ${filePath}.`)
process.exit(1)
}
@@ -326,63 +383,27 @@ export default class EnvConfig {
return parseEnv(readEnvFile(envPath, envPath))
}
/**
* Asserts that the defaultValue is the same as the previous one.
*
* @param key - The key (e.g., 'projects_chrome_tag')
* @param defaultValue - The default value to assert
*/
#assertDefaultValueIsSame(key: string, defaultValue: any) {
if (key in this.#seenDefaultValues) {
assert.deepStrictEqual(
defaultValue,
this.#seenDefaultValues[key],
`EnvConfig for key ${key} was requested with a different defaultValue`,
)
} else {
this.#seenDefaultValues[key] = defaultValue
}
}
/**
* Validates the value against the expected value type.
*
* @param value - The value to validate
* @param expectedValueType - Expected type of the value
* @param valueDescCallback - Callback to get the
* description of the value
*/
static #validateValueType(
value: any,
expectedValueType: ConfigValueType,
valueDescCallback: () => string,
) {
if (expectedValueType === 'Undefined') {
if (expectedValueType === 'Any') {
return
}
const valueType = EnvConfig.#getValueType(value)
if (valueType !== expectedValueType) {
Log.error(
`${valueDescCallback()} value type is invalid: expected ${expectedValueType}, got ${valueType}`,
`${valueDescCallback()} invalid config value: expected ${expectedValueType}, got ${valueType}: ${value}`,
)
process.exit(1)
}
}
/**
* Returns a string representing the type of a value. Throws an error if the
* value is not a supported value type.
*
* @param value - Value to get the type of
* @returns Type name
*/
static #getValueType(value: any): ConfigValueType {
if (value === undefined) {
return 'Undefined'
}
if (value === null) {
return 'Null'
if (value === undefined || value === null) {
return 'Any'
}
const typeName = value.constructor.name
@@ -397,28 +418,30 @@ export default class EnvConfig {
return typeName
}
/**
* Parses a value as JSON or returns it as a string if it is not
* JSON-parseable.
*
* @param value - The value to parse
* @returns The parsed value or the original value if it is not JSON-parseable
*/
static #parseJsonOrKeepString(value: any): any {
if (value === undefined) {
return value
}
try {
return JSON.parse(value)
} catch (e) {
return value
}
}
static #joinKeyPath(keyPath: string[]): string {
assert.notEqual(keyPath.length, 0, 'keyPath must not be empty')
const joinedKeyPath = keyPath.join('_')
assert.notEqual(joinedKeyPath, '', 'joinedKeyPath must not be empty')
return joinedKeyPath
}
}
// Type name for supported configuration value types.
type ConfigValueType =
| 'Undefined'
| 'Null'
| 'String'
| 'Number'
| 'Boolean'
| 'Number'
| 'String'
| 'Array'
| 'Object'
| 'Any'