Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions packages/env-utils/src/create-env-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
7 changes: 4 additions & 3 deletions packages/env-utils/src/create-env-parser.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -27,9 +27,10 @@ import { buildEnvObject, getByPath, normalizeKey } from "./utils/index.js";
* }));
*/
export function createEnvParser<T extends EnvObject = EnvObject>(options: EnvParserOptions): EnvParser<T> {
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.
Expand Down
1 change: 1 addition & 0 deletions packages/env-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
28 changes: 28 additions & 0 deletions packages/env-utils/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends EnvObject = EnvObject> {
Expand Down
93 changes: 93 additions & 0 deletions packages/env-utils/src/utils/filter-by-namespace.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined> = {
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({});
});
});
52 changes: 52 additions & 0 deletions packages/env-utils/src/utils/filter-by-namespace.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>,
namespace: string
): Record<string, string | undefined> {
const prefix = `${namespace.toUpperCase()}_`;
const result: Record<string, string | undefined> = {};

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<string, string | undefined>,
namespaces: string[]
): Record<string, string | undefined> {
const result: Record<string, string | undefined> = {};

// Forward iteration: later namespaces overwrite earlier ones (last wins).
for (const ns of namespaces) {
Object.assign(result, filterByNamespace(raw, ns));
}

return result;
}
1 change: 1 addition & 0 deletions packages/env-utils/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
Loading