Skip to content

Changsik00/node-settings

Use this GitHub action with your project
Add this Action to an existing workflow or create a new one
View on Marketplace

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

71 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Schema-first settings for Node apps — cascading config across env files, per-env config files, and packages. One zod schema → typed runtime config + .env.example + Markdown docs + Kubernetes manifests + a CLI that gates deploys in CI.

npm version npm downloads CI Bundle size Install size Tests Coverage Types: TypeScript Node.js License: MIT

Sample · Configuration · Deployment · Errors · Architecture · Testing

For teams that ship the same image to many environments and want every downstream artefact — .env.example, K8s manifests, Markdown docs, JSON Schema — derived from a single zod schema instead of hand-maintained.


TL;DR

pnpm add @env-kit/node-settings zod
import { z } from "zod";
import { defineSettings } from "@env-kit/node-settings";

const loadSettings = defineSettings({
  // 1. envSchema — the contract for env vars. CI/infra injects these.
  //    Key names matching DEFAULT_SECRET_PATTERNS (PASSWORD, TOKEN, …)
  //    are auto-flagged as secrets, so they land in K8s Secret (not
  //    ConfigMap) and get masked in generated docs.
  envSchema: z.object({
    APP_ENV: z.enum(["local", "dev", "prod"]).default("local"),
    DB_HOST: z.string(),
    DB_PASSWORD: z.string(),         // auto-flagged as a secret
    APP_CONFIG_JSON: z.string().optional(), // runtime override (see (5))
  }),

  // 2. envKey — which env var picks the active perEnv branch.
  //    Use "APP_ENV" for rich enums (local/dev/stage/prod), or
  //    "NODE_ENV" if you want to stick to the Node convention
  //    (development/production/test). Must exist in envSchema.
  envKey: "APP_ENV",

  // 3. defaults — config shared across every env. Used as the *base*;
  //    perEnv[mode] is deep-merged on top. If a key exists only in
  //    defaults, it survives to the final config (fallback for envs
  //    that don't override it).
  defaults: {
    bucket: "",
    region: "us-east-1",            // every env keeps this unless overridden
  },

  // 4. perEnv — branch-specific overrides keyed by envKey value.
  //    Each key here MUST be a value from the envKey enum (typos are
  //    caught at definition time). Branch wins over defaults via deep
  //    merge; nested objects merge field-by-field, not replace.
  perEnv: {
    local: { bucket: "local-bucket" },
    dev:   { bucket: "dev-bucket" },
    prod:  { bucket: "prod-bucket", region: "us-west-2" }, // overrides region
  },

  // 5. overrideEnvKey — name of an env var that, if set, carries a
  //    JSON blob deep-merged on top of perEnv at boot. The runtime
  //    escape hatch: hot-swap a value in a canary deploy, flip a
  //    flag without redeploying, patch a region during incident
  //    response. Same image, different config, no rebuild.
  overrideEnvKey: "APP_CONFIG_JSON",

  // 6. build — receives (envSchema output, merged defaults + perEnv
  //    + JSON override) and returns the final settings object. This
  //    is what you import in your app code; the loader Object.freeze()s
  //    the return value.
  build: (env, config) => ({
    dbHost: env.DB_HOST,
    dbPassword: env.DB_PASSWORD,
    bucket: config.bucket,
    region: config.region,
  }),
});

export default loadSettings;
export type Settings = ReturnType<typeof loadSettings>;
// at boot — three cascades resolve into one frozen `settings` object
import { loadDotenvCascade } from "@env-kit/node-settings";
import loadSettings from "./settings.config.js";

const { env, mode } = loadDotenvCascade();
//   .env → .env.local → .env.<mode> → .env.<mode>.local → process.env
//   (Vite / Next / dotenv-flow convention; later sources win)

export const settings = loadSettings(env); // → validate → layer → override → frozen

Two file streams cascade into one frozen settings:

env files                             per-env config files
  .env                                  config/defaults.ts
  .env.local                            config/<mode>.ts
  .env.<mode>                           (or inline `perEnv: {...}`)
  .env.<mode>.local
  process.env  ← CI / Vault wins        ⊕ APP_CONFIG_JSON  ← runtime override
        │                                       │
        ▼ envSchema.parse() (zod)               ▼ deep-merge, later wins
       env                                   config
          \                                  /
           \                                /
            ─────► build(env, config) ◄────
                          │
                          ▼
                  Object.freeze ⇒ settings

Three cascades, one frozen settings:

  • Cascade 1 — env-var files (loadDotenvCascade()). .env → .env.local → .env.<mode> → .env.<mode>.local → process.env, later sources win. CI / Kubernetes / Vault inject directly into process.env, which beats every file.
  • Cascade 2 — per-env config files. config/defaults.ts is the baseline; config/<mode>.ts is DeepPartial<AppConfig> deep-merged on top. Inline defaults: {...} / perEnv: {...} is the same shape — split into files when they outgrow one screen.
  • Cascade 3 — extends: [base] (monorepo). A base loader's envSchema / defaults / perEnv are merged in before the child's own. t3-oss/env-style composition.
  • Runtime override. APP_CONFIG_JSON='{"bucket":"failover"}' deep-merges on top of cascades 1–3. Same image, different config — built for canaries and incident response.

For a complete worked example with split-file config + monorepo extends + env templates, see sample/.

Why use this

  • Schema-first, single source of truth. One z.object({...}) becomes runtime config, .env.example, Markdown docs, K8s ConfigMap + Secret, JSON Schema, Terraform .tfvars, and a docker-compose fragment. Wire node-settings generate into CI (or use one of the build-time plugins) and the downstream artefacts can't drift from the schema — edit the schema, regenerate, commit, done.
  • Layered config. defaults + perEnv[mode] + optional JSON override at boot. Result is Object.freeze'd.
  • Build once, deploy many. Same image, APP_ENV-driven branching. Runtime override (APP_CONFIG_JSON) lets ops patch values without redeploying.
  • Monorepo-friendly. extends: [baseLoader] composes shared base configs (t3-oss/env-style).
  • .env.<mode> cascade. Opt-in loadDotenvCascade() follows the Vite / Next.js / dotenv-flow convention.
  • Platform presets. presets.vercel(), presets.netlify(), presets.githubActions(...), … map platform signals to APP_ENV.
  • Defensive at definition time. Typo'd perEnv key, wrong envKey, missing override key — caught when the loader is defined, not on the first request.
  • todo(reason) markers. Mark unfilled config slots with a type-safe sentinel; the loader fails loudly with PER_ENV_TODO if an env tries to load with one still in place.
  • Severity-aware error catalog. Every throw is a NodeSettingsError with a stable .code, a .severity bucket (config | runtime | io | usage), a .title, and a .docsUrl. Drop reportError(err) into your logger to get a structured ErrorReport ready for Sentry / log aggregators.
  • Build-time validation plugins. Vite, Next.js, and esbuild plugins fail the build the moment an env is invalid — no waiting for the app to boot.
  • ESM, Node ≥ 18. Only jiti (TS config loading) at runtime; zod is a peer dep.

Design principles

The library codifies four patterns we lean on hard. They show up in the public API and in how the package is built internally — docs/ARCHITECTURE.md has the full treatment.

  1. Single source of truth, everything else derived. A z.object produces seven downstream artifacts. The internal ERROR_CATALOG follows the same shape: NodeSettingsErrorCode, err.severity, err.docsUrl, reportError() output, and docs/ERRORS.md are all generated from a single record. pnpm verify:errors fails CI if any of them drift.
  2. Fail at the earliest moment possible. Misconfiguration → at defineSettings(...) call time. Bad env → at boot (before the first request). todo(...) placeholder → when the target env tries to load, not when any env loads. Vite / Next / esbuild plugins → at build time, before bundling.
  3. Stable contract; evolving messages. .code and .severity are part of the public API and are versioned strictly. .message is a human-friendly diagnostic and may improve in minor versions. The api-surface/*.d.ts snapshots are the contract for types; the catalog is the contract for errors. Drift fails CI.
  4. Frozen output, layered architecture. Loader output is Object.freeze'd so accidental mutation is impossible. Source is organised in strict layers (errors / utils → tools → core → adapters); higher layers may import from lower but never the reverse. Tests follow the standard unit / contract / integration / e2e taxonomy — see docs/TESTING.md.

Comparison

Capability dotenv dotenv-flow t3-oss/env convict node-config node-settings
zod-based env validation
Server / client env split (prefix-checked)
.env.<mode> file cascade
Per-env config layering (defaults → perEnv)
JSON runtime override (env syntax)
Monorepo extends
Platform presets (Vercel / Netlify / GH Actions / …)
todo(...) sentinel for unfilled values
K8s ConfigMap + Secret YAML
K8s drift detection (diff CLI)
Terraform .tfvars generation
Docker Compose fragment generation
Build-time validation plugins (Vite + Next + esbuild)
CLI (validate / check / inspect / generate)
Severity-aware error catalog + reportError()
CI-enforced contract checks (api-surface, errors, dist, pack)

The differentiation is concentrated in monorepo composition, per-env layering with todo-sentinels, first-class infra handoff (K8s manifests, Terraform tfvars, Docker Compose, Vite / Next / esbuild plugins), and the operational ergonomics around errors (catalog → severity → reportError() → log aggregator).

Known trade-offs

To balance the table above, here's what you give up by picking this over an older neighbour:

  • Younger and smaller. dotenv / dotenv-flow / node-config have years of production miles and a much bigger community. This library has the test scaffolding to compensate, but it's not the same as battle-tested.
  • One maintainer. Maintained by @Changsik00. Response times depend on a human with a day job; see SECURITY.md for what to expect.
  • ESM-only. No CommonJS build. Requires "type": "module" (or a bundler / loader equivalent) and Node ≥ 18.
  • More concepts than a one-liner replacement. If all you need is process.env.PORT, dotenv is two lines. The complexity here pays off when you have ≥ 2 environments, ≥ 1 secret, and want CI to gate them.
  • zod as a peer dep. You ship zod whether you wanted to or not. Worth it for the validation, but worth knowing.

CLI

# CI gate — exits non-zero on validation errors
npx node-settings validate [.env.production]

# Per-env completeness check (placeholders, missing required envs, secret lint)
npx node-settings check --env prod,stage
npx node-settings check --workspace          # every package in a monorepo

# Dry-run inspection — no secrets needed
npx node-settings inspect --env=prod
npx node-settings inspect --workspace        # every package in a monorepo

# Composite gate: validate + check + inspect in one shot
npx node-settings preflight .env.production

# Drift detection: compare a live K8s ConfigMap/Secret to your schema
kubectl get cm,secret -n prod -o yaml | npx node-settings diff -

# Machine-readable output for CI dashboards / AI agents
npx node-settings validate  .env.production --format json
npx node-settings preflight .env.production --format json
npx node-settings diff      live.yaml       --format json

# Generate artifacts from the schema
npx node-settings generate env-example  --out .env.example
npx node-settings generate envs         --out-dir env-samples/
npx node-settings generate docs         --out ENV.md
npx node-settings generate k8s          --name my-app --namespace prod --out k8s.yaml
npx node-settings generate json-schema  --out env.schema.json
npx node-settings generate tfvars       --out terraform.tfvars
npx node-settings generate compose      --name web --out docker-compose.snippet.yml

Auto-discovers node-settings.config.{ts,js,...} (or settings.config.{...}) by walking up to the nearest workspace marker (.git, pnpm-workspace.yaml, turbo.json, nx.json, lerna.json, rush.json). TS configs work via jiti.

GitHub Action

# .github/workflows/ci.yml
- uses: Changsik00/node-settings@v1
  with:
    command: validate
    config: ./settings.config.ts

- uses: Changsik00/node-settings@v1
  with:
    command: check
    args: --workspace --no-allow-warnings

See action.yml for the full input list.

Build-time validation plugins

Fail the dev server / production build the moment your env is invalid, without waiting for the app to boot. All three plugins reuse the same loader your runtime code calls, so the contract that gated the build is the contract that ships.

Vite (vite.config.ts):

import { defineConfig } from "vite";
import { nodeSettings } from "@env-kit/node-settings/vite";

export default defineConfig({
  plugins: [nodeSettings()],
});

Next.js (next.config.mjs):

import { withNodeSettings } from "@env-kit/node-settings/next";

export default await withNodeSettings({
  reactStrictMode: true,
});

esbuild (build.mjs):

import { build } from "esbuild";
import { nodeSettings } from "@env-kit/node-settings/esbuild";

await build({
  entryPoints: ["src/main.ts"],
  bundle: true,
  outfile: "dist/main.js",
  plugins: [nodeSettings()],
});

vite build / next build / esbuild build always abort on validation failure. vite serve / next dev abort too unless you pass failOnDev: false; the esbuild plugin exposes failOnError for the same purpose in watch mode.

Vite, Next.js, and esbuild are optional peer deps — only projects that import the respective entry need them installed.

Server / client env split

Browser bundles must never see server-only secrets. defineClientEnv is a separate loader for the public, prefix-gated half of your env:

// settings.client.ts
import { z } from "zod";
import { defineClientEnv } from "@env-kit/node-settings";

export const clientEnv = defineClientEnv({
  prefix: "VITE_",
  schema: z.object({
    VITE_API_URL: z.string().url(),
    VITE_SENTRY_DSN: z.string().optional(),
  }),
});

// app code (browser)
const env = clientEnv(import.meta.env);
fetch(env.VITE_API_URL);

Three guarantees:

  • Prefix enforced at definition time. A schema key without the prefix throws CLIENT_ENV_PREFIX_VIOLATION immediately — mismatch is caught long before a secret reaches the bundle.
  • Server keys filtered before zod sees them. Any input key without the prefix is dropped, so clientEnv(process.env) cannot smuggle DATABASE_URL into the client.
  • Optional strict: true flags extra prefixed keys at runtime — catches typos and forgotten-to-declare drift.

Conventional prefixes: NEXT_PUBLIC_ (Next.js), VITE_ (Vite), PUBLIC_ (Astro, SvelteKit). Pair with defineSettings for the server side; the prefix is your compile-time and runtime firewall.

Error handling

Every throw is a NodeSettingsError with a stable, programmatically matchable contract. Match on .code or .severity — never on .message, which can evolve in minor versions.

import { NodeSettingsError, reportError } from "@env-kit/node-settings";

declare const log: (payload: unknown) => void;

try {
  // ... loadSettings(process.env) etc.
  throw new NodeSettingsError("ENV_VALIDATION_FAILED", "demo");
} catch (err) {
  if (err instanceof NodeSettingsError) {
    if (err.severity === "runtime") {
      // operator alarm — env is missing or wrong at boot
    } else if (err.severity === "config") {
      // developer alarm — defineSettings(...) misconfigured
    }
    console.error(`${err.title}: ${err.message}`);
    console.error(`  see ${err.docsUrl}`);
  }

  // Or hand the structured report to a logger / dashboard
  log(reportError(err));
}

reportError(err) distils any throw (NodeSettingsError, ZodError, plain Error) into a JSON-serialisable ErrorReport:

{
  code: "ENV_VALIDATION_FAILED",
  severity: "runtime",
  title: "Zod env validation failed",
  message: "env validation failed:\n  - DB_HOST: Required",
  hint: "Check that every required env var is set and matches the schema.",
  docsUrl: "https://.../docs/ERRORS.md#env_validation_failed",
  issues: [{ path: "DB_HOST", message: "Required" }],
  cause: { name: "ZodError", message: "..." },
}

Severity buckets route to the right alarm channel without hard-coding code lists:

Severity When raised Who fixes it
config defineSettings(...) / defineClientEnv(...) call time Developer (source)
runtime Loader called with a bad env at boot Operator (env)
io CLI / loader filesystem / parse failures Operator or CI
usage Library API called incorrectly Developer (source)

See docs/ERRORS.md for the complete catalog grouped by severity, with one row per stable code.

K8s drift detection

The generate k8s command writes ConfigMap + Secret YAML from your schema. node-settings diff closes the loop in the other direction: compare what's actually running in your cluster against the schema your code expects.

kubectl get cm,secret -n prod -o yaml | npx node-settings diff -

The four issue categories:

Category Severity What it catches
missing-required error Schema key is required but missing from every live manifest.
secret-in-configmap error Schema flags the key secret, but it sits in a ConfigMap (read by anyone).
public-in-secret warning Schema doesn't flag it secret, but it lives in a Secret (harmless / odd).
extra-key warning Key present in the live manifest but not declared in the schema.

Exit codes: 0 for clean / warnings-only, 1 on any error, 2 for bad input. Pass --strict to upgrade warnings into errors. --format json emits a single DiffReport document for CI dashboards.

Documentation

For users:

  • sample/ — complete worked example (env files + split-file config + settings.ts that wires everything).
  • Configuration guide — file layouts, the two "base" concepts (defaults vs extends), monorepo composition, layering model.
  • Deployment guide — setting APP_ENV on every common platform, opt-in presets.* adapters, the .env.<mode> cascade.
  • Error codes — every NodeSettingsError.code, severity, hint, and docs anchor. Auto-generated from ERROR_CATALOG.
  • Migration guides — recipes for moving from t3-oss/env, convict, node-config, or dotenv-flow.

For contributors:

  • Architecture — layering rules, file / directory conventions, ESM resolution discipline, and the nine core code patterns (factory + frozen loader, error catalog, CLI subcommand triplet, generator purity, dispatch registry, workspace runner, …).
  • Testing strategy — unit / contract / integration / e2e taxonomy, the nine-layer verify chain, coverage philosophy, mutation testing setup, decision tree for new tests.
  • CONTRIBUTING.md — dev loop, commit style, release flow.
  • AGENTS.md — deep context for AI coding assistants working in this repo.

Meta:

License

MIT © Changsik00


Built for teams that ship the same image to many environments.

About

No description, website, or topics provided.

Resources

License

Contributing

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors