env-trace-core

Static analysis of process.env and import.meta.env usage from TypeScript/JavaScript source. Never reads .env files.


License
MIT
Install
npm install env-trace-core@0.2.0

Documentation

env-trace

env-trace never reads .env files. It derives which environment variables a TypeScript/JavaScript codebase actually uses from static analysis of the source AST, so it works even in legacy repositories where a .env never existed and nobody remembers what the app needs to boot.

The source of truth is the code, not a populated env file. env-trace does not sync or generate .env.example by reading secrets out of .env.

Trust

env-trace never reads .env, .env.local, or any file that may contain real secret values. It only reads source code and .env.example (or .env.sample / .env.template). There is no network activity, no telemetry, and no backend.

This is enforced by an allowlist in packages/core/src/exclusions.ts — not a denylist. Any basename outside {.env.example, .env.sample, .env.template} is rejected before readFile.

Install

One-off via npx:

npx env-trace scan

Global:

npm install -g env-trace

Project dependency:

npm install --save-dev env-trace
# pnpm add -D env-trace
# yarn add -D env-trace

Cursor — .cursor/mcp.json:

{
  "mcpServers": {
    "env-trace": {
      "command": "npx",
      "args": ["-y", "env-trace-mcp"]
    }
  }
}

Claude Code (stdio servers):

claude mcp add --transport stdio env-trace -- npx -y env-trace-mcp

Claude Desktop — claude_desktop_config.json:

{
  "mcpServers": {
    "env-trace": {
      "command": "npx",
      "args": ["-y", "env-trace-mcp"]
    }
  }
}

Programmatic:

import { scanProject } from "env-trace-core";

const result = await scanProject({ cwd: process.cwd() });
console.log(result.variables.map((v) => v.name));

From this repo before the first npm publish:

pnpm install
pnpm build
node packages/cli/dist/cli.js scan
node packages/cli/dist/mcp.js

Quick start

npx env-trace diff

Lists names used in source but missing from .env.example, and names documented but not found in code. Exit code is non-zero when anything is missing from the example file.

CLI

Global flags (all commands): --cwd <path>, --json, --config <path>.

The console examples below were captured from this repo after pnpm build:

node packages/cli/dist/cli.js <command> --cwd packages/core/test/fixtures/app

Use packages/core/test/fixtures/synced for the in-sync diff case. After publish, replace the node …/cli.js prefix with npx env-trace or a global env-trace.

env-trace scan

List every detected usage (name, file, line, default, kind). Detects process.env.X, process.env["X"], destructuring from process.env, import.meta.env.VITE_* (Vite/Astro; kind meta), Zod z.object({...}).parse(process.env) (best-effort), and Nest ConfigService.get / getOrThrow with a literal key (best-effort). Vite built-ins on import.meta.env (MODE, BASE_URL, DEV, PROD, SSR) are ignored.

npx env-trace scan

env-trace scan table

┌────────────────┬────────────────────┬──────┬───────────┬─────────────┐
│ Name           │ File               │ Line │ Default   │ Kind        │
├────────────────┼────────────────────┼──────┼───────────┼─────────────┤
│ API_KEY        │ src/config.ts      │ 1    │           │ element     │
├────────────────┼────────────────────┼──────┼───────────┼─────────────┤
│ AWS_REGION     │ src/config.ts      │ 2    │ us-east-1 │ element     │
├────────────────┼────────────────────┼──────┼───────────┼─────────────┤
│ DATABASE_URL   │ src/db.ts          │ 1    │           │ property    │
├────────────────┼────────────────────┼──────┼───────────┼─────────────┤
│ LOG_LEVEL      │ src/destructure.ts │ 1    │ info      │ destructure │
├────────────────┼────────────────────┼──────┼───────────┼─────────────┤
│ REDIS_URL      │ src/destructure.ts │ 1    │           │ destructure │
├────────────────┼────────────────────┼──────┼───────────┼─────────────┤
│ SESSION_SECRET │ src/destructure.ts │ 1    │           │ destructure │
├────────────────┼────────────────────┼──────┼───────────┼─────────────┤
│ ZOD_REQUIRED   │ src/env.ts         │ 4    │           │ zod         │
├────────────────┼────────────────────┼──────┼───────────┼─────────────┤
│ ZOD_OPTIONAL   │ src/env.ts         │ 5    │ from-zod  │ zod         │
├────────────────┼────────────────────┼──────┼───────────┼─────────────┤
│ ZOD_CHAINED    │ src/env.ts         │ 12   │           │ zod         │
├────────────────┼────────────────────┼──────┼───────────┼─────────────┤
│ PORT           │ src/server.ts      │ 1    │ 3000      │ property    │
├────────────────┼────────────────────┼──────┼───────────┼─────────────┤
│ HOST           │ src/server.ts      │ 2    │ localhost │ property    │
├────────────────┼────────────────────┼──────┼───────────┼─────────────┤
│ FLAG           │ src/server.ts      │ 3    │           │ property    │
├────────────────┼────────────────────┼──────┼───────────┼─────────────┤
│ OTHER_FLAG     │ src/server.ts      │ 3    │           │ property    │
└────────────────┴────────────────────┴──────┴───────────┴─────────────┘
13 variable(s), 13 usage(s)

--json prints the ScanResult object (filePath values are absolute):

npx env-trace scan --json
{
  "usages": [
    {
      "name": "DATABASE_URL",
      "filePath": "/repo/src/index.ts",
      "line": 1,
      "hasDefault": false,
      "kind": "property"
    },
    {
      "name": "PORT",
      "filePath": "/repo/src/index.ts",
      "line": 2,
      "hasDefault": true,
      "defaultValue": "3000",
      "kind": "property"
    }
  ],
  "variables": [
    {
      "name": "DATABASE_URL",
      "usages": [
        {
          "name": "DATABASE_URL",
          "filePath": "/repo/src/index.ts",
          "line": 1,
          "hasDefault": false,
          "kind": "property"
        }
      ],
      "hasDefaultOnAllUsages": false,
      "firstSeenFile": "/repo/src/index.ts"
    },
    {
      "name": "PORT",
      "usages": [
        {
          "name": "PORT",
          "filePath": "/repo/src/index.ts",
          "line": 2,
          "hasDefault": true,
          "defaultValue": "3000",
          "kind": "property"
        }
      ],
      "hasDefaultOnAllUsages": true,
      "firstSeenFile": "/repo/src/index.ts"
    }
  ]
}

env-trace diff

Compare source usage to .env.example / .env.sample / .env.template.

Flag Meaning
--ci One-line output (ok or missing N, unused M)
--json DiffResult object

Exit code 1 if missingInExample.length > 0. Unused names in the example file are reported but do not fail the process.

npx env-trace diff

env-trace diff drift

Example file: .env.example

missingInExample (11)
  API_KEY  src/config.ts:1
  AWS_REGION  src/config.ts:2
  FLAG  src/server.ts:3
  HOST  src/server.ts:2
  LOG_LEVEL  src/destructure.ts:1
  OTHER_FLAG  src/server.ts:3
  REDIS_URL  src/destructure.ts:1
  SESSION_SECRET  src/destructure.ts:1
  ZOD_CHAINED  src/env.ts:12
  ZOD_OPTIONAL  src/env.ts:5
  ZOD_REQUIRED  src/env.ts:4

unusedInExample (1)
  DEAD_VAR

When source and the example file match:

Example file: .env.example

In sync: source usages match the example file.

--ci is meant for pipelines:

npx env-trace diff --ci
missing 11, unused 1
ok

--json prints DiffResult:

{
  "missingInExample": [
    {
      "name": "API_KEY",
      "references": [{ "filePath": "/repo/src/config.ts", "line": 1 }]
    }
  ],
  "unusedInExample": ["DEAD_VAR"],
  "exampleFile": "/repo/.env.example"
}

env-trace generate

Write .env.example from source. Refuses to overwrite unless --force. --json prints { content, fileName } (same shape as the MCP tool); the write is a CLI side effect.

npx env-trace generate

env-trace generate

Wrote .env.example

If the file already exists:

Refusing to overwrite .env.example. Pass --force to replace it.
npx env-trace generate --force
Wrote .env.example

Generated content groups names by the first file that referenced them, uses a literal default when one was found, and <REQUIRED> otherwise:

# src/index.ts
DATABASE_URL=<REQUIRED>
PORT=3000

env-trace types

Write env.d.ts. process.env usages become declare namespace NodeJS { interface ProcessEnv { ... } }. import.meta.env usages become Vite-style ImportMetaEnv / ImportMeta (with /// <reference types="vite/client" />). A mixed project gets both. A variable is optional (?:) only if every usage in source had a literal default.

npx env-trace types

env-trace types

Wrote env.d.ts
declare namespace NodeJS {
  interface ProcessEnv {
    DATABASE_URL: string;
    PORT?: string;
  }
}

Vite / import.meta.env:

/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_API_URL?: string;
  readonly VITE_APP_NAME: string;
}

interface ImportMeta {
  readonly env: ImportMetaEnv;
}

Screenshots

The README image links above expect PNG captures in docs/images/. Do not commit mock-ups — capture a real terminal (dark theme, full command + output visible).

File Capture
docs/images/scan.png scan table with cyan headers and the N variable(s), M usage(s) footer. The screenshot already taken against school-trips-backend is the right one.
docs/images/diff.png diff on packages/core/test/fixtures/app — red missingInExample list and yellow unusedInExample.
docs/images/generate.png Wrote .env.example (run generate in a directory that has no example file yet).
docs/images/types.png Wrote env.d.ts.

MCP tools

Tool Description Agent sees
scan_env_usage Full list of detected usages ScanResult JSON (usages, variables)
diff_env_example Source vs example-file drift DiffResult JSON (missingInExample with file+line, unusedInExample, exampleFile)
generate_env_example Generated example file contents { content, fileName } JSON. Does not write to disk.

--json on the CLI is the same payload as the corresponding tool. Both call toJson in env-trace-core.

Input: { "cwd": "<optional project root>" }. Defaults to CLAUDE_PROJECT_DIR, then process.cwd().

CI

name: env-trace
on: [push, pull_request]
jobs:
  diff:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v5
        with:
          node-version: "24"
      - run: npx env-trace diff --ci

The job fails when source references a variable that is not in .env.example.

Releasing this repo

CI on main and pull requests runs typecheck, ESLint, Prettier, tests, and build (see .github/workflows/ci.yml).

Publishing to npm is manual and gated:

  1. Bump "version" in packages/core/package.json and packages/cli/package.json to the same value (for example 0.1.0).
  2. Commit, then tag and push that version: git tag v0.1.0 && git push origin v0.1.0.
  3. .github/workflows/publish.yml re-runs CI, then waits on the GitHub Environment Prod.
  4. Approve the pending deployment in the Actions UI. Only then are env-trace-core and env-trace published via OIDC (@env-trace/mcp-server stays private). No NPM_TOKEN is used.

One-time GitHub / npm setup:

  1. Repo Settings → Environments → Prod. Required reviewers: your user. Uncheck Allow administrators to bypass configured protection rules (otherwise admins skip the review). Optionally restrict deployments to tags v*.
  2. On npmjs.com, add a Trusted Publisher (GitHub Actions) for each of env-trace-core and env-trace. If the package does not exist yet, add the trusted publisher for a new package name. Fields must match exactly:
    • Organization or user: mytheondev (the GitHub owner of this repo, not the npm username)
    • Repository: env-trace
    • Workflow filename: publish.yml
    • Environment name: Prod
    • Allowed actions: npm publish
  3. npm does not validate these fields when you save them. A mismatch only shows up at publish time. After a successful publish you can set Publishing access to Require 2FA and disallow tokens and delete the NPM_TOKEN secret from Prod if it is still there.

Config

Optional env-trace.config.json in the project root:

{
  "include": ["**/*.{ts,tsx,js,jsx,mjs,cjs,mts,cts}"],
  "exclude": [],
  "exampleFile": ".env.example"
}
Field Default Notes
include **/*.{ts,tsx,js,jsx,mjs,cjs,mts,cts} Source globs, relative to --cwd
exclude [] Additional globs. Cannot re-enable node_modules, dist, build, .next, .turbo, *.d.ts, or other hardcoded exclusions.
exampleFile .env.example Must be .env.example, .env.sample, or .env.template. Any other name is rejected.

Hardcoded scan exclusions (not configurable): node_modules/, dist/, build/, .next/, .turbo/, out/, coverage/, .output/, .nuxt/, .svelte-kit/, .vercel/, .cache/, .git/, and *.d.ts. Implemented in the scanner itself — tsconfig include/exclude is ignored.

What this tool deliberately does not do

  • Does not read .env, .env.local, .env.development, .env.production, or .env.*.local.
  • Does not scrub secrets or detect provider key patterns. It will not turn a populated .env into a scrubbed .env.example.
  • Does not support languages other than TypeScript/JavaScript.
  • Does not watch files, call the network, or phone home.

Roadmap (not in this release)

  • VS Code extension with inline diagnostics
  • Multi-repo / org-level scan via GitHub API
  • Opt-in comparison of names only (never values) against real .env.* files — needs its own security design; not built speculatively