diff --git a/packages/env-utils/src/create-env-parser.test.ts b/packages/env-utils/src/create-env-parser.test.ts index 5de513a..6cbc855 100644 --- a/packages/env-utils/src/create-env-parser.test.ts +++ b/packages/env-utils/src/create-env-parser.test.ts @@ -236,4 +236,63 @@ describe("createEnvParser", () => { deprecate("new_db_url", "database_url"); expect(warn).not.toHaveBeenCalled(); }); + + // --- namespaces --- + + it("namespaces: only exposes vars matching the given prefix", () => { + const { env } = createEnvParser({ + appName: "test-app", + namespaces: ["HOLOCRON"], + loader: makeLoader({ HOLOCRON_DEBUG: "true", PORT: "3000" }), + }); + expect(env).toEqual({ debug: true }); + }); + + it("namespaces: strips the prefix from keys", () => { + const { get } = createEnvParser({ + appName: "test-app", + namespaces: ["CLI_TEMPLATE"], + loader: makeLoader({ CLI_TEMPLATE_SOUND: "true", CLI_TEMPLATE_DEBUG: "false" }), + }); + expect(get("sound")).toBe(true); + expect(get("debug")).toBe(false); + }); + + it("namespaces: last namespace wins over earlier ones for the same key", () => { + const { get } = createEnvParser({ + appName: "test-app", + // HOLOCRON is the global base; CLI_TEMPLATE is the local override (last = wins) + namespaces: ["HOLOCRON", "CLI_TEMPLATE"], + loader: makeLoader({ HOLOCRON_DEBUG: "true", CLI_TEMPLATE_DEBUG: "false" }), + }); + expect(get("debug")).toBe(false); + }); + + it("namespaces: earlier namespace acts as base when the key is absent from later ones", () => { + const { get } = createEnvParser({ + appName: "test-app", + namespaces: ["HOLOCRON", "CLI_TEMPLATE"], + loader: makeLoader({ HOLOCRON_VERBOSE: "true" }), + }); + expect(get("verbose")).toBe(true); + }); + + it("namespaces: ignores unrelated vars entirely", () => { + const { get } = createEnvParser({ + appName: "test-app", + namespaces: ["HOLOCRON"], + loader: makeLoader({ HOLOCRON_DEBUG: "true", PORT: "3000", OTHER_VAR: "x" }), + }); + expect(get("port")).toBeUndefined(); + expect(get("other_var")).toBeUndefined(); + }); + + it("namespaces: behaves as unfiltered when not provided", () => { + const { get } = createEnvParser({ + appName: "test-app", + loader: makeLoader({ PORT: "3000", HOLOCRON_DEBUG: "true" }), + }); + expect(get("port")).toBe(3000); + expect(get("holocron_debug")).toBe(true); + }); }); diff --git a/packages/env-utils/src/create-env-parser.ts b/packages/env-utils/src/create-env-parser.ts index 8c60bd8..af4ca6f 100644 --- a/packages/env-utils/src/create-env-parser.ts +++ b/packages/env-utils/src/create-env-parser.ts @@ -1,7 +1,7 @@ import { debug } from "./debug.js"; import { DotenvLoader } from "./loader.js"; import type { EnvObject, EnvParser, EnvParserOptions } from "./types.js"; -import { buildEnvObject, getByPath, normalizeKey } from "./utils/index.js"; +import { buildEnvObject, getByPath, mergeNamespaces, normalizeKey } from "./utils/index.js"; /** * Create an env parser for your app. @@ -27,9 +27,10 @@ import { buildEnvObject, getByPath, normalizeKey } from "./utils/index.js"; * })); */ export function createEnvParser(options: EnvParserOptions): EnvParser { - const { appName, loader = new DotenvLoader(), parseValues = true } = options; + const { appName, loader = new DotenvLoader(), parseValues = true, namespaces } = options; - const raw = loader.load(); + const loaded = loader.load(); + const raw = namespaces && namespaces.length > 0 ? mergeNamespaces(loaded, namespaces) : loaded; // buildEnvObject returns EnvObject; T extends EnvObject and is caller-supplied, // so this cast is the correct and minimal assertion needed here. diff --git a/packages/env-utils/src/index.ts b/packages/env-utils/src/index.ts index 185ea5f..d83b397 100644 --- a/packages/env-utils/src/index.ts +++ b/packages/env-utils/src/index.ts @@ -3,3 +3,4 @@ export type { Environment } from "./environment.js"; export { DEFAULT_ENVIRONMENT, DEPLOYED_ENVIRONMENTS, environment, ENVIRONMENTS } from "./environment.js"; export { DotenvLoader } from "./loader.js"; export type { EnvLoader, EnvObject, EnvParser, EnvParserOptions, Primitive } from "./types.js"; +export { filterByNamespace, mergeNamespaces } from "./utils/filter-by-namespace.js"; diff --git a/packages/env-utils/src/types.ts b/packages/env-utils/src/types.ts index eeb5ed6..7e43485 100644 --- a/packages/env-utils/src/types.ts +++ b/packages/env-utils/src/types.ts @@ -29,6 +29,34 @@ export interface EnvParserOptions { * native types. Defaults to true. */ parseValues?: boolean; + + /** + * Namespace prefixes to filter and strip from raw env var keys. + * When set, only vars whose names start with one of these prefixes + * (case-insensitive, followed by `_`) are visible to the parser. + * The prefix is stripped before the key is normalised and looked up. + * + * Uses a **cascade model** — later entries win when the same key + * exists under multiple prefixes. Put broad/global namespaces first + * and project-specific overrides last (same mental model as CSS + * specificity or `Object.assign`). + * + * This lets you share an org-wide namespace (e.g. `HOLOCRON_` set + * once in `~/.bashrc`) as a base, then override individual vars with + * a project-specific prefix in `.env`, without copying every global + * var into each project. + * + * @example + * // HOLOCRON_DEBUG is the global default; CLI_TEMPLATE_DEBUG overrides it. + * createEnvParser({ appName: "my-cli", namespaces: ["HOLOCRON", "CLI_TEMPLATE"] }) + * + * // Single namespace — only HOLOCRON_* vars are visible + * createEnvParser({ appName: "my-cli", namespaces: ["HOLOCRON"] }) + * + * // Completely custom — no org namespace at all + * createEnvParser({ appName: "my-cli", namespaces: ["MY_TOOL"] }) + */ + namespaces?: string[]; } export interface EnvParser { diff --git a/packages/env-utils/src/utils/filter-by-namespace.test.ts b/packages/env-utils/src/utils/filter-by-namespace.test.ts new file mode 100644 index 0000000..26039b2 --- /dev/null +++ b/packages/env-utils/src/utils/filter-by-namespace.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; + +import { filterByNamespace, mergeNamespaces } from "./filter-by-namespace.js"; + +describe("filterByNamespace", () => { + it("returns only vars that start with the prefix", () => { + const raw = { + HOLOCRON_DEBUG: "true", + HOLOCRON_VERBOSE: "false", + PORT: "3000", + OTHER_VAR: "x", + }; + expect(filterByNamespace(raw, "HOLOCRON")).toEqual({ + DEBUG: "true", + VERBOSE: "false", + }); + }); + + it("strips the prefix from the returned keys", () => { + const raw = { CLI_TEMPLATE_SOUND: "true" }; + expect(filterByNamespace(raw, "CLI_TEMPLATE")).toEqual({ SOUND: "true" }); + }); + + it("is case-insensitive for the prefix match", () => { + const raw = { holocron_debug: "true", HOLOCRON_VERBOSE: "false" }; + expect(filterByNamespace(raw, "HOLOCRON")).toEqual({ + debug: "true", + VERBOSE: "false", + }); + }); + + it("returns an empty object when no keys match", () => { + const raw = { PORT: "3000", NODE_ENV: "test" }; + expect(filterByNamespace(raw, "HOLOCRON")).toEqual({}); + }); + + it("excludes vars that only match the prefix without the trailing underscore", () => { + const raw = { HOLOCRON: "oops", HOLOCRON_DEBUG: "true" }; + expect(filterByNamespace(raw, "HOLOCRON")).toEqual({ DEBUG: "true" }); + }); + + it("handles undefined values", () => { + const raw: Record = { + HOLOCRON_DEBUG: undefined, + HOLOCRON_VERBOSE: "true", + }; + expect(filterByNamespace(raw, "HOLOCRON")).toEqual({ + DEBUG: undefined, + VERBOSE: "true", + }); + }); +}); + +describe("mergeNamespaces", () => { + it("merges vars from all namespaces", () => { + const raw = { + HOLOCRON_DEBUG: "true", + CLI_TEMPLATE_SOUND: "false", + }; + expect(mergeNamespaces(raw, ["HOLOCRON", "CLI_TEMPLATE"])).toEqual({ + DEBUG: "true", + SOUND: "false", + }); + }); + + it("last namespace wins when the same key exists in multiple namespaces", () => { + const raw = { + HOLOCRON_DEBUG: "true", + CLI_TEMPLATE_DEBUG: "false", + }; + // CLI_TEMPLATE is last = more specific = overrides HOLOCRON + expect(mergeNamespaces(raw, ["HOLOCRON", "CLI_TEMPLATE"])).toEqual({ + DEBUG: "false", + }); + }); + + it("earlier namespace acts as base/fallback when the key is absent from later ones", () => { + const raw = { + HOLOCRON_VERBOSE: "true", + CLI_TEMPLATE_DEBUG: "false", + }; + // HOLOCRON provides VERBOSE; CLI_TEMPLATE provides DEBUG and overrides nothing + expect(mergeNamespaces(raw, ["HOLOCRON", "CLI_TEMPLATE"])).toEqual({ + DEBUG: "false", + VERBOSE: "true", + }); + }); + + it("returns an empty object when no namespaces are given", () => { + const raw = { HOLOCRON_DEBUG: "true" }; + expect(mergeNamespaces(raw, [])).toEqual({}); + }); +}); diff --git a/packages/env-utils/src/utils/filter-by-namespace.ts b/packages/env-utils/src/utils/filter-by-namespace.ts new file mode 100644 index 0000000..04b9857 --- /dev/null +++ b/packages/env-utils/src/utils/filter-by-namespace.ts @@ -0,0 +1,52 @@ +/** + * Filter raw env vars to only those matching a namespace prefix, + * stripping the prefix from each key before returning. + * + * Case-insensitive: "holocron_debug" and "HOLOCRON_DEBUG" both match + * namespace "HOLOCRON". + * + * @example + * filterByNamespace({ HOLOCRON_DEBUG: "true", PORT: "3000" }, "HOLOCRON") + * // → { DEBUG: "true" } + */ +export function filterByNamespace( + raw: Record, + namespace: string +): Record { + const prefix = `${namespace.toUpperCase()}_`; + const result: Record = {}; + + for (const [key, value] of Object.entries(raw)) { + if (key.toUpperCase().startsWith(prefix)) { + result[key.slice(prefix.length)] = value; + } + } + + return result; +} + +/** + * Merge multiple namespace-filtered views of a raw env record. + * + * Uses a cascade model — **later entries win** over earlier ones when + * the same key exists under multiple prefixes. Put broad/global + * namespaces first and project-specific overrides last, the same way + * you would layer CSS rules or Object.assign calls. + * + * @example + * // HOLOCRON_DEBUG is the org-wide default; CLI_TEMPLATE_DEBUG overrides it. + * mergeNamespaces(raw, ["HOLOCRON", "CLI_TEMPLATE"]) + */ +export function mergeNamespaces( + raw: Record, + namespaces: string[] +): Record { + const result: Record = {}; + + // Forward iteration: later namespaces overwrite earlier ones (last wins). + for (const ns of namespaces) { + Object.assign(result, filterByNamespace(raw, ns)); + } + + return result; +} diff --git a/packages/env-utils/src/utils/index.ts b/packages/env-utils/src/utils/index.ts index 0e873e8..ba0a9f8 100644 --- a/packages/env-utils/src/utils/index.ts +++ b/packages/env-utils/src/utils/index.ts @@ -1,6 +1,7 @@ export * from "./build-env-object.js"; export * from "./coerce-value.js"; export * from "./collect-keys.js"; +export * from "./filter-by-namespace.js"; export * from "./get-by-path.js"; export * from "./is-env-object.js"; export * from "./normalize-key.js";