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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user