npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@achs/env

v5.6.0

Published

Extensible environment variables handler for NodeJS apps

Readme

📖 About

@achs/env eases environment variable handling for NodeJS apps — like env-cmd or dotenv, but with powerful, extensible features: pluggable providers to load, pull and push variables from different stores, JSON Schema validation, value interpolation, secret masking and nested/shared keys.

✨ Features

  • 🧩 Provider plugins — bundled package-json, app-settings, azure-key-vault and local providers, plus your own (NPM package or local script).
  • 💉 Injection — load variables into process.env and run any command.
  • 🔐 Azure Key Vault — pull/push secrets per environment.
  • JSON Schema — auto-generate and validate variables before injecting.
  • 🪆 Nested & shared keysGROUP__VAR flattening and $-prefixed global keys (the $ marker is stripped on injection).
  • 🧵 Interpolation — reference other args/vars with [[ ]] delimiters.
  • 🙈 Masking — hide secrets in the debug output by key name, key regex, or value content regex.
  • 🎨 Pretty output — colorized, sorted, masked debug render of the resolved environment.
  • 📤 Export — write the unified environment to a .env or JSON file.

📌 Requirements

NodeJS 20 or higher.

> node -v
v20.0.0

📦 ESM only

Since v5 this package is ESM-only ("type": "module"). Consumers must use ESM import syntax, and custom providers must be ESM modules that export their provider object.

// ✅ ESM
import { EnvProvider } from '@achs/env';

// ❌ CommonJS require is not supported
// const { EnvProvider } = require('@achs/env');

⚡️ Quick start

Install the package:

> npm install @achs/env

Run the binary directly:

> npx env --help

  Usage: env [command] [options..] [: subcmd [:]] [options..]

  Commands:
    env [options..] [: <subcmd> :]
    env pull [options..]
    env push [options..]
    env schema [options..]
    env export [options..]

Or wire it into your npm scripts:

{
	"scripts": {
		// inject "dev" variables (debug mode) and start the app
		"start:dev": "env -e dev -m debug : node dist/main.js",
		// inject "prod" variables for the build
		"build:prod": "env -e prod -m build : tsc",
		// regenerate the validation schema
		"env:schema": "env schema -e dev",
		// pull / push secrets for the "dev" environment
		"env:pull:dev": "env pull -e dev",
		"env:push:dev": "env push -e dev",
	},
}

Run it:

> npm run start:dev

  ⚡ env v5.0.0  ·  🌎 dev  ·  🧩 debug

  📦  package-json     6 vars
  🗂️  app-settings     4 vars
  🔐  azure-key-vault  2 secrets

  environment (10 variables)
    ARR1         = 1,val,true
    ENV          = dev
    GROUP1__VAR1 = G1V2
    NAME         = @my-app
    NODE_ENV     = development
    SECRET       = *****
    VAR1         = true
    VERSION      = 5.0.0

  ✓ 10 variables loaded in 198ms

  ▶ node dist/main.js

  My environment loaded is: dev
  ✓ finished in 232ms

The resolved environment is only rendered at --log debug. Values are sorted, secrets are masked, and colored by type — strings in gray, numbers in orange, booleans true/false in green/red.

⚙️ Commands & Options

Interpolation — any option value can reference other arguments using [[ and ]] delimiters. With root: "config", the value [[root]]/file.json resolves to config/file.json; with env: "dev", [[root]]/config.[[env]].json resolves to config/config.dev.json.

Global options

| Option | Description | Type | Default | | ---------------------------------- | ------------------------------------------------- | ---------- | ------- | | --help | Show help | boolean | | | -e, --env | Environment to load (i.e. dev, prod) | string | | | -m, --modes | Execution modes (i.e. debug, test) | string[] | [] | | --nd, --nestingDelimiter | Nesting-level delimiter for flatten | string | __ | | --arrDesc, --arrayDescomposition | Serialize (false) or break down (true) arrays | boolean | false | | -x, --expand | Interpolate environment variables using itself | boolean | false | | --ci | Continuous Integration mode (skips local files) | boolean | auto |

Workspace options

| Option | Description | Type | Default | | ---------------------- | --------------------------------- | -------- | --------------------------------- | | --root | Base environment folder path | string | env | | -c, --configFile | Config JSON file path | string | [[root]]/settings/settings.json | | -s, --schemaFile | Environment schema JSON file path | string | [[root]]/settings/schema.json | | --pkg, --packageJson | package.json path | string | cwd |

JSON Schema options

