Skip to content

Commit a00f1d6

Browse files
authored
feat(config): allow dotenv options in framework config (#150)
* chore: pin env loader behavior * chore: add environment dotenv config * chore: make env config planning clearer * feat: allow dotenv config during env loading * chore: make config module naming clearer * chore: make env loader tests clearer * chore(agents): add file and module renames instructions * fix(config): fix module resolution after rename * chore: keep build script cross-platform
1 parent 176807d commit a00f1d6

14 files changed

+185
-89
lines changed

AGENTS.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,15 @@
7171
- Run commands on the docker container named `koalats-framework-container`.
7272
- Start the container if it's not running using the make file.
7373

74+
## File and Module Renames
75+
76+
- Treat the filesystem as case-sensitive when renaming files or directories.
77+
- Do not rely on case-only renames or mixed-case import paths.
78+
- Do not rely on module-local directory imports such as `@/feature` resolving through `feature/index.ts` during
79+
internal refactors. Prefer explicit file imports such as `@/feature/specific-file`.
80+
- After renaming files or directories, clean generated build output and rerun the full validation pipeline to catch
81+
stale artifact and path-casing issues before opening the PR.
82+
7483
## Refactorings
7584

7685
- Refactorings MUST not introduce any breaking changes to the public API or types.

src/Config/ConfigLoader.test.ts

Lines changed: 0 additions & 55 deletions
This file was deleted.

src/Config/ConfigLoader.ts

Lines changed: 0 additions & 24 deletions
This file was deleted.

src/Config/index.ts

Lines changed: 0 additions & 3 deletions
This file was deleted.

src/Testing/TestAgentFactory.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { create } from '@/application/create-application';
2-
import { type KoalaConfig } from '@/Config';
2+
import { type KoalaConfig } from '@/config/koala-config';
33
import { type HttpMiddleware, type HttpScope, type NextMiddleware } from '@/Http';
44
import { type User } from '@/Security/types';
55
import supertest from 'supertest';

src/application/create-application.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { create } from '@/application/create-application';
2-
import { koalaDefaultConfig } from '@/Config';
2+
import { koalaDefaultConfig } from '@/config/default-config';
33
import { Get, Route, RouteGroup } from '@/routing';
44
import { exclusiveRoutingModeError } from '@/routing/verify-routing-mode';
55
import { expect, test } from 'vitest';

src/application/create-application.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { type KoalaConfig } from '@/Config';
1+
import { type KoalaConfig } from '@/config/koala-config';
22
import { initializeScope } from '@/Http';
33
import { serveStaticFiles } from '@/Http/Files';
44
import { applyConfiguredGlobalMiddleware } from '@/Http/middleware/apply-configured-global-middleware';

src/config/config-loader.test.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import dotenv from 'dotenv';
2+
import dotenvExpand from 'dotenv-expand';
3+
import { beforeEach, describe, expect, test, vi } from 'vitest';
4+
import { loadEnvConfig } from '@/config/config-loader';
5+
6+
describe('load env config', () => {
7+
const configSpy = vi.spyOn(dotenv, 'config');
8+
const expandSpy = vi.spyOn(dotenvExpand, 'expand');
9+
10+
beforeEach(() => {
11+
vi.clearAllMocks();
12+
configSpy.mockReturnValue({ parsed: {} });
13+
expandSpy.mockReturnValue({ parsed: {} });
14+
});
15+
16+
describe('file selection', () => {
17+
test('loads environment files in development order', () => {
18+
loadEnvConfig('development');
19+
20+
expect(configSpy).toHaveBeenNthCalledWith(1, {
21+
path: expect.stringContaining('.env'),
22+
override: true,
23+
quiet: true,
24+
});
25+
expect(configSpy).toHaveBeenNthCalledWith(2, {
26+
path: expect.stringContaining('.env.local'),
27+
override: true,
28+
quiet: true,
29+
});
30+
expect(configSpy).toHaveBeenNthCalledWith(3, {
31+
path: expect.stringContaining('.env.development'),
32+
override: true,
33+
quiet: true,
34+
});
35+
expect(configSpy).toHaveBeenNthCalledWith(4, {
36+
path: expect.stringContaining('.env.development.local'),
37+
override: true,
38+
quiet: true,
39+
});
40+
});
41+
42+
test('skips the shared local file in the test environment', () => {
43+
loadEnvConfig('test');
44+
45+
expect(configSpy).toHaveBeenCalledTimes(3);
46+
47+
expect(configSpy).toHaveBeenNthCalledWith(1, {
48+
path: expect.stringContaining('.env'),
49+
override: true,
50+
quiet: true,
51+
});
52+
expect(configSpy).toHaveBeenNthCalledWith(2, {
53+
path: expect.stringContaining('.env.test'),
54+
override: true,
55+
quiet: true,
56+
});
57+
expect(configSpy).toHaveBeenNthCalledWith(3, {
58+
path: expect.stringContaining('.env.test.local'),
59+
override: true,
60+
quiet: true,
61+
});
62+
});
63+
});
64+
65+
describe('dotenv options', () => {
66+
test('uses the provided dotenv options for every file', () => {
67+
loadEnvConfig('development', {
68+
debug: true,
69+
encoding: 'latin1',
70+
override: false,
71+
quiet: false,
72+
});
73+
74+
expect(configSpy).toHaveBeenCalledTimes(4);
75+
76+
expect(configSpy).toHaveBeenNthCalledWith(1, {
77+
debug: true,
78+
encoding: 'latin1',
79+
override: false,
80+
path: expect.stringContaining('.env'),
81+
quiet: false,
82+
});
83+
expect(configSpy).toHaveBeenNthCalledWith(2, {
84+
debug: true,
85+
encoding: 'latin1',
86+
override: false,
87+
path: expect.stringContaining('.env.local'),
88+
quiet: false,
89+
});
90+
expect(configSpy).toHaveBeenNthCalledWith(3, {
91+
debug: true,
92+
encoding: 'latin1',
93+
override: false,
94+
path: expect.stringContaining('.env.development'),
95+
quiet: false,
96+
});
97+
expect(configSpy).toHaveBeenNthCalledWith(4, {
98+
debug: true,
99+
encoding: 'latin1',
100+
override: false,
101+
path: expect.stringContaining('.env.development.local'),
102+
quiet: false,
103+
});
104+
});
105+
106+
test('preserves the framework defaults when dotenv options are omitted', () => {
107+
loadEnvConfig('development');
108+
109+
expect(configSpy.mock.calls).toEqual([
110+
[{ path: expect.stringContaining('.env'), override: true, quiet: true }],
111+
[{ path: expect.stringContaining('.env.local'), override: true, quiet: true }],
112+
[{ path: expect.stringContaining('.env.development'), override: true, quiet: true }],
113+
[{ path: expect.stringContaining('.env.development.local'), override: true, quiet: true }],
114+
]);
115+
});
116+
});
117+
118+
test('expands variables after each file is loaded', () => {
119+
loadEnvConfig('development');
120+
121+
expect(expandSpy).toHaveBeenCalledTimes(4);
122+
expect(expandSpy).toHaveBeenNthCalledWith(1, { parsed: {} });
123+
expect(expandSpy).toHaveBeenNthCalledWith(2, { parsed: {} });
124+
expect(expandSpy).toHaveBeenNthCalledWith(3, { parsed: {} });
125+
expect(expandSpy).toHaveBeenNthCalledWith(4, { parsed: {} });
126+
});
127+
});

src/config/config-loader.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import path from 'path';
2+
import dotenv from 'dotenv';
3+
import dotenvExpand from 'dotenv-expand';
4+
import type { KoalaDotenvOptions } from './koala-config';
5+
6+
export function loadEnvConfig(env: string, dotenvOptions?: KoalaDotenvOptions): void {
7+
const rootDir = process.cwd();
8+
9+
resolveEnvFileNames(env).forEach(fileName => {
10+
loadEnvFile(path.resolve(rootDir, fileName), resolveDotenvOptions(dotenvOptions));
11+
});
12+
}
13+
14+
function resolveEnvFileNames(env: string): string[] {
15+
if (env === 'test') {
16+
return ['.env', `.env.${env}`, `.env.${env}.local`];
17+
}
18+
19+
return ['.env', '.env.local', `.env.${env}`, `.env.${env}.local`];
20+
}
21+
22+
function resolveDotenvOptions(dotenvOptions?: KoalaDotenvOptions): KoalaDotenvOptions {
23+
return {
24+
override: true,
25+
quiet: true,
26+
...dotenvOptions,
27+
};
28+
}
29+
30+
function loadEnvFile(filePath: string, options: KoalaDotenvOptions): void {
31+
const expandedOptions = dotenv.config({ path: filePath, ...options });
32+
33+
dotenvExpand.expand(expandedOptions);
34+
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { type KoalaConfig } from './types';
1+
import { type KoalaConfig } from './koala-config';
22

33
export const koalaDefaultConfig: KoalaConfig = {
44
controllers: [],

0 commit comments

Comments
 (0)