@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-vaultandlocalproviders, plus your own (NPM package or local script). - 💉 Injection — load variables into
process.envand run any command. - 🔐 Azure Key Vault — pull/push secrets per environment.
- ✅ JSON Schema — auto-generate and validate variables before injecting.
- 🪆 Nested & shared keys —
GROUP__VARflattening 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
.envor 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/envRun 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 232msThe resolved environment is only rendered at
--log debug. Values are sorted, secrets are masked, and colored by type — strings in gray, numbers in orange, booleanstrue/falsein green/red.
⚙️ Commands & Options
Interpolation — any option value can reference other arguments using
[[and]]delimiters. Withroot: "config", the value[[root]]/file.jsonresolves toconfig/file.json; withenv: "dev",[[root]]/config.[[env]].jsonresolves toconfig/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 ofappsettings.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
-eunknown → 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--configFilecannot 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 buildexport
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)
- flat root object (only when no
|...|section is present) |DEFAULT||ENV|for the current--env|MODE|for each--modesentry (later modes win)appsettings.<env>.jsonappsettings.<mode>.json(one per mode, in--modesorder)|LOCAL|for the current--envappsettings.<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
.jsfile thatexport 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-jsonis the base,localwins).
💽 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.jsonand*.env.jsonout 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 -oor 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](-ooverwrites). - 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, sopull/pushround-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:
NODE_ENV=development(built-in base value)package-json(project info:ENV,NAME,VERSION, …)app-settings(appsettings.jsonsections and unitary files — see the app-settings precedence above)azure-key-vault(secrets cached in<env>.env.json)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 accessFrontend (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, autocompletedOverride pattern. Inherited keys come in as
string. To give a key a literal type (e.g.'false' | 'true'), eitherOmitit before extending and redeclare it, or just redeclare it in the interface body — a narrower type is assignable tostring, so it overrides cleanly.
Note. Because JSON imports widen
typearrays, schema keys cannot be filtered by their declared type:null-only entries stay typedstringand primitives are not narrowed. The result is the superset of keys declared across both sources. Keepschema.jsonregenerated (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.jsBy 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
\\dfor\d. Neither mechanism masks the variable name — keys are always shown. Masking only affects the output; the real (unmasked) values are still injected intoprocess.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
- yargs — CLI argument parsing
- ajv + to-json-schema — JSON Schema
- tslog — logging
- subslate — interpolation
- merge-deep — deep merge
- Vite — build · Vitest — testing