| Option | Description | Type | Default | | ---------------------- | ---------------------------------------------- | ------------------ | ------- | | -r, --resolve | Merge new schema or override it | merge/override | merge | | --null, --nullable | Whether variables are nullable by default | boolean | true | | --df, --detectFormat | Include string formats in the generated schema | boolean | false |

Logger options

| Option | Description | Type | Default | | ------------------------------ | ------------------------------------------------------ | --------------------------------------------- | ------- | | --log, --logLevel | Log level | silly/trace/debug/info/warn/error | info | | --mvk, --logMaskValuesOfKeys | Mask a value when its key matches (exact or regex) | string[] | [] | | --mrx, --logMaskAnyRegEx | Mask value content matching a regex (every match) | string[] | [] |

See 🙈 Masking secrets for the full masking semantics.

🔮 Environment inference from npm scripts

When -e is not provided (neither by CLI nor config file), the CLI infers the environment from the npm script name (npm_lifecycle_event), taking the last segment after : — running npm run start:dev infers dev, so the -e flag becomes redundant in per-env scripts:

{
	"scripts": {
		"start:dev": "env -m debug : node dist/main.js", // infers "dev"
		"start:qa": "env -m debug : node dist/main.js", // infers "qa"
		"start:prod": "env -m debug : node dist/main.js", // infers "prod"
	},
}

The inferred value is validated against the environments defined in the workspace, discovered dynamically as the union of:

  • the |ENV| and |LOCAL| section keys of appsettings.json,
  • per-env provider files in the root folder (appsettings.<env>.json, appsettings.<env>.local.json, <env>.env.json, <env>.local.env.json).

Validation rules:

  • inferred env unknown → the command aborts listing the defined environments (catches typos like start:prd);
  • explicit -e unknown → only a warning;
  • no defined environments → inference is skipped silently;
  • scripts without a : suffix (i.e. preview) and direct CLI invocations are unaffected.

Scripts whose suffix is not an environment (i.e. test:mutation, env:schema) must keep an explicit -e.

[[env]] inside --configFile cannot reference an inferred environment — the config file is loaded before inference runs.


env

Inject environment variables into process.env and execute a command (the command goes after :).

env -e <env> [options..] [: <subcmd> :] [options..]
> env -e dev -m test unit : npm test
> env -e dev -m debug : npm start : -c [[root]]/[[env]].env.json
> env -e prod -m build optimize : npm run build

| Option | Description | Type | Default | | ------------------------------ | ------------------------------------------ | --------- | ------- | | --validate, --schemaValidate | Validate variables against the JSON schema | boolean | true |

pull

Pull environment variables from the providers' stores.

env pull -e <env> [options..]

| Option | Description | Type | Default | | ----------------- | ------------------------- | --------- | ------- | | -o, --overwrite | Overwrite local variables | boolean | false |

push

Push environment variables to the providers' stores.

env push -e <env> [options..]

| Option | Description | Type | Default | | ------------- | ---------------------- | --------- | ------- | | -f, --force | Force push for secrets | boolean | false |

schema

Generate (or update) the validation schema from the providers' output.

> env schema -e dev -m build

export

Export the unified environment to a file.

env export -e <env> -m <modes> [options..]

| Option | Description | Type | Default | | --------------- | --------------------- | --------------- | -------- | | -u, -p, --uri | Output file path | string | .env | | -f, --format | Output format | dotenv/json | dotenv | | -q, --quotes | Wrap values in quotes | boolean | false |

> env export -e dev -m build -f json --uri [[env]].env.json

📡 Providers

Providers are the core of this library. It ships with four integrated providers, and you can add your own.

package-json

Loads project info from your package.json (version, project, name, title, description) into ENV, VERSION, PROJECT, NAME, TITLE, DESCRIPTION.

| Option | Description | Type | Default | | --------------------------- | ------------------------------- | -------- | ------- | | --vp, --packageInfoPrefix | Prefix for the loaded variables | string | "" |

# i.e. expose them as REACT_APP_* for CRA
> env -e dev -m build : react-scripts build : --vp REACT_APP_

app-settings

Non-secret loader for appsettings.json, organized by sections:

{
	"|DEFAULT|": { "VAR1": "v1_default" },
	"|ENV|": {
		"dev": {
			"C1": "V1",
			"GROUP1": { "VAR2": "G1V2", "GROUP2": { "VAR1": "G1G2V1" } },
		},
	},
	"|MODE|": {
		"build": { "NODE_ENV": "production" },
		"debug": { "NODE_ENV": "development" },
	},
	"|LOCAL|": { "dev": { "LOCAL_VAR": "only-local" } },
}

