diff --git a/AGENTS.md b/AGENTS.md index 36a0e50..da2d7d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,6 +71,15 @@ - Run commands on the docker container named `koalats-framework-container`. - Start the container if it's not running using the make file. +## File and Module Renames + +- Treat the filesystem as case-sensitive when renaming files or directories. +- Do not rely on case-only renames or mixed-case import paths. +- Do not rely on module-local directory imports such as `@/feature` resolving through `feature/index.ts` during + internal refactors. Prefer explicit file imports such as `@/feature/specific-file`. +- After renaming files or directories, clean generated build output and rerun the full validation pipeline to catch + stale artifact and path-casing issues before opening the PR. + ## Refactorings - Refactorings MUST not introduce any breaking changes to the public API or types. diff --git a/src/Config/ConfigLoader.test.ts b/src/Config/ConfigLoader.test.ts deleted file mode 100644 index 6113aad..0000000 --- a/src/Config/ConfigLoader.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import dotenv from 'dotenv'; -import dotenvExpand from 'dotenv-expand'; -import { describe, expect, test, vi } from 'vitest'; -import { loadEnvConfig } from '@/Config'; - -describe('Load env config', () => { - const configSpy = vi.spyOn(dotenv, 'config'); - const expandSpy = vi.spyOn(dotenvExpand, 'expand'); - - test('it should load .env file', () => { - loadEnvConfig('development'); - - expect(configSpy).toHaveBeenCalledWith({ path: expect.stringContaining('.env'), override: true, quiet: true }); - expect(expandSpy).toHaveBeenCalled(); - }); - - test('it should load .env.local file', () => { - loadEnvConfig('development'); - - expect(configSpy).toHaveBeenCalledWith({ - path: expect.stringContaining('.env.local'), - override: true, - quiet: true, - }); - expect(expandSpy).toHaveBeenCalled(); - }); - - test('it should not load .env.local file in test environment', () => { - loadEnvConfig('test'); - - expect(configSpy).not.toHaveBeenCalledWith(expect.stringContaining('.env.local')); - }); - - test('it should load .env. file', () => { - loadEnvConfig('development'); - - expect(configSpy).toHaveBeenCalledWith({ - path: expect.stringContaining('.env.development'), - override: true, - quiet: true, - }); - expect(expandSpy).toHaveBeenCalled(); - }); - - test('it should load .env..local file', () => { - loadEnvConfig('test'); - - expect(configSpy).toHaveBeenCalledWith({ - path: expect.stringContaining('.env.test.local'), - override: true, - quiet: true, - }); - expect(expandSpy).toHaveBeenCalled(); - }); -}); diff --git a/src/Config/ConfigLoader.ts b/src/Config/ConfigLoader.ts deleted file mode 100644 index 9c928d4..0000000 --- a/src/Config/ConfigLoader.ts +++ /dev/null @@ -1,24 +0,0 @@ -import path from 'path'; -import dotenv from 'dotenv'; -import dotenvExpand from 'dotenv-expand'; - -export function loadEnvConfig(env: string): void { - loadEnvFile('.env'); - - if (env !== 'test') loadEnvFile('.env.local'); - - loadEnvFile(`.env.${env}`); - loadEnvFile(`.env.${env}.local`); -} - -function loadEnvFile(fileName: string): void { - const rootDir = process.cwd(); - - const expandedOptions = dotenv.config({ - path: path.resolve(rootDir, fileName), - override: true, - quiet: true, - }); - - dotenvExpand.expand(expandedOptions); -} diff --git a/src/Config/index.ts b/src/Config/index.ts deleted file mode 100644 index 3c8a091..0000000 --- a/src/Config/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type * from '@/Config/types'; -export * from '@/Config/DefaultConfig'; -export * from '@/Config/ConfigLoader'; diff --git a/src/Testing/TestAgentFactory.ts b/src/Testing/TestAgentFactory.ts index 771ec8a..9695fb1 100644 --- a/src/Testing/TestAgentFactory.ts +++ b/src/Testing/TestAgentFactory.ts @@ -1,5 +1,5 @@ import { create } from '@/application/create-application'; -import { type KoalaConfig } from '@/Config'; +import { type KoalaConfig } from '@/config/koala-config'; import { type HttpMiddleware, type HttpScope, type NextMiddleware } from '@/Http'; import { type User } from '@/Security/types'; import supertest from 'supertest'; diff --git a/src/application/create-application.test.ts b/src/application/create-application.test.ts index d7d5db7..4e122b4 100644 --- a/src/application/create-application.test.ts +++ b/src/application/create-application.test.ts @@ -1,5 +1,5 @@ import { create } from '@/application/create-application'; -import { koalaDefaultConfig } from '@/Config'; +import { koalaDefaultConfig } from '@/config/default-config'; import { Get, Route, RouteGroup } from '@/routing'; import { exclusiveRoutingModeError } from '@/routing/verify-routing-mode'; import { expect, test } from 'vitest'; diff --git a/src/application/create-application.ts b/src/application/create-application.ts index 2bee672..be225a5 100644 --- a/src/application/create-application.ts +++ b/src/application/create-application.ts @@ -1,4 +1,4 @@ -import { type KoalaConfig } from '@/Config'; +import { type KoalaConfig } from '@/config/koala-config'; import { initializeScope } from '@/Http'; import { serveStaticFiles } from '@/Http/Files'; import { applyConfiguredGlobalMiddleware } from '@/Http/middleware/apply-configured-global-middleware'; diff --git a/src/config/config-loader.test.ts b/src/config/config-loader.test.ts new file mode 100644 index 0000000..c6f72c1 --- /dev/null +++ b/src/config/config-loader.test.ts @@ -0,0 +1,127 @@ +import dotenv from 'dotenv'; +import dotenvExpand from 'dotenv-expand'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { loadEnvConfig } from '@/config/config-loader'; + +describe('load env config', () => { + const configSpy = vi.spyOn(dotenv, 'config'); + const expandSpy = vi.spyOn(dotenvExpand, 'expand'); + + beforeEach(() => { + vi.clearAllMocks(); + configSpy.mockReturnValue({ parsed: {} }); + expandSpy.mockReturnValue({ parsed: {} }); + }); + + describe('file selection', () => { + test('loads environment files in development order', () => { + loadEnvConfig('development'); + + expect(configSpy).toHaveBeenNthCalledWith(1, { + path: expect.stringContaining('.env'), + override: true, + quiet: true, + }); + expect(configSpy).toHaveBeenNthCalledWith(2, { + path: expect.stringContaining('.env.local'), + override: true, + quiet: true, + }); + expect(configSpy).toHaveBeenNthCalledWith(3, { + path: expect.stringContaining('.env.development'), + override: true, + quiet: true, + }); + expect(configSpy).toHaveBeenNthCalledWith(4, { + path: expect.stringContaining('.env.development.local'), + override: true, + quiet: true, + }); + }); + + test('skips the shared local file in the test environment', () => { + loadEnvConfig('test'); + + expect(configSpy).toHaveBeenCalledTimes(3); + + expect(configSpy).toHaveBeenNthCalledWith(1, { + path: expect.stringContaining('.env'), + override: true, + quiet: true, + }); + expect(configSpy).toHaveBeenNthCalledWith(2, { + path: expect.stringContaining('.env.test'), + override: true, + quiet: true, + }); + expect(configSpy).toHaveBeenNthCalledWith(3, { + path: expect.stringContaining('.env.test.local'), + override: true, + quiet: true, + }); + }); + }); + + describe('dotenv options', () => { + test('uses the provided dotenv options for every file', () => { + loadEnvConfig('development', { + debug: true, + encoding: 'latin1', + override: false, + quiet: false, + }); + + expect(configSpy).toHaveBeenCalledTimes(4); + + expect(configSpy).toHaveBeenNthCalledWith(1, { + debug: true, + encoding: 'latin1', + override: false, + path: expect.stringContaining('.env'), + quiet: false, + }); + expect(configSpy).toHaveBeenNthCalledWith(2, { + debug: true, + encoding: 'latin1', + override: false, + path: expect.stringContaining('.env.local'), + quiet: false, + }); + expect(configSpy).toHaveBeenNthCalledWith(3, { + debug: true, + encoding: 'latin1', + override: false, + path: expect.stringContaining('.env.development'), + quiet: false, + }); + expect(configSpy).toHaveBeenNthCalledWith(4, { + debug: true, + encoding: 'latin1', + override: false, + path: expect.stringContaining('.env.development.local'), + quiet: false, + }); + }); + + test('preserves the framework defaults when dotenv options are omitted', () => { + loadEnvConfig('development'); + + expect(configSpy.mock.calls).toEqual([ + [{ path: expect.stringContaining('.env'), override: true, quiet: true }], + [{ path: expect.stringContaining('.env.local'), override: true, quiet: true }], + [{ path: expect.stringContaining('.env.development'), override: true, quiet: true }], + [{ path: expect.stringContaining('.env.development.local'), override: true, quiet: true }], + ]); + }); + }); + + test('expands variables after each file is loaded', () => { + loadEnvConfig('development'); + + expect(expandSpy).toHaveBeenCalledTimes(4); + expect(expandSpy).toHaveBeenNthCalledWith(1, { parsed: {} }); + expect(expandSpy).toHaveBeenNthCalledWith(2, { parsed: {} }); + expect(expandSpy).toHaveBeenNthCalledWith(3, { parsed: {} }); + expect(expandSpy).toHaveBeenNthCalledWith(4, { parsed: {} }); + }); +}); diff --git a/src/config/config-loader.ts b/src/config/config-loader.ts new file mode 100644 index 0000000..a8fb4d5 --- /dev/null +++ b/src/config/config-loader.ts @@ -0,0 +1,34 @@ +import path from 'path'; +import dotenv from 'dotenv'; +import dotenvExpand from 'dotenv-expand'; +import type { KoalaDotenvOptions } from './koala-config'; + +export function loadEnvConfig(env: string, dotenvOptions?: KoalaDotenvOptions): void { + const rootDir = process.cwd(); + + resolveEnvFileNames(env).forEach(fileName => { + loadEnvFile(path.resolve(rootDir, fileName), resolveDotenvOptions(dotenvOptions)); + }); +} + +function resolveEnvFileNames(env: string): string[] { + if (env === 'test') { + return ['.env', `.env.${env}`, `.env.${env}.local`]; + } + + return ['.env', '.env.local', `.env.${env}`, `.env.${env}.local`]; +} + +function resolveDotenvOptions(dotenvOptions?: KoalaDotenvOptions): KoalaDotenvOptions { + return { + override: true, + quiet: true, + ...dotenvOptions, + }; +} + +function loadEnvFile(filePath: string, options: KoalaDotenvOptions): void { + const expandedOptions = dotenv.config({ path: filePath, ...options }); + + dotenvExpand.expand(expandedOptions); +} diff --git a/src/Config/DefaultConfig.ts b/src/config/default-config.ts similarity index 58% rename from src/Config/DefaultConfig.ts rename to src/config/default-config.ts index eab87a4..fdb6441 100644 --- a/src/Config/DefaultConfig.ts +++ b/src/config/default-config.ts @@ -1,4 +1,4 @@ -import { type KoalaConfig } from './types'; +import { type KoalaConfig } from './koala-config'; export const koalaDefaultConfig: KoalaConfig = { controllers: [], diff --git a/src/Config/types.ts b/src/config/koala-config.ts similarity index 77% rename from src/Config/types.ts rename to src/config/koala-config.ts index cb2f618..9df4c74 100644 --- a/src/Config/types.ts +++ b/src/config/koala-config.ts @@ -2,17 +2,23 @@ import { type HttpMiddleware } from '@/Http'; import { type StaticFilesOptions } from '@/Http/Files'; import { type EventSubscriber } from '@/Kernel'; import type { RouteSource } from '@/routing'; +import type { DotenvConfigOptions } from 'dotenv'; /** * @deprecated Use function-first routes from `@koala-ts/framework/routing` with `KoalaConfig.routes` instead. */ export type Controller = new (...args: unknown[]) => unknown; +export type KoalaDotenvOptions = Pick; + export interface KoalaConfig { /** * @deprecated Use `routes` with `Route` from `@koala-ts/framework/routing` instead. */ controllers: Controller[]; + environment?: { + dotenv?: KoalaDotenvOptions; + }; routes?: RouteSource[]; globalMiddleware?: HttpMiddleware[]; staticFiles?: StaticFilesOptions; diff --git a/src/index.ts b/src/index.ts index fd717b9..74f40f9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,9 @@ import { getLegacyRoutes, registerLegacyRoutes } from '@/routing/decorator/legac import type { RouteMetadata as LegacyRouteMetadata } from '@/routing/decorator/route-metadata'; // Core modules -export * from '@/Config'; +export type * from '@/config/koala-config'; +export * from '@/config/default-config'; +export * from '@/config/config-loader'; export * from '@/Http'; export * from '@/Kernel'; diff --git a/src/routing/verify-routing-mode.ts b/src/routing/verify-routing-mode.ts index d65e900..8fa2ec5 100644 --- a/src/routing/verify-routing-mode.ts +++ b/src/routing/verify-routing-mode.ts @@ -1,4 +1,4 @@ -import type { KoalaConfig } from '@/Config'; +import type { KoalaConfig } from '@/config/koala-config'; export const exclusiveRoutingModeError = 'Koala routing mode is exclusive. Use either legacy controllers from @koala-ts/framework or routes from @koala-ts/framework/routing.'; diff --git a/tests/function-first-routing.e2e.test.ts b/tests/function-first-routing.e2e.test.ts index 5393f6d..cd59bf6 100644 --- a/tests/function-first-routing.e2e.test.ts +++ b/tests/function-first-routing.e2e.test.ts @@ -1,7 +1,7 @@ import { text } from 'node:stream/consumers'; import { describe, expect, test } from 'vitest'; import { createTestAgent, type HttpRequest, type HttpScope, type UploadedFile } from '../src'; -import { koalaDefaultConfig } from '../src/Config'; +import { koalaDefaultConfig } from '../src/config/default-config'; import { Any, Get, Route, RouteGroup } from '../src/routing'; import { exclusiveRoutingModeError } from '../src/routing/verify-routing-mode';