Skip to content

Commit 51f450f

Browse files
committed
feat: add config and .config path support
1 parent f5ea93a commit 51f450f

File tree

2 files changed

+189
-26
lines changed

2 files changed

+189
-26
lines changed

src/config.ts

Lines changed: 41 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ export function applyEnvVarsToConfig<T extends Record<string, any>>(
159159
* @param {ArrayMergeStrategy} [options.arrayStrategy] - The strategy to use when merging arrays.
160160
* @param {string} [options.alias] - An alternative name to check for config files.
161161
* @param {string} [options.cwd] - The current working directory.
162+
* @param {string} [options.configDir] - Additional directory to search for configuration files.
162163
* @param {T} options.defaultConfig - The default configuration.
163164
* @param {boolean} [options.verbose] - Whether to log verbose information.
164165
* @param {boolean} [options.checkEnv] - Whether to check environment variables.
@@ -174,6 +175,7 @@ export async function loadConfig<T>({
174175
name = '',
175176
alias,
176177
cwd,
178+
configDir,
177179
defaultConfig,
178180
verbose = false,
179181
checkEnv = true,
@@ -192,33 +194,46 @@ export async function loadConfig<T>({
192194
log.info(`Loading configuration for "${name}"${alias ? ` (alias: "${alias}")` : ''} from ${baseDir}`)
193195
}
194196

195-
// Build the list of config file patterns to try (including alias if provided)
196-
const configPatterns = []
197-
198-
// Primary name patterns
199-
configPatterns.push(`${name}.config`)
200-
configPatterns.push(`.${name}.config`)
201-
configPatterns.push(name)
202-
configPatterns.push(`.${name}`)
203-
204-
// Alias patterns if an alias is provided
205-
if (alias) {
206-
configPatterns.push(`${alias}.config`)
207-
configPatterns.push(`.${alias}.config`)
208-
configPatterns.push(alias)
209-
configPatterns.push(`.${alias}`)
210-
}
211-
212-
// Try loading config in order of preference (local directory first)
213-
for (const configPath of configPatterns) {
214-
for (const ext of extensions) {
215-
const fullPath = resolve(baseDir, `${configPath}${ext}`)
216-
const config = await tryLoadConfig(fullPath, configWithEnvVars, arrayStrategy)
217-
if (config !== null) {
218-
if (verbose) {
219-
log.success(`Configuration loaded from: ${configPath}${ext}`)
197+
// Base pattern sets for primary and alias
198+
const primaryBarePatterns = [name, `.${name}`].filter(Boolean)
199+
const primaryConfigSuffixPatterns = [`${name}.config`, `.${name}.config`].filter(Boolean)
200+
const aliasBarePatterns = alias ? [alias, `.${alias}`] : []
201+
const aliasConfigSuffixPatterns = alias ? [`${alias}.config`, `.${alias}.config`] : []
202+
203+
// Determine local directories to search
204+
const searchDirectories = Array.from(new Set([
205+
baseDir,
206+
resolve(baseDir, 'config'),
207+
resolve(baseDir, '.config'),
208+
configDir ? resolve(baseDir, configDir) : undefined,
209+
].filter(Boolean) as string[]))
210+
211+
// Try loading config in order of preference for each directory (local directories first)
212+
for (const dir of searchDirectories) {
213+
if (verbose)
214+
log.info(`Searching for configuration in: ${dir}`)
215+
216+
// Prefer bare names inside config directories to avoid redundant ".config" suffix
217+
const isConfigLikeDir = [resolve(baseDir, 'config'), resolve(baseDir, '.config')]
218+
.concat(configDir ? [resolve(baseDir, configDir)] : [])
219+
.includes(dir)
220+
221+
const patternsForDir = isConfigLikeDir
222+
// Primary first, then alias: prefer bare before *.config when inside config dirs
223+
? [...primaryBarePatterns, ...primaryConfigSuffixPatterns, ...aliasBarePatterns, ...aliasConfigSuffixPatterns]
224+
// Primary first, then alias: default order keeps *.config before bare
225+
: [...primaryConfigSuffixPatterns, ...primaryBarePatterns, ...aliasConfigSuffixPatterns, ...aliasBarePatterns]
226+
227+
for (const configPath of patternsForDir) {
228+
for (const ext of extensions) {
229+
const fullPath = resolve(dir, `${configPath}${ext}`)
230+
const config = await tryLoadConfig(fullPath, configWithEnvVars, arrayStrategy)
231+
if (config !== null) {
232+
if (verbose) {
233+
log.success(`Configuration loaded from: ${fullPath}`)
234+
}
235+
return config
220236
}
221-
return config
222237
}
223238
}
224239
}

test/bunfig.test.ts

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -637,6 +637,154 @@ describe('bunfig', () => {
637637
port: 443,
638638
})
639639
})
640+
641+
it('should find config inside ./config directory', async () => {
642+
const nestedDir = resolve(testConfigDir, 'config')
643+
mkdirSync(nestedDir, { recursive: true })
644+
645+
const name = 'local-in-config'
646+
const configPath = resolve(nestedDir, `${name}.config.ts`)
647+
writeFileSync(configPath, `export default { source: 'config-dir' }`)
648+
649+
const result = await loadConfig<{ source: string }>({
650+
name,
651+
cwd: testConfigDir,
652+
defaultConfig: { source: 'default' },
653+
})
654+
655+
expect(result).toEqual({ source: 'config-dir' })
656+
})
657+
658+
it('should find config inside ./.config directory', async () => {
659+
const nestedDir = resolve(testConfigDir, '.config')
660+
mkdirSync(nestedDir, { recursive: true })
661+
662+
const name = 'local-in-dot-config'
663+
const configPath = resolve(nestedDir, `${name}.config.ts`)
664+
writeFileSync(configPath, `export default { source: 'dot-config-dir' }`)
665+
666+
const result = await loadConfig<{ source: string }>({
667+
name,
668+
cwd: testConfigDir,
669+
defaultConfig: { source: 'default' },
670+
})
671+
672+
expect(result).toEqual({ source: 'dot-config-dir' })
673+
})
674+
675+
it('should prefer bare name over name.config inside config directories', async () => {
676+
const name = 'prefer-bare'
677+
const dirConfig = resolve(testConfigDir, 'config')
678+
const dirDotConfig = resolve(testConfigDir, '.config')
679+
mkdirSync(dirConfig, { recursive: true })
680+
mkdirSync(dirDotConfig, { recursive: true })
681+
682+
// In ./config: place both bare and *.config; bare should win
683+
writeFileSync(resolve(dirConfig, `${name}.ts`), `export default { where: 'config-bare' }`)
684+
writeFileSync(resolve(dirConfig, `${name}.config.ts`), `export default { where: 'config-suffixed' }`)
685+
686+
// In ./.config: place both; but ./config is searched before ./.config, so above should win regardless
687+
writeFileSync(resolve(dirDotConfig, `${name}.ts`), `export default { where: 'dot-config-bare' }`)
688+
writeFileSync(resolve(dirDotConfig, `${name}.config.ts`), `export default { where: 'dot-config-suffixed' }`)
689+
690+
const result = await loadConfig<{ where: string }>({
691+
name,
692+
cwd: testConfigDir,
693+
defaultConfig: { where: 'default' },
694+
})
695+
696+
expect(result).toEqual({ where: 'config-bare' })
697+
})
698+
699+
it('should support bare alias names inside config directories', async () => {
700+
const name = 'alias-bare'
701+
const dirConfig = resolve(testConfigDir, 'config')
702+
mkdirSync(dirConfig, { recursive: true })
703+
704+
// Only create alias files, both bare and suffixed; bare should take precedence in config dir
705+
writeFileSync(resolve(dirConfig, `tls.ts`), `export default { target: 'alias-bare' }`)
706+
writeFileSync(resolve(dirConfig, `tls.config.ts`), `export default { target: 'alias-suffixed' }`)
707+
708+
const result = await loadConfig<{ target: string }>({
709+
name,
710+
alias: 'tls',
711+
cwd: testConfigDir,
712+
defaultConfig: { target: 'default' },
713+
})
714+
715+
expect(result).toEqual({ target: 'alias-bare' })
716+
})
717+
718+
it('should respect directory precedence: base > config > .config > custom configDir', async () => {
719+
const name = 'precedence-test'
720+
721+
// Prepare directories
722+
const dirBase = testConfigDir
723+
const dirConfig = resolve(testConfigDir, 'config')
724+
const dirDotConfig = resolve(testConfigDir, '.config')
725+
const dirExtras = resolve(testConfigDir, 'extras')
726+
mkdirSync(dirConfig, { recursive: true })
727+
mkdirSync(dirDotConfig, { recursive: true })
728+
mkdirSync(dirExtras, { recursive: true })
729+
730+
// Write same-named configs in all locations with different sources
731+
writeFileSync(resolve(dirExtras, `${name}.config.ts`), `export default { source: 'extras' }`)
732+
writeFileSync(resolve(dirDotConfig, `${name}.config.ts`), `export default { source: 'dot-config' }`)
733+
writeFileSync(resolve(dirConfig, `${name}.config.ts`), `export default { source: 'config' }`)
734+
writeFileSync(resolve(dirBase, `${name}.config.ts`), `export default { source: 'base' }`)
735+
736+
const result = await loadConfig<{ source: string }>({
737+
name,
738+
cwd: testConfigDir,
739+
configDir: 'extras',
740+
defaultConfig: { source: 'default' },
741+
verbose: false,
742+
})
743+
744+
// Base directory should win
745+
expect(result).toEqual({ source: 'base' })
746+
})
747+
748+
it('should prioritize ./config over ./.config and custom configDir when base is missing', async () => {
749+
const name = 'precedence-no-base'
750+
751+
const dirConfig = resolve(testConfigDir, 'config')
752+
const dirDotConfig = resolve(testConfigDir, '.config')
753+
const dirExtras = resolve(testConfigDir, 'extras')
754+
mkdirSync(dirConfig, { recursive: true })
755+
mkdirSync(dirDotConfig, { recursive: true })
756+
mkdirSync(dirExtras, { recursive: true })
757+
758+
writeFileSync(resolve(dirExtras, `${name}.config.ts`), `export default { source: 'extras' }`)
759+
writeFileSync(resolve(dirDotConfig, `${name}.config.ts`), `export default { source: 'dot-config' }`)
760+
writeFileSync(resolve(dirConfig, `${name}.config.ts`), `export default { source: 'config' }`)
761+
762+
const result = await loadConfig<{ source: string }>({
763+
name,
764+
cwd: testConfigDir,
765+
configDir: 'extras',
766+
defaultConfig: { source: 'default' },
767+
})
768+
769+
expect(result).toEqual({ source: 'config' })
770+
})
771+
772+
it('should use a custom configDir when provided', async () => {
773+
const extrasDir = resolve(testConfigDir, 'extras')
774+
mkdirSync(extrasDir, { recursive: true })
775+
776+
const name = 'custom-config-dir'
777+
writeFileSync(resolve(extrasDir, `${name}.config.ts`), `export default { source: 'extras' }`)
778+
779+
const result = await loadConfig<{ source: string }>({
780+
name,
781+
cwd: testConfigDir,
782+
configDir: 'extras',
783+
defaultConfig: { source: 'default' },
784+
})
785+
786+
expect(result).toEqual({ source: 'extras' })
787+
})
640788
})
641789

642790
describe('config function', () => {

0 commit comments

Comments
 (0)