dotenv-diff 2.0.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -42,6 +42,27 @@ dotenv-diff --scan-usage
42
42
  ```
43
43
  This scans your entire codebase to detect which environment variables are actually used in the code — and compare them against your `.env` file(s).
44
44
 
45
+ ## CI/CD integration with `--ci` option
46
+ You can scan and compare against specific environment file, eg. `.env.example`
47
+ This is useful for CI/CD pipelines to ensure that the environment variables used in your code match those defined in your `.env.example` file.
48
+
49
+ Use the `--ci` flag for automated environments. This enables strict mode where the tool exits with code 1 on any issues, making it perfect for CI/CD pipelines.
50
+
51
+ And the `--example` option allows you to specify which `.env.example` file to compare against.
52
+
53
+ ### Use it in Github Actions:
54
+
55
+ ```yaml
56
+ - name: Check environment variables
57
+ run: dotenv-diff --scan-usage --example .env.example --ci
58
+ ```
59
+
60
+ You can also change the comparison file by using the `--example` flag to point to a different `.env.example` file.
61
+
62
+ ```bash
63
+ dotenv-diff --scan-usage --example .env.example.staging --ci
64
+ ```
65
+
45
66
  ## Show unused variables
46
67
 
47
68
  Use `--show-unused` together with `--scan-usage` to list variables that are defined in `.env` but never used in your codebase:
@@ -59,13 +80,22 @@ This will display statistics about the scan, such as the number of files scanned
59
80
 
60
81
  ## include or exclude specific files for scanning
61
82
 
62
- You can specify which files to include or exclude from the scan using the `--include-files` and `--exclude-files` options:
83
+ You can specify which files to include or exclude from the scan using the `--include` and `--exclude` options:
63
84
 
64
85
  ```bash
65
- dotenv-diff --scan-usage --include-files '**/*.js,**/*.ts' --exclude-files '**/*.spec.ts'
86
+ dotenv-diff --scan-usage --include '**/*.js,**/*.ts' --exclude '**/*.spec.ts'
66
87
  ```
67
88
 
68
- This allows you to focus the scan on specific file types or directories, making it more efficient and tailored to your project structure.
89
+ By default, the scanner looks at JavaScript, TypeScript, Vue, and Svelte files.
90
+ The --include and --exclude options let you refine this list to focus on specific file types or directories.
91
+
92
+ ### Override with `--files`
93
+
94
+ If you want to completely override the default include/exclude logic (for example, to also include test files), you can use --files:
95
+ ```bash
96
+ dotenv-diff --scan-usage --files '**/*.js'
97
+ ```
98
+ This will only scan the files matching the given patterns, even if they would normally be excluded.
69
99
 
70
100
  ## Optional: Check values too
71
101
 
@@ -14,7 +14,8 @@ export function createProgram() {
14
14
  .option('--json', 'Output results in JSON format')
15
15
  .option('--only <list>', 'Comma-separated categories to only run (missing,extra,empty,mismatch,duplicate,gitignore)')
16
16
  .option('--scan-usage', 'Scan codebase for environment variable usage')
17
- .option('--include-files <patterns>', '[requires --scan-usage] Comma-separated file patterns to include in scan (default: **/*.{js,ts,jsx,tsx,vue,svelte})')
17
+ .option('--include-files <patterns>', '[requires --scan-usage] Comma-separated file patterns to ADD to default scan patterns (extends default)')
18
+ .option('--files <patterns>', '[requires --scan-usage] Comma-separated file patterns to scan (completely replaces default patterns)')
18
19
  .option('--exclude-files <patterns>', '[requires --scan-usage] Comma-separated file patterns to exclude from scan')
19
20
  .option('--show-unused', '[requires --scan-usage] Show variables defined in .env but not used in code')
20
21
  .option('--show-stats', 'Show statistics (in scan-usage mode: scan stats, in compare mode: env compare stats)');
@@ -19,10 +19,13 @@ export async function run(program) {
19
19
  exclude: opts.excludeFiles,
20
20
  ignore: opts.ignore,
21
21
  ignoreRegex: opts.ignoreRegex,
22
+ examplePath: opts.exampleFlag || undefined,
22
23
  envPath,
23
24
  json: opts.json,
24
25
  showUnused: opts.showUnused,
25
26
  showStats: opts.showStats,
27
+ isCiMode: opts.isCiMode,
28
+ files: opts.files,
26
29
  });
27
30
  process.exit(exitWithError ? 1 : 0);
28
31
  }
@@ -1,9 +1,12 @@
1
1
  import { type ScanOptions } from '../services/codeBaseScanner.js';
2
2
  export interface ScanUsageOptions extends ScanOptions {
3
3
  envPath?: string;
4
+ examplePath?: string;
4
5
  json: boolean;
5
6
  showUnused: boolean;
6
7
  showStats: boolean;
8
+ isCiMode?: boolean;
9
+ files?: string[];
7
10
  }
8
11
  export interface ScanJsonEntry {
9
12
  stats: {
@@ -28,14 +31,18 @@ export interface ScanJsonEntry {
28
31
  pattern: string;
29
32
  context: string;
30
33
  }>;
34
+ comparedAgainst?: string;
35
+ totalEnvVariables?: number;
31
36
  }
32
37
  /**
33
38
  * Scans codebase for environment variable usage and compares with .env file
34
39
  * @param {ScanUsageOptions} opts - Scan configuration options
35
40
  * @param {string} [opts.envPath] - Path to .env file for comparison
41
+ * @param {string} [opts.examplePath] - Path to .env.example file for comparison
36
42
  * @param {boolean} opts.json - Output as JSON instead of console
37
43
  * @param {boolean} opts.showUnused - Show unused variables from .env
38
44
  * @param {boolean} opts.showStats - Show detailed statistics
45
+ * @param {boolean} [opts.isCiMode] - Run in CI mode (exit with error code)
39
46
  * @returns {Promise<{exitWithError: boolean}>} Returns true if missing variables found
40
47
  */
41
48
  export declare function scanUsage(opts: ScanUsageOptions): Promise<{
@@ -1,14 +1,19 @@
1
1
  import chalk from 'chalk';
2
+ import fs from 'fs';
3
+ import path from 'path';
2
4
  import { parseEnvFile } from '../lib/parseEnv.js';
3
5
  import { scanCodebase, compareWithEnvFiles, } from '../services/codeBaseScanner.js';
4
6
  import { filterIgnoredKeys } from '../core/filterIgnoredKeys.js';
7
+ const resolveFromCwd = (cwd, p) => path.isAbsolute(p) ? p : path.resolve(cwd, p);
5
8
  /**
6
9
  * Scans codebase for environment variable usage and compares with .env file
7
10
  * @param {ScanUsageOptions} opts - Scan configuration options
8
11
  * @param {string} [opts.envPath] - Path to .env file for comparison
12
+ * @param {string} [opts.examplePath] - Path to .env.example file for comparison
9
13
  * @param {boolean} opts.json - Output as JSON instead of console
10
14
  * @param {boolean} opts.showUnused - Show unused variables from .env
11
15
  * @param {boolean} opts.showStats - Show detailed statistics
16
+ * @param {boolean} [opts.isCiMode] - Run in CI mode (exit with error code)
12
17
  * @returns {Promise<{exitWithError: boolean}>} Returns true if missing variables found
13
18
  */
14
19
  export async function scanUsage(opts) {
@@ -18,31 +23,84 @@ export async function scanUsage(opts) {
18
23
  }
19
24
  // Scan the codebase
20
25
  let scanResult = await scanCodebase(opts);
21
- // If we have an env file, compare with it
26
+ // If user explicitly passed --example but the file doesn't exist:
27
+ if (opts.examplePath) {
28
+ const exampleAbs = resolveFromCwd(opts.cwd, opts.examplePath);
29
+ const missing = !fs.existsSync(exampleAbs);
30
+ if (missing) {
31
+ const msg = `❌ Missing specified example file: ${opts.examplePath}`;
32
+ if (opts.isCiMode) {
33
+ // IMPORTANT: stdout (console.log), not stderr, to satisfy the test
34
+ console.log(chalk.red(msg));
35
+ return { exitWithError: true };
36
+ }
37
+ else if (!opts.json) {
38
+ console.log(chalk.yellow(msg.replace('❌', '⚠️')));
39
+ }
40
+ // Non-CI: continue without comparison
41
+ }
42
+ }
43
+ // Determine which file to compare against
44
+ const compareFile = determineComparisonFile(opts);
22
45
  let envVariables = {};
23
- if (opts.envPath) {
46
+ let comparedAgainst = '';
47
+ if (compareFile) {
24
48
  try {
25
- const envFull = parseEnvFile(opts.envPath);
49
+ const envFull = parseEnvFile(compareFile.path);
26
50
  const envKeys = filterIgnoredKeys(Object.keys(envFull), opts.ignore, opts.ignoreRegex);
27
51
  envVariables = Object.fromEntries(envKeys.map((k) => [k, envFull[k]]));
28
52
  scanResult = compareWithEnvFiles(scanResult, envVariables);
53
+ comparedAgainst = compareFile.name;
29
54
  }
30
55
  catch (error) {
56
+ const errorMessage = `⚠️ Could not read ${compareFile.name}: ${compareFile.path} - ${error}`;
57
+ if (opts.isCiMode) {
58
+ // In CI mode, exit with error if file doesn't exist
59
+ console.log(chalk.red(`❌ ${errorMessage}`));
60
+ return { exitWithError: true };
61
+ }
31
62
  if (!opts.json) {
32
- console.log(chalk.yellow(`⚠️ Could not read env file: ${opts.envPath} - ${error}`));
63
+ console.log(chalk.yellow(errorMessage));
33
64
  }
34
65
  }
35
66
  }
36
67
  // Prepare JSON output
37
68
  if (opts.json) {
38
- const jsonOutput = createJsonOutput(scanResult, opts);
69
+ const jsonOutput = createJsonOutput(scanResult, opts, comparedAgainst, Object.keys(envVariables).length);
39
70
  console.log(JSON.stringify(jsonOutput, null, 2));
40
71
  return { exitWithError: scanResult.missing.length > 0 };
41
72
  }
42
73
  // Console output
43
- return outputToConsole(scanResult, opts);
74
+ return outputToConsole(scanResult, opts, comparedAgainst);
75
+ }
76
+ /**
77
+ * Determines which file to use for comparison based on provided options
78
+ */
79
+ function determineComparisonFile(opts) {
80
+ // Priority: explicit flags first, then auto-discovery
81
+ if (opts.examplePath) {
82
+ const p = resolveFromCwd(opts.cwd, opts.examplePath);
83
+ if (fs.existsSync(p)) {
84
+ return { path: p, name: path.basename(opts.examplePath) };
85
+ }
86
+ }
87
+ if (opts.envPath) {
88
+ const p = resolveFromCwd(opts.cwd, opts.envPath);
89
+ if (fs.existsSync(p)) {
90
+ return { path: p, name: path.basename(opts.envPath) };
91
+ }
92
+ }
93
+ // Auto-discovery: look for common env files relative to cwd
94
+ const candidates = ['.env', '.env.example', '.env.local', '.env.production'];
95
+ for (const candidate of candidates) {
96
+ const fullPath = path.resolve(opts.cwd, candidate);
97
+ if (fs.existsSync(fullPath)) {
98
+ return { path: fullPath, name: candidate };
99
+ }
100
+ }
101
+ return null;
44
102
  }
45
- function createJsonOutput(scanResult, opts) {
103
+ function createJsonOutput(scanResult, opts, comparedAgainst, totalEnvVariables) {
46
104
  // Group usages by variable for missing variables
47
105
  const missingGrouped = scanResult.missing.map((variable) => ({
48
106
  variable,
@@ -60,6 +118,11 @@ function createJsonOutput(scanResult, opts) {
60
118
  missing: missingGrouped,
61
119
  unused: scanResult.unused,
62
120
  };
121
+ // Add comparison info if we compared against a file
122
+ if (comparedAgainst) {
123
+ output.comparedAgainst = comparedAgainst;
124
+ output.totalEnvVariables = totalEnvVariables;
125
+ }
63
126
  // Optionally include all usages
64
127
  if (opts.showStats) {
65
128
  output.allUsages = scanResult.used.map((u) => ({
@@ -72,8 +135,13 @@ function createJsonOutput(scanResult, opts) {
72
135
  }
73
136
  return output;
74
137
  }
75
- function outputToConsole(scanResult, opts) {
138
+ function outputToConsole(scanResult, opts, comparedAgainst) {
76
139
  let exitWithError = false;
140
+ // Show what we're comparing against
141
+ if (comparedAgainst) {
142
+ console.log(chalk.gray(`📋 Comparing codebase usage against: ${comparedAgainst}`));
143
+ console.log();
144
+ }
77
145
  // Show stats if requested
78
146
  if (opts.showStats) {
79
147
  console.log(chalk.bold('📊 Scan Statistics:'));
@@ -82,8 +150,8 @@ function outputToConsole(scanResult, opts) {
82
150
  console.log(chalk.gray(` Unique variables: ${scanResult.stats.uniqueVariables}`));
83
151
  console.log();
84
152
  }
85
- // Always show found variables when not in .env comparison mode
86
- if (!opts.envPath || scanResult.missing.length === 0) {
153
+ // Always show found variables when not comparing or when no missing variables
154
+ if (!comparedAgainst || scanResult.missing.length === 0) {
87
155
  console.log(chalk.green(`✅ Found ${scanResult.stats.uniqueVariables} unique environment variables in use`));
88
156
  console.log();
89
157
  // List all variables found (if any)
@@ -113,10 +181,11 @@ function outputToConsole(scanResult, opts) {
113
181
  console.log();
114
182
  }
115
183
  }
116
- // Missing variables (used in code but not in .env)
184
+ // Missing variables (used in code but not in env file)
117
185
  if (scanResult.missing.length > 0) {
118
186
  exitWithError = true;
119
- console.log(chalk.red('❌ Missing in .env:'));
187
+ const fileType = comparedAgainst || 'environment file';
188
+ console.log(chalk.red(`❌ Missing in ${fileType}:`));
120
189
  const grouped = scanResult.missing.reduce((acc, variable) => {
121
190
  const usages = scanResult.used.filter((u) => u.variable === variable);
122
191
  acc[variable] = usages;
@@ -134,21 +203,29 @@ function outputToConsole(scanResult, opts) {
134
203
  }
135
204
  }
136
205
  console.log();
206
+ // CI mode specific message
207
+ if (opts.isCiMode) {
208
+ console.log(chalk.red(`💥 Found ${scanResult.missing.length} missing environment variable(s).`));
209
+ console.log(chalk.red(` Add these variables to ${comparedAgainst || 'your environment file'} to fix this error.`));
210
+ console.log();
211
+ }
137
212
  }
138
- // Unused variables (in .env but not used in code)
213
+ // Unused variables (in env file but not used in code)
139
214
  if (opts.showUnused && scanResult.unused.length > 0) {
140
- console.log(chalk.yellow('⚠️ Unused in codebase:'));
215
+ const fileType = comparedAgainst || 'environment file';
216
+ console.log(chalk.yellow(`⚠️ Unused in codebase (defined in ${fileType}):`));
141
217
  scanResult.unused.forEach((variable) => {
142
218
  console.log(chalk.yellow(` - ${variable}`));
143
219
  });
144
220
  console.log();
145
221
  }
146
- // Success message for .env comparison
147
- if (opts.envPath && scanResult.missing.length === 0) {
148
- console.log(chalk.green('✅ All used environment variables are defined in .env'));
222
+ // Success message for env file comparison
223
+ if (comparedAgainst && scanResult.missing.length === 0) {
224
+ console.log(chalk.green(`✅ All used environment variables are defined in ${comparedAgainst}`));
149
225
  if (opts.showUnused && scanResult.unused.length === 0) {
150
226
  console.log(chalk.green('✅ No unused environment variables found'));
151
227
  }
228
+ console.log();
152
229
  }
153
230
  return { exitWithError };
154
231
  }
@@ -30,6 +30,7 @@ export function normalizeOptions(raw) {
30
30
  const excludeFiles = parseList(raw.excludeFiles);
31
31
  const showUnused = Boolean(raw.showUnused);
32
32
  const showStats = Boolean(raw.showStats);
33
+ const files = parseList(raw.files);
33
34
  const ignore = parseList(raw.ignore);
34
35
  const ignoreRegex = [];
35
36
  for (const pattern of parseList(raw.ignoreRegex)) {
@@ -64,5 +65,6 @@ export function normalizeOptions(raw) {
64
65
  excludeFiles,
65
66
  showUnused,
66
67
  showStats,
68
+ files,
67
69
  };
68
70
  }
@@ -17,6 +17,7 @@ export type Options = {
17
17
  excludeFiles: string[];
18
18
  showUnused: boolean;
19
19
  showStats: boolean;
20
+ files?: string[];
20
21
  };
21
22
  export type RawOptions = {
22
23
  checkValues?: boolean;
@@ -34,6 +35,7 @@ export type RawOptions = {
34
35
  excludeFiles?: string | string[];
35
36
  showUnused?: boolean;
36
37
  showStats?: boolean;
38
+ files?: string | string[];
37
39
  };
38
40
  export type CompareJsonEntry = {
39
41
  env: string;
@@ -12,6 +12,7 @@ export interface ScanOptions {
12
12
  exclude: string[];
13
13
  ignore: string[];
14
14
  ignoreRegex: RegExp[];
15
+ files?: string[];
15
16
  }
16
17
  export interface ScanResult {
17
18
  used: EnvUsage[];
@@ -1,5 +1,6 @@
1
1
  import fs from 'fs/promises';
2
2
  import path from 'path';
3
+ import fsSync from 'fs';
3
4
  // Framework-specific patterns for finding environment variable usage
4
5
  const ENV_PATTERNS = [
5
6
  {
@@ -67,6 +68,7 @@ export async function scanCodebase(opts) {
67
68
  const files = await findFiles(opts.cwd, {
68
69
  include: opts.include,
69
70
  exclude: [...DEFAULT_EXCLUDE_PATTERNS, ...opts.exclude],
71
+ files: opts.files, // Pass files option
70
72
  });
71
73
  const allUsages = [];
72
74
  let filesScanned = 0;
@@ -97,14 +99,117 @@ export async function scanCodebase(opts) {
97
99
  },
98
100
  };
99
101
  }
102
+ function expandBraceSets(pattern) {
103
+ // Single-level brace expansion: **/*.{js,ts,svelte} -> [**/*.js, **/*.ts, **/*.svelte]
104
+ const m = pattern.match(/\{([^}]+)\}/);
105
+ if (!m)
106
+ return [pattern];
107
+ const variants = m[1]
108
+ .split(',')
109
+ .map((p) => p.trim())
110
+ .filter(Boolean);
111
+ const prefix = pattern.slice(0, m.index);
112
+ const suffix = pattern.slice(m.index + m[0].length);
113
+ return variants.flatMap((v) => expandBraceSets(`${prefix}${v}${suffix}`));
114
+ }
100
115
  /**
101
116
  * Recursively finds all files in the given directory matching the include patterns,
102
117
  * while excluding files and directories that match the exclude patterns.
103
118
  * @param rootDir The root directory to start searching from.
104
- * @param opts Options for include and exclude patterns.
119
+ * @param opts Options for include, exclude patterns and files override.
105
120
  * @returns A promise that resolves to an array of file paths.
106
121
  */
107
122
  async function findFiles(rootDir, opts) {
123
+ // If --files provided, keep existing replacement behavior
124
+ if (opts.files && opts.files.length > 0) {
125
+ return findFilesByPatterns(rootDir, opts.files);
126
+ }
127
+ const defaultPatterns = getDefaultPatterns();
128
+ const rawInclude = opts.include.length > 0
129
+ ? [...defaultPatterns, ...opts.include]
130
+ : defaultPatterns;
131
+ const includePatterns = rawInclude.flatMap(expandBraceSets);
132
+ const files = [];
133
+ const walked = new Set();
134
+ // Compute additional roots for include patterns that point outside cwd or are absolute
135
+ const extraRoots = new Set();
136
+ for (const p of includePatterns) {
137
+ const hasParentEscape = p.includes('..') || path.isAbsolute(p);
138
+ if (!hasParentEscape)
139
+ continue;
140
+ const dir = getPatternBaseDir(rootDir, p);
141
+ if (dir && !dir.startsWith(rootDir)) {
142
+ extraRoots.add(dir);
143
+ }
144
+ }
145
+ async function walk(startDir) {
146
+ // prevent duplicate subtree walks
147
+ const key = path.resolve(startDir);
148
+ if (walked.has(key))
149
+ return;
150
+ walked.add(key);
151
+ let entries;
152
+ try {
153
+ entries = await fs.readdir(startDir, { withFileTypes: true });
154
+ }
155
+ catch {
156
+ return;
157
+ }
158
+ for (const entry of entries) {
159
+ const fullPath = path.join(startDir, entry.name);
160
+ const relativeToRoot = path.relative(rootDir, fullPath);
161
+ // Exclude checks should use path relative to *rootDir* (keeps existing semantics)
162
+ if (shouldExclude(entry.name, relativeToRoot, [
163
+ ...DEFAULT_EXCLUDE_PATTERNS,
164
+ ...opts.exclude,
165
+ ])) {
166
+ continue;
167
+ }
168
+ if (entry.isDirectory()) {
169
+ await walk(fullPath);
170
+ }
171
+ else if (entry.isFile() &&
172
+ shouldInclude(entry.name, relativeToRoot, includePatterns)) {
173
+ files.push(fullPath);
174
+ }
175
+ }
176
+ }
177
+ // Walk root first (current behavior)
178
+ await walk(rootDir);
179
+ // Walk any extra roots (e.g., ../../packages/…)
180
+ for (const r of extraRoots) {
181
+ await walk(r);
182
+ }
183
+ return files;
184
+ }
185
+ function getPatternBaseDir(rootDir, pattern) {
186
+ // Stop at first glob char — DO NOT include '/' here
187
+ const idx = pattern.search(/[*?\[\]{}]/); // <— removed '/'
188
+ const raw = idx === -1 ? pattern : pattern.slice(0, idx);
189
+ const base = path.isAbsolute(raw) ? raw : path.resolve(rootDir, raw);
190
+ try {
191
+ const st = fsSync.statSync(base);
192
+ if (st.isDirectory())
193
+ return base;
194
+ if (st.isFile())
195
+ return path.dirname(base);
196
+ }
197
+ catch {
198
+ const dir = path.dirname(base);
199
+ try {
200
+ const st2 = fsSync.statSync(dir);
201
+ if (st2.isDirectory())
202
+ return dir;
203
+ }
204
+ catch { }
205
+ }
206
+ return null;
207
+ }
208
+ /**
209
+ * Find files using the --files patterns (complete replacement mode)
210
+ */
211
+ async function findFilesByPatterns(rootDir, patterns) {
212
+ const expanded = patterns.flatMap(expandBraceSets);
108
213
  const files = [];
109
214
  async function walk(currentDir) {
110
215
  let entries;
@@ -112,21 +217,16 @@ async function findFiles(rootDir, opts) {
112
217
  entries = await fs.readdir(currentDir, { withFileTypes: true });
113
218
  }
114
219
  catch {
115
- // Skip directories we can't read (permissions, etc.)
116
220
  return;
117
221
  }
118
222
  for (const entry of entries) {
119
223
  const fullPath = path.join(currentDir, entry.name);
120
224
  const relativePath = path.relative(rootDir, fullPath);
121
- // Check if should be excluded (directory or file)
122
- if (shouldExclude(entry.name, relativePath, opts.exclude)) {
123
- continue;
124
- }
125
225
  if (entry.isDirectory()) {
126
226
  await walk(fullPath);
127
227
  }
128
228
  else if (entry.isFile() &&
129
- shouldInclude(entry.name, relativePath, opts.include)) {
229
+ shouldInclude(entry.name, relativePath, expanded)) {
130
230
  files.push(fullPath);
131
231
  }
132
232
  }
@@ -134,6 +234,12 @@ async function findFiles(rootDir, opts) {
134
234
  await walk(rootDir);
135
235
  return files;
136
236
  }
237
+ /**
238
+ * Generate default patterns from extensions
239
+ */
240
+ function getDefaultPatterns() {
241
+ return DEFAULT_INCLUDE_EXTENSIONS.map((ext) => `**/*${ext}`);
242
+ }
137
243
  /**
138
244
  * Check if a file should be included based on its name, path, and include patterns.
139
245
  * @param fileName The name of the file.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotenv-diff",
3
- "version": "2.0.0",
3
+ "version": "2.1.1",
4
4
  "type": "module",
5
5
  "description": "A CLI tool to find differences between .env and .env.example / .env.* files. And optionally scan your codebase to find out which environment variables are actually used in your code.",
6
6
  "bin": {