Reference

Env Changelog

Version history for @goenvless/env, the package your application imports to load its variables.

Version history for @goenvless/env, the runtime package your application imports. The envless CLI ships separately as @goenvless/cli. Pin it in your projects for the latest features, security patches, and performance improvements.

This is not the SDK. The API client is @goenvless/sdk, a separate package with its own changelog, and its Python and Ruby twins, both envless-sdk, and its PHP twin, envless/sdk, have their own: Python, Ruby and PHP. The Python and Ruby runtimes, both envless-env, have their own too: Python and Ruby, and so does the PHP runtime, envless/env.

The runtime tells you when a newer release is out. The first time a process loads its environment, @goenvless/env asks the npm registry for the latest published version in the background. When that is newer than the installed one, it prints a single line and carries on:

text
ℹ @goenvless/env 0.0.5 is available, you are on 0.0.4. https://envless.cloud/docs/reference/changelogs-runtime.
  • It never delays or fails a load. The request runs alongside it, gives up after 2 seconds, and any failure, being offline included, is ignored
  • It only runs when stdout is a terminal, so CI, containers, production logs and edge runtimes never make the request
  • It runs once per process, even when several entrypoints load, for example @goenvless/env/register preloaded and load() from @goenvless/env/server awaited later
  • The request is a plain GET to registry.npmjs.org and carries nothing about your project, your variables or your machine key
  • Set ENVLESS_DISABLE_UPDATE_NOTICE to any non-empty value to turn it off

Object plumbing names are reserved on the env proxy. Every name Object.prototype owns (toString, valueOf, constructor, hasOwnProperty, isPrototypeOf, propertyIsEnumerable, toLocaleString, __proto__, the __defineGetter__ family), plus then and toJSON, is now treated as plumbing rather than as a variable, and all four traps agree on it: get, has, ownKeys and getOwnPropertyDescriptor.

The env proxy

  • A variable named toJSON can no longer stand in for the serialisation guard. The guard used to fire only when the store held no name called toJSON, so a variable actually named toJSON made env.toJSON return that variable's value instead of throwing. A string is not callable, so JSON.stringify(env) fell back to enumerating the proxy and copied every decrypted secret into the output. toJSON is now answered before the store is consulted, so the root and /server proxies always throw and /client always returns its filtered snapshot
  • Spreading an env that holds such a name no longer throws, and on /client it no longer copies the snapshot function onto the plain object. 'toJSON' in env is false, and Object.keys(env), spread, Object.entries and the /client snapshot all skip these names
  • The store still keeps the value. The plain record load() resolves to still carries it, and it is still written into process.env like any other variable. Only reading it off the proxy is unavailable, so rename the variable if your code needs env.<NAME>
  • Previously only then, toString, valueOf and constructor were treated as plumbing, and only when the store did not hold a variable of that name

Freezing the env is refused like every other mutation

  • Object.freeze(env) and Object.seal(env) now throw Envless env is read-only. through a new preventExtensions trap
  • Without that trap, Object.freeze made the underlying target non-extensible first, and only then did the ownKeys invariant reject the variable names the trap reports. The TypeError arrived after the damage was done, and because the target belongs to the exported singleton, enumeration of that env stayed broken for the rest of the process. Refusing the operation up front leaves the proxy intact

Unsafe names are matched without regard to case

  • The loader and the injected seed now compare UNSAFE_PROCESS_ENV_NAMES in lower case, since the Windows process environment ignores case, so path, Node_Options and ld_preload are refused exactly like PATH, NODE_OPTIONS and LD_PRELOAD
  • As before, a refused name is still returned by load() and still readable through env, it is only kept out of process.env. In an injected environment the refused name is left out of the seed entirely

Client names that are not JavaScript identifiers are skipped when inlining

  • @goenvless/env/vite and @goenvless/env/next both inline a variable by writing its name into your source (import.meta.env.<NAME> and Next's config.env), so a name that is not a valid identifier produces broken code. Client-visibility names now have to match ^[A-Za-z_][A-Za-z0-9_]*$ to be inlined, and the rest are skipped with a warning listing them
  • The ENVLESS_CLIENT_ENV hydration payload still carries the skipped names, so env from @goenvless/env/client reads them by name. It is import.meta.env and process.env in the browser that will not see them. Rename them using letters, numbers and underscores, and do not start with a number

A wrapped process adopts the environment it already holds instead of fetching it again. When envless run or envless build starts your process, the CLI writes an ENVLESS_INJECTED manifest alongside the variables. The runtime now seeds its store from that manifest the first time anything touches it, so env reads work with no load() and no token, and load() resolves to those values rather than going to the network. Before this, a wrapped process threw the not-loaded error and load() fetched with a machine token it should not have needed.

Adopting an injected environment

  • The manifest is a JSON object of { "<NAME>": { "t": type, "v": visibility } }. The values themselves come from process.env, so the manifest carries metadata only and never a secret. Each variable keeps its configured type and visibility, which is what makes the server and client split enforceable at build time: a wrapped Vite or Next build can use the plugin with no token and no second round trip, and only variables Envless marks client reach the bundle
  • An entry whose name is not present in process.env is skipped, and an entry with a missing or unrecognised t / v falls back to string and server visibility
  • Names that change how the process executes code are refused even when the manifest lists them, on the same list the loader uses
  • ENVLESS_INJECTED itself joins that list, which grows from 14 names to 15, so a stored variable can never forge a manifest by being written into process.env under that name
  • A malformed manifest (not JSON, not an object, an array, or one that yields no usable entry) leaves the store empty rather than throwing, and loading falls back to fetching as usual
  • load({ revalidate: true }) still bypasses the adopted values and fetches

New optional view

  • @goenvless/env/server and @goenvless/env/client both export optional alongside env. optional.X returns undefined when the name is not set, where env.X throws Missing env var: X. Use env for something the app cannot run without, and optional for code that tests whether an integration is configured, the reading process.env gave for free
  • It is the same proxy with the same rules otherwise: values are still coerced by their configured type, and the /client view still exposes client-visibility variables only, so a server-only name still throws there
  • It still throws the not-loaded error before anything is loaded, so a misconfigured process fails loudly instead of reading undefined everywhere
  • It is typed as Partial<EnvlessServerEnv> and Partial<EnvlessClientEnv>, so your generated names come back with | undefined. The root @goenvless/env entry does not export it, and env there is unchanged

Initial public release. @goenvless/env ships the application runtime, and the envless CLI ships separately as @goenvless/cli, a binary-only package with no importable module. The runtime fetches your ciphertext from the API and decrypts it locally (zero-knowledge, the server never sees your key), then populates process.env without clobbering existing ambient variables and without placing names that would change how code executes.

Nine entrypoints ship: ., /server, /client, /next, /vite, /eslint, /crypto, /edge and /register.

Root: @goenvless/env (six exports)

  • env: a proxy over your loaded variables, unfiltered and not narrowed by the generated types (those views live on /server and /client)
  • load(opts?): asynchronously fetch + decrypt + populate process.env. Takes { revalidate? } and resolves to a plain record
  • encrypt(plaintext, passphrase, workspaceUniqueId) / decrypt(ciphertext, passphrase, workspaceUniqueId): Web Crypto helpers. encrypt writes the v1:<base64(iv || ciphertext)> envelope, decrypt reads that and the cost-pinned v2:<iterations>:<base64(iv || ciphertext)> form
  • passphraseStrength(passphrase): returns { valid, error } against the same floor the dashboard and CLI enforce (16 characters minimum with 3 character classes, or 24 characters with 2, no run of 4 repeated or sequential characters, no dominant weak fragment)
  • ENCRYPTION_PARAMETERS: the cryptographic constants (PBKDF2 200000 / SHA-256 → AES-GCM-256, salt namespace, version prefix) for byte-for-byte interop with non-JS implementations
  • This entry reaches node:fs through the loader and cache, so it needs a filesystem. For crypto alone use /crypto, for a runtime with no filesystem use /edge

Subpath entrypoints

  • @goenvless/env/server: env (all variables, coerced by their configured type), load({ revalidate? }), and __override(values) (returns a using-disposable that restores the previous values, handy in tests)
  • @goenvless/env/client: env exposing only client-visibility variables. Accessing a server-only variable throws. It hydrates synchronously from the payload the Next or Vite plugin injects (ENVLESS_CLIENT_ENV / __ENVLESS_CLIENT_ENV__), so there is no load() to await in the browser
  • @goenvless/env/register: side-effect module that eagerly runs the loader via top-level await. Use node --import @goenvless/env/register app.js or a Bun preload
  • @goenvless/env/next: withEnvless(nextConfig?) returns an async Next config function that awaits load() (filling process.env for server/build) and inlines client-visibility variables into config.env. Before it hands the config back it removes every NEXT_PUBLIC_ name Envless marks server visibility from process.env and warns with the list, because Next inlines any NEXT_PUBLIC_ name it finds into client chunks whatever Envless says about it. Flag those client in Envless if they belong in the browser, or rename them
  • @goenvless/env/vite: envless() returns a Vite plugin that awaits load() and defines import.meta.env.<NAME> for client-visibility variables only. It runs the same strip and the same warning against the project's envPrefix, defaulting to VITE_ when none is declared
  • @goenvless/env/eslint: ESLint plugin named @goenvless/env, with the @goenvless/env/no-server-import-in-client rule (errors when a client file imports @goenvless/env/server) and a configs.recommended preset. clientGlobs are matched against both the absolute path and the path relative to the working directory, so ordinary project-relative globs like src/client/** work
  • @goenvless/env/crypto: the crypto surface on its own, pure Web Crypto with no Node built-ins and no Buffer, so it runs in a browser, a service worker or an edge runtime. Exports encrypt, decrypt, encryptWithKey(key, plaintext), decryptWithKey(key, ciphertext), workspaceKey(passphrase, workspaceUniqueId), importWorkspaceKey(base64urlKey), passphraseStrength and ENCRYPTION_PARAMETERS
  • @goenvless/env/edge: load(options) for runtimes with no filesystem, also the default export. Takes { token, project, environment, product?, passphrase? | key?, workspaceId?, endpoint?, fetch? } and returns a plain record. It never writes process.env, keeps no cache and reads no .envless file, so everything it needs is passed in. With a passphrase and no workspaceId it resolves the workspace over the API first, since the salt is derived from it

Loading model

  • Reads ENVLESS_TOKEN (an ev_sk_ API key) plus ENVLESS_KEY (base64url raw AES-256 key) or ENVLESS_PASSPHRASE, plus ENVLESS_PRODUCT / ENVLESS_PROJECT / ENVLESS_ENV slugs (falling back to a local .envless file for product/project/env). ENVLESS_PRODUCT is optional and only needed when the project slug exists in more than one product of the workspace
  • Loading is async, so use /register (eager preload) or await load() before reading env. /register is the only entrypoint that loads on its own, importing the root or /server does not
  • The on-disk ciphertext cache lives under ~/.envless/cache (directories 0700, files 0600) and is keyed by workspace + product + project + environment, so two projects sharing a slug in different products never read each other's entry. A cached entry is reused for 15 minutes, and load({ revalidate: true }) skips it
  • load() is memoised for the life of the process. A second call returns the same promise, so nothing is fetched or decrypted twice, and only load({ revalidate: true }) starts a fresh one. A load that throws drops the memo, so the next call retries rather than replaying the failure
  • Loaded values populate process.env without overwriting variables already present in the ambient environment. The loader remembers the names it wrote, so a revalidating load updates its own and deletes the ones that have since disappeared, while an ambient name it never owned is left exactly as it was
  • Names that change how a process or its children execute code are decrypted but never written into process.env. All 14 are refused with a warning that lists them: NODE_OPTIONS, NODE_PATH, NODE_REPL_EXTERNAL_MODULE, ELECTRON_RUN_AS_NODE, LD_PRELOAD, LD_LIBRARY_PATH, LD_AUDIT, DYLD_INSERT_LIBRARIES, DYLD_LIBRARY_PATH, DYLD_FRAMEWORK_PATH, PATH, BUN_INSPECT, BUN_INSPECT_CONNECT_TO and the ENVLESS_CLIENT_ENV hydration channel. They are still returned by load() and still readable through env, and envless run / envless build refuse the same 14 when they build a child environment

The env proxy

  • Serialising a server env throws. JSON.stringify(env) on the root or /server is refused, because it would copy every decrypted secret straight into the output. Read the names you need, or use the plain record load() already resolved to. The /client proxy still serialises, since it is filtered to client-visibility variables and those are inlined into the bundle anyway
  • The proxy is read-only. Assigning to it, deleting a name off it, redefining a property or reprototyping it all throw. __override(values) from /server is the sanctioned way to layer temporary values, and it is scoped and disposable rather than a mutation
  • Enumeration follows the same rules as reading, so Object.keys(env) and in on the /client proxy show client-visibility names only

Typed codegen

  • Run envless types (from @goenvless/cli) to emit envless-env.d.ts, augmenting NodeJS.ProcessEnv, @goenvless/env/server's EnvlessServerEnv, and @goenvless/env/client's EnvlessClientEnv so env is fully typed per variable
  • EnvlessServerEnv / EnvlessClientEnv ship empty, so a name that is not in your generated types is a compile error rather than silently typed. Runtime access is unaffected, this is a type-level check only, and it means envless types is what turns on typing

Runtime

  • Pure Web Crypto API, so no native dependencies, no WASM and no polyfill on Node 20+ / Bun / Deno / modern browsers
  • /client, /crypto and /edge are the browser-safe entries, asserted at build time to carry no node: import and no Buffer. The root, /server, /next, /vite and /register all reach node:fs through the loader and cache, so they cannot run on an edge runtime
  • require('@goenvless/env/register') throws by design, because a CJS require cannot block and would hand control back before a single variable is loaded. Preload the ESM entry with node --import @goenvless/env/register, or await load() from the root
  • Same encryption recipe used by the CLI and dashboard, so values encrypted here decrypt identically everywhere else