It also merges the unitary files appsettings.<env>.json, appsettings.<mode>.json and appsettings.<env>.local.json.

Precedence (lowest → highest)

  1. flat root object (only when no |...| section is present)
  2. |DEFAULT|
  3. |ENV| for the current --env
  4. |MODE| for each --modes entry (later modes win)
  5. appsettings.<env>.json
  6. appsettings.<mode>.json (one per mode, in --modes order)
  7. |LOCAL| for the current --env
  8. appsettings.<env>.local.json

Local layers (7 and 8) always win, and both are skipped in --ci.

| Option | Description | Type | Default | | ----------------- | ----------------------------- | -------- | --------------------------- | | --ef, --envFile | Non-secret settings file path | string | [[root]]/appsettings.json |

local

Loads local-only variables (never loaded in --ci). The file is auto-created if missing.

| Option | Description | Type | Default | | ------------------- | ------------------------- | -------- | --------------------------------- | | --lf, --localFile | Local variables file path | string | [[root]]/[[env]].local.env.json |

azure-key-vault

Loads secrets from an Azure Key Vault store into [[root]]/[[env]].env.json per environment (see the Azure Key Vault section below).

| Option | Description | Type | Default | | ---------------------------------------- | --------------------------------- | ---------- | ---------------------------------------- | | --secretsFile | Secret variables file path | string | [[root]]/[[env]].env.json | | -k, --keys, --keysFile | SPN credentials file paths | string[] | ['[[root]]/keys.json', '../keys.json'] | | --url, --vaultUrl | Azure Key Vault URL | string | | | --id, --clientId, --spn | SPN Client ID | string | | | -p, --pass, --clientSecret, --password | SPN Client Secret | string | | | -t, --tenant | Azure Tenant ID | string | | | --mock | Mock the Key Vault client (tests) | boolean | false | | --dns, --skipDnsCheck | Skip the DNS reachability check | boolean | false |

✒️ Creating custom providers

Create a provider in two ways:

  • Local script — a .js file that export defaults your provider.
  • NPM package — a published module that export defaults your provider.

Both are wired in the config file via the providers list.

import type { CommandArguments, EnvProvider } from '@achs/env';
import { logger, readJson, writeJson } from '@achs/env/utils';

const KEY = 'my-unique-provider-key';

interface MyProviderArguments extends CommandArguments {
	anyExtraOption: boolean;
}

const MyProvider: EnvProvider<MyProviderArguments> = {
	// unique identifier
	key: KEY,

	// (optional) add custom options to the CLI via yargs
	builder: (builder) => {
		builder.options({
			anyExtraOption: {
				group: KEY,
				alias: ['a', 'aeo'],
				type: 'boolean',
				default: false,
				describe: 'Any option description',
			},
		});
	},

	// called on load — may be sync or async, and may return a list to merge
	load: ({ env }) => {
		if (env === 'dev') return { NODE_ENV: 'development' };

		return [{ NODE_ENV: 'production' }, { ANY_GROUP: { INNER_VAR: 12 } }];
	},

	// (optional) called on `env pull`
	pull: (argv, config) => {
		/* fetch variables into your local cache */
	},

	// (optional) called on `env push`
	push: (argv, config) => {
		/* publish/update your variables */
	},
};

export default MyProvider;

📥 Config

Any CLI argument can be set in your config file ([[root]]/settings/settings.json by default), but it is mainly used to declare providers:

{
	"logLevel": "silly",
	// mask secrets in the debug output (see the Masking section)
	"logMaskValuesOfKeys": ["SECRET", "/token/i", "/api_key/i"],
	"logMaskAnyRegEx": ["AKIA[0-9A-Z]{16}"],
	"providers": [
		{ "path": "package-json" },
		{ "path": "app-settings" },
		{
			"path": "azure-key-vault",
			"config": {
				"dev": {
					"vaultUrl": "https://kv-dev-myproject.vault.azure.net",
				},
				"qa": { "vaultUrl": "https://kv-qa-myproject.vault.azure.net" },
			},
		},
		{ "path": "local" },
		// custom NPM package
		{ "path": "@my-scope/my-provider", "type": "module", "config": {} },
		// custom local script
		{ "path": "scripts/custom-loader.js", "type": "script" },
	],
}

Provider order matters — providers are merged in declaration order, so later providers override earlier ones (package-json is the base, local wins).

💽 Azure Key Vault

Store and retrieve your secrets from Azure Key Vault, grouped by your name and project from package.json.

Keys file (env/keys.json)

SPN credentials per environment, used to bootstrap the first pull:

{
	"dev": {
		"vaultUrl": "<azure-key-vault-url>", // optional if set in settings config
		"clientId": "<spn-client-id>",
		"clientSecret": "<spn-client-secret>",
		"tenantId": "<tenant-id>",
	},
}

⚠️ Keep keys.json and *.env.json out of version control — they hold secrets.

Credentials via environment variables

Alternatively, provide credentials through env vars (they take precedence):

AZURE_VAULT_URL=<azure-key-vault-url> \
AZURE_CLIENT_ID=<spn-client-id> \
AZURE_CLIENT_SECRET=<spn-client-secret> \
AZURE_TENANT_ID=<tenant-id> \
npx env pull -e dev -o

or as CLI flags:

npx env pull -e dev -o \
  --vaultUrl <azure-key-vault-url> \
  --spn <spn-client-id> \
  --password <spn-client-secret> \
  --tenant <tenant-id>

Workflow

  • Pull secrets to your local file: env pull -e <env> [-o] (-o overwrites).
  • Push local secrets to the vault: env push -e <env>.

Pushing a new variable updates the schema automatically. To stop loading a variable without deleting it from the vault, remove it from the schema file.

🪆 Nested & global keys

Organize variables in nested objects. They are flattened into process.env using the nesting delimiter (__ by default):

{
	"GROUP1": {
		"VAR": "anyValue1",
		"GROUP2": { "VAR": "anyValue2" },
	},
	"VAR3": "anyValue3",
}
process.env.GROUP1__VAR; // "anyValue1"
process.env.GROUP1__GROUP2__VAR; // "anyValue2"
process.env.VAR3; // "anyValue3"

$ global marker

Prefix a key with $ to mark it as global/shared — relevant for the Azure Key Vault provider, which uses the marker to scope the secret across the project rather than per-mode. The marker is stripped on injection at any nesting depth, while the group prefix is kept:

{
	"$TOKEN": "rootValue",
	"GROUP1": {
		"$SHARED": "groupValue",
		"VAR": "anyValue",
	},
}
// the `$` is removed; the nesting prefix is preserved
process.env.TOKEN; // "rootValue"        (was $TOKEN)
process.env.GROUP1__SHARED; // "groupValue"  (was GROUP1.$SHARED)
process.env.GROUP1__VAR; // "anyValue"

The $ is removed only from the process environment and from JSON Schema validation keys. It is preserved in the secrets file and in Key Vault storage, so pull/push round-trip the global marker intact.

Skip a key entirely (never injected) by prefixing it with #.

Priority (lowest → highest)

Providers are merged in declaration order — with the default providers list:

  1. NODE_ENV=development (built-in base value)
  2. package-json (project info: ENV, NAME, VERSION, …)
  3. app-settings (appsettings.json sections and unitary files — see the app-settings precedence above)
  4. azure-key-vault (secrets cached in <env>.env.json)
  5. local (<env>.local.env.json, skipped in --ci — overrides everything)

The resolved variables are written into the child process process.env, so they override any variables inherited from the parent shell.

🔮 Type-safe env variables (TypeScript)

The library exports a flat interface type of your variables — { KEY: string }, purely at compile time, no code generation. It is not bound to process.env; you plug it into whatever env interface you use (Node's ProcessEnv, Vite's ImportMetaEnv, …) and override individual keys with literal types as you like.

EnvVars<TAppsettings, TSchema> derives the flat keys from two sources combined:

  • appsettings.json — every section/env/mode, mirroring the runtime flattening (__ nesting, $ stripped, # skipped).
  • schema.json — the generated schema, which already merges all providers, so it also covers the unitary files and the secrets from <env>.env.json.

Their key sets are unioned (every declared variable is defined), and every leaf is typed string (the loader injects via String(...)). Variants:

  • FlatEnv<typeof appsettings> — appsettings only. Narrow to a single env/mode: FlatEnv<typeof appsettings, 'dev', 'debug'>.
  • SchemaEnv<typeof schema> — schema only.
  • EnvName<typeof appsettings> — literal union of env names deduced from the |ENV| and |LOCAL| sections (e.g. 'dev' | 'qa' | 'prod').
  • ModeName<typeof appsettings> — literal union of mode names deduced from the |MODE| section (e.g. 'build' | 'debug' | 'test').

Both helpers need "resolveJsonModule": true, and schema.json must be generated via env schema.

Backend — NodeJS.ProcessEnv

// env.d.ts
import type appsettings from './env/appsettings.json';
import type schema from './env/settings/schema.json';
import type { EnvName, EnvVars, ModeName } from '@achs/env';

declare global {
	namespace NodeJS {
		// inherit every flat key as `string`, override the ones you want
		interface ProcessEnv extends Readonly<
			Omit<EnvVars<typeof appsettings, typeof schema>, 'ENV' | 'NODE_ENV'>
		> {
			// deduced from the appsettings sections
			readonly ENV: EnvName<typeof appsettings>; // 'dev' | 'qa' | 'prod' ...
			readonly NODE_ENV: ModeName<typeof appsettings>; // 'build' | 'debug' ...
			readonly SWAGGER_UI?: 'false' | 'true';
		}
	}
}

export {};
process.env.SECRET; // ✅ readonly string — secret from dev.env.json
process.env.ENV; // ✅ 'dev' | 'qa' | 'prod' — deduced from |ENV|/|LOCAL|
process.env.NODE_ENV; // ✅ 'build' | 'debug' | 'test' — deduced from |MODE|
process.env.TYPOED_NAME; // ✅ flagged as a likely-undefined access

Frontend (Vite) — ImportMetaEnv

// vite-env.d.ts
import type appsettings from './env/appsettings.json';
import type { EnvName, FlatEnv, ModeName } from '@achs/env';

interface ImportMetaEnv extends FlatEnv<typeof appsettings> {
	readonly APP_ENV: EnvName<typeof appsettings>; // deduced from |ENV|/|LOCAL|
	readonly MODE: ModeName<typeof appsettings>; // deduced from |MODE|
	readonly APP_MOCK_SERVER: 'false' | 'true';
}

interface ImportMeta {
	readonly env: ImportMetaEnv;
}
import.meta.env.APP_ENV; // ✅ 'dev' | 'qa' | 'prod'
import.meta.env.BASE_URL; // ✅ string, autocompleted

Override pattern. Inherited keys come in as string. To give a key a literal type (e.g. 'false' | 'true'), either Omit it before extending and redeclare it, or just redeclare it in the interface body — a narrower type is assignable to string, so it overrides cleanly.

Note. Because JSON imports widen type arrays, schema keys cannot be filtered by their declared type: null-only entries stay typed string and primitives are not narrowed. The result is the superset of keys declared across both sources. Keep schema.json regenerated (env schema) so the types stay in sync.

🙈 Masking secrets

When the resolved environment is rendered (at --log debug) and when objects are logged, secrets are masked as *****. There are two complementary mechanisms, configurable via CLI flags or the config file:

By key — logMaskValuesOfKeys (--mvk)

Masks a variable's whole value when its key matches. Each entry is either an exact key name (case-insensitive) or a /source/flags regex matched against the key:

{
	"logMaskValuesOfKeys": [
		"PASSWORD", // exact key (case-insensitive)
		"/token/i", // any key containing "token"
		"/_secret$/i", // any key ending in "_secret"
	],
}
# same, via CLI flag
> env -e dev --log debug --mvk PASSWORD "/token/i" : node app.js

By value content — logMaskAnyRegEx (--mrx)

Masks only the matching portion of any string value, wherever it appears. The global flag is always forced, so every occurrence is masked; use the /source/flags form for extra flags such as i:

{
	"logMaskAnyRegEx": [
		"AKIA[0-9A-Z]{16}", // AWS access key ids
		"/bearer .+/i", // bearer tokens (case-insensitive)
	],
}

| Goal | Use | | --------------------------------------------------- | --------------------- | | Know the key of the secret | logMaskValuesOfKeys | | Know the shape of the secret (token, key, RUT…) | logMaskAnyRegEx |

In JSON the backslash must be escaped: write \\d for \d. Neither mechanism masks the variable name — keys are always shown. Masking only affects the output; the real (unmasked) values are still injected into process.env.

🧰 Development scripts

| Script | Description | | -------------------- | ------------------------------------------------- | | pnpm build | Build the library (Vite, ESM) into dist/ | | pnpm test | Run unit tests (Vitest) | | pnpm test:cov | Run unit tests with coverage (100% threshold) | | pnpm test:int | Run integration tests against the built binary | | pnpm typecheck | Type-check with tsc --noEmit | | pnpm lint | Lint with ESLint (flat config) | | pnpm format | Format with Prettier | | pnpm run pub | Gate + build + publish to npm (latest) | | pnpm run pub:alpha | Gate + build + publish a prerelease (alpha tag) |

🛠️ Built with