Skip to content

Commit 32b1876

Browse files
authored
feat(cli): --explain <key> feature (#419)
* feat(cli): --explain <key> feature * chore: fixed ignored ui * chore: updated readme and docs * chore: jsdocs * chore: added ignore URLS * chore: updated comment
1 parent a957e7f commit 32b1876

14 files changed

Lines changed: 961 additions & 1 deletion

File tree

.changeset/odd-vans-glow.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'dotenv-diff': minor
3+
---
4+
5+
feat: --explain <key> to show a detailed breakdown of a single environment varaible

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,18 @@ API_TOKEN=
9898

9999
---
100100

101+
## Explain a variable (`--explain`)
102+
103+
Inspect a specific environment variable to see where it is defined, where it is used in the codebase, and its overall status:
104+
105+
```bash
106+
dotenv-diff --explain DATABASE_URL
107+
```
108+
109+
→ See [--explain Documentation](./docs/configuration_and_flags.md#--explain-key) for more details.
110+
111+
---
112+
101113
## Monorepo support
102114

103115
In monorepos with multiple apps and packages, you can include shared folders:

docs/configuration_and_flags.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ CLI flags always take precedence over configuration file values.
2929
### Display Options
3030

3131
- [--list-all](#--list-all)
32+
- [--explain](#--explain-key)
3233
- [--show-unused](#--show-unused)
3334
- [--no-show-unused](#--no-show-unused)
3435
- [--show-stats](#--show-stats)
@@ -465,6 +466,42 @@ Usage in the configuration file:
465466

466467
---
467468

469+
### `--explain <key>`
470+
471+
Shows a detailed breakdown of a single environment variable: where it is defined in env files, where it is used in the codebase, and its overall status.
472+
473+
This is useful for debugging a specific variable — for example when you want to confirm it is defined, find all usage sites, or understand why it is flagged as missing, unused, or ignored.
474+
475+
Example usage:
476+
477+
```bash
478+
dotenv-diff --explain DATABASE_URL
479+
```
480+
481+
The output shows:
482+
483+
- **Key** — the variable name
484+
- **Status** — one of: `ok`, `missing`, `unused`, `ignored`, or `duplicated`
485+
- **Defined in** — which env files the key appears in (e.g. `.env`, `.env.example`)
486+
- **Used in** — file paths and line numbers where the key is referenced in the codebase
487+
- **Checks** — a checklist of whether the key is defined, used, duplicated, and/or ignored
488+
489+
Use `--json` together with `--explain` to get the result as structured JSON:
490+
491+
```bash
492+
dotenv-diff --explain DATABASE_URL --json
493+
```
494+
495+
Usage in the configuration file:
496+
497+
```json
498+
{
499+
"explain": "DATABASE_URL"
500+
}
501+
```
502+
503+
---
504+
468505
### `--show-unused`
469506

470507
List variables that are defined in `.env` but not used in the codebase (enabled by default).

packages/cli/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,18 @@ API_TOKEN=
9898

9999
---
100100

101+
## Explain a variable (`--explain`)
102+
103+
Inspect a specific environment variable to see where it is defined, where it is used in the codebase, and its overall status:
104+
105+
```bash
106+
dotenv-diff --explain DATABASE_URL
107+
```
108+
109+
→ See [--explain Documentation](https://github.com/Chrilleweb/dotenv-diff/blob/main/docs/configuration_and_flags.md#--explain-key) for more details.
110+
111+
---
112+
101113
## Monorepo support
102114

103115
In monorepos with multiple apps and packages, you can include shared folders:

packages/cli/src/cli/program.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,5 +96,9 @@ export function createProgram() {
9696
'--list-all',
9797
'List all unique environment variable keys found in codebase',
9898
)
99+
.option(
100+
'--explain <key>',
101+
'Show where a specific key is defined, used, and its status',
102+
)
99103
);
100104
}

packages/cli/src/cli/run.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
ExitResult,
1515
} from '../config/types.js';
1616
import { scanUsage } from '../commands/scanUsage.js';
17+
import { explainKey } from '../commands/explain.js';
1718
import { printErrorNotFound } from '../ui/compare/printErrorNotFound.js';
1819
import { setupGlobalConfig } from '../ui/shared/setupGlobalConfig.js';
1920
import { loadConfig } from '../config/loadConfig.js';
@@ -49,6 +50,23 @@ export async function run(program: Command): Promise<void> {
4950

5051
setupGlobalConfig(opts);
5152

53+
// Handle --explain flag
54+
if (opts.explain) {
55+
await explainKey({
56+
key: opts.explain,
57+
cwd: opts.cwd,
58+
include: opts.includeFiles,
59+
exclude: opts.excludeFiles,
60+
ignore: opts.ignore,
61+
ignoreRegex: opts.ignoreRegex,
62+
files: opts.files,
63+
secrets: opts.secrets,
64+
ignoreUrls: opts.ignoreUrls,
65+
json: opts.json,
66+
});
67+
process.exit(0);
68+
}
69+
5270
// Route to appropriate command and handle exit
5371
const exitWithError = opts.compare
5472
? await runCompareMode(opts)
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import fs from 'fs';
2+
import path from 'path';
3+
import { scanCodebase } from '../services/scanCodebase.js';
4+
import { parseEnvFile } from '../services/parseEnvFile.js';
5+
import { findDuplicateKeys } from '../core/duplicates.js';
6+
import type { ScanOptions } from '../config/types.js';
7+
import { DEFAULT_ENV_FILE, DEFAULT_EXAMPLE_FILE } from '../config/constants.js';
8+
import { printExplain, type ExplainResult } from '../ui/scan/printExplain.js';
9+
import { skipCommentedUsages } from '../core/helpers/skipCommentedUsages.js';
10+
11+
/**
12+
* Options forwarded from the CLI for the --explain command.
13+
*/
14+
export interface ExplainOptions extends ScanOptions {
15+
key: string;
16+
}
17+
18+
/**
19+
* Implements `dotenv-diff --explain <KEY>`.
20+
*
21+
* Reports where the key is defined in env files, where it is used in the
22+
* codebase, and its overall status (defined / used / duplicated / ignored).
23+
* @param opts Explain options from CLI
24+
* @returns void
25+
*/
26+
export async function explainKey(opts: ExplainOptions): Promise<void> {
27+
const { key, cwd, ignore, ignoreRegex } = opts;
28+
29+
// Find env files that contain the key
30+
const envFiles = discoverEnvFilesSync(cwd);
31+
const definedIn: string[] = [];
32+
const isDuplicated = envFiles.some((filePath) => {
33+
const dups = findDuplicateKeys(filePath);
34+
return dups.some((d) => d.key === key);
35+
});
36+
37+
for (const filePath of envFiles) {
38+
const parsed = parseEnvFile(filePath);
39+
if (Object.prototype.hasOwnProperty.call(parsed, key)) {
40+
definedIn.push(path.relative(cwd, filePath));
41+
}
42+
}
43+
44+
// Scan codebase for usages
45+
const scanResult = await scanCodebase(opts);
46+
47+
// Filter out commented usages
48+
const filteredUsages = skipCommentedUsages(scanResult.used);
49+
const usages = filteredUsages.filter((u) => u.variable === key);
50+
51+
// Check ignore status
52+
const isIgnored =
53+
ignore.includes(key) || ignoreRegex.some((rx) => rx.test(key));
54+
55+
// Print result
56+
const result: ExplainResult = {
57+
key,
58+
definedIn,
59+
usages,
60+
isDuplicated,
61+
isIgnored,
62+
};
63+
64+
if (opts.json) {
65+
console.log(JSON.stringify(result, null, 2));
66+
} else {
67+
printExplain(result);
68+
}
69+
}
70+
71+
/**
72+
* Returns absolute paths to all .env* files in the cwd
73+
* (both env files and example files).
74+
* @param cwd Directory to search in
75+
* @returns Array of absolute file paths
76+
*/
77+
function discoverEnvFilesSync(cwd: string): string[] {
78+
let entries: string[] = [];
79+
try {
80+
entries = fs.readdirSync(cwd);
81+
} catch {
82+
return [];
83+
}
84+
85+
return entries
86+
.filter(
87+
(f) =>
88+
f.startsWith(DEFAULT_ENV_FILE) || f.startsWith(DEFAULT_EXAMPLE_FILE),
89+
)
90+
.map((f) => path.resolve(cwd, f));
91+
}

packages/cli/src/config/options.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ export function normalizeOptions(raw: RawOptions): Options {
5757
const expireWarnings = raw.expireWarnings !== false;
5858
const inconsistentNamingWarnings = raw.inconsistentNamingWarnings !== false;
5959
const listAll = toBool(raw.listAll);
60+
const explain =
61+
typeof raw.explain === 'string' ? raw.explain.trim() : undefined;
6062

6163
const cwd = process.cwd();
6264
const envFlag =
@@ -98,6 +100,7 @@ export function normalizeOptions(raw: RawOptions): Options {
98100
expireWarnings,
99101
inconsistentNamingWarnings,
100102
listAll,
103+
explain,
101104
};
102105
}
103106

packages/cli/src/config/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ export interface RawOptions {
9494
expireWarnings?: boolean;
9595
inconsistentNamingWarnings?: boolean;
9696
listAll?: boolean;
97+
explain?: string;
9798
}
9899

99100
/**
@@ -137,6 +138,7 @@ export interface Options {
137138
expireWarnings: boolean;
138139
inconsistentNamingWarnings: boolean;
139140
listAll: boolean;
141+
explain: string | undefined;
140142
}
141143

142144
export type EnvPatternName = 'process.env' | 'import.meta.env' | 'sveltekit';

0 commit comments

Comments
 (0)