envprobe 1.0.0

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/src/cli.js ADDED
@@ -0,0 +1,606 @@
1
+ import { readFileSync } from 'fs';
2
+ import { fileURLToPath } from 'url';
3
+ import { dirname, join } from 'path';
4
+ import { validateGlobPattern } from './ignore.js';
5
+ import { loadConfig, mergeConfig } from './config.js';
6
+ import { Spinner } from './progress.js';
7
+ import { generateSuggestions, findSimilarVariables } from './suggestions.js';
8
+ import { startWatchMode } from './watch.js';
9
+
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = dirname(__filename);
12
+
13
+ /**
14
+ * Parse command-line arguments into a structured options object
15
+ * @param {string[]} args - Command-line arguments (typically process.argv.slice(2))
16
+ * @returns {CLIOptions} Parsed options object
17
+ * @throws {Error} If arguments are invalid
18
+ */
19
+ export function parseArguments(args) {
20
+ const options = {
21
+ path: '.',
22
+ envFile: '.env.example',
23
+ format: 'text',
24
+ failOn: 'none',
25
+ ignore: [],
26
+ noColor: false,
27
+ quiet: false,
28
+ version: false,
29
+ help: false,
30
+ watch: false,
31
+ suggestions: true,
32
+ progress: true,
33
+ config: null,
34
+ fix: false,
35
+ };
36
+
37
+ let i = 0;
38
+ while (i < args.length) {
39
+ const arg = args[i];
40
+
41
+ // Handle flags
42
+ if (arg === '--help' || arg === '-h') {
43
+ options.help = true;
44
+ i++;
45
+ } else if (arg === '--version' || arg === '-v') {
46
+ options.version = true;
47
+ i++;
48
+ } else if (arg === '--no-color') {
49
+ options.noColor = true;
50
+ i++;
51
+ } else if (arg === '--quiet' || arg === '-q') {
52
+ options.quiet = true;
53
+ i++;
54
+ } else if (arg === '--watch' || arg === '-w') {
55
+ options.watch = true;
56
+ i++;
57
+ } else if (arg === '--no-suggestions') {
58
+ options.suggestions = false;
59
+ i++;
60
+ } else if (arg === '--no-progress') {
61
+ options.progress = false;
62
+ i++;
63
+ } else if (arg === '--fix') {
64
+ options.fix = true;
65
+ i++;
66
+ } else if (arg === '--config' || arg === '-c') {
67
+ if (i + 1 >= args.length) {
68
+ throw new Error('--config requires a value');
69
+ }
70
+ options.config = args[i + 1];
71
+ i += 2;
72
+ } else if (arg === '--env-file') {
73
+ if (i + 1 >= args.length) {
74
+ throw new Error('--env-file requires a value');
75
+ }
76
+ options.envFile = args[i + 1];
77
+ i += 2;
78
+ } else if (arg === '--format' || arg === '-f') {
79
+ if (i + 1 >= args.length) {
80
+ throw new Error('--format requires a value');
81
+ }
82
+ options.format = args[i + 1];
83
+ i += 2;
84
+ } else if (arg === '--fail-on') {
85
+ if (i + 1 >= args.length) {
86
+ throw new Error('--fail-on requires a value');
87
+ }
88
+ options.failOn = args[i + 1];
89
+ i += 2;
90
+ } else if (arg === '--ignore' || arg === '-i') {
91
+ if (i + 1 >= args.length) {
92
+ throw new Error('--ignore requires a value');
93
+ }
94
+ options.ignore.push(args[i + 1]);
95
+ i += 2;
96
+ } else if (arg.startsWith('--')) {
97
+ throw new Error(`Unrecognized flag: ${arg}`);
98
+ } else if (arg.startsWith('-') && arg !== '-') {
99
+ throw new Error(`Unrecognized flag: ${arg}`);
100
+ } else {
101
+ // Positional argument (path)
102
+ options.path = arg;
103
+ i++;
104
+ }
105
+ }
106
+
107
+ // Validate options
108
+ validateOptions(options);
109
+
110
+ return options;
111
+ }
112
+
113
+ /**
114
+ * Validate parsed CLI options
115
+ * @param {CLIOptions} options - Parsed options to validate
116
+ * @throws {Error} If options are invalid
117
+ */
118
+ function validateOptions(options) {
119
+ // Skip validation if help or version flags are set
120
+ if (options.help || options.version) {
121
+ return;
122
+ }
123
+
124
+ // Validate format
125
+ const validFormats = ['text', 'json', 'github'];
126
+ if (!validFormats.includes(options.format)) {
127
+ throw new Error(
128
+ `Invalid --format value: "${options.format}". Must be one of: ${validFormats.join(', ')}`
129
+ );
130
+ }
131
+
132
+ // Validate failOn
133
+ const validFailOn = ['missing', 'unused', 'undocumented', 'all', 'none'];
134
+ if (!validFailOn.includes(options.failOn)) {
135
+ throw new Error(
136
+ `Invalid --fail-on value: "${options.failOn}". Must be one of: ${validFailOn.join(', ')}`
137
+ );
138
+ }
139
+
140
+ // Validate ignore patterns (basic check for invalid glob patterns)
141
+ for (const pattern of options.ignore) {
142
+ if (pattern.trim() === '') {
143
+ throw new Error('--ignore pattern cannot be empty');
144
+ }
145
+ try {
146
+ validateGlobPattern(pattern);
147
+ } catch (error) {
148
+ throw new Error(error.message.replace('Invalid glob pattern', 'Invalid --ignore pattern'));
149
+ }
150
+ }
151
+
152
+ // Validate path is not empty
153
+ if (options.path.trim() === '') {
154
+ throw new Error('Path argument cannot be empty');
155
+ }
156
+
157
+ // Validate envFile is not empty
158
+ if (options.envFile.trim() === '') {
159
+ throw new Error('--env-file path cannot be empty');
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Display help message
165
+ */
166
+ export function displayHelp() {
167
+ const helpText = `
168
+ envcheck - Validate environment variable usage across your codebase
169
+
170
+ USAGE:
171
+ envcheck [path] [options]
172
+ envcheck --repl Start interactive REPL mode
173
+ envcheck Start interactive REPL mode (no args)
174
+
175
+ ARGUMENTS:
176
+ path Directory or file to scan (default: ".")
177
+
178
+ OPTIONS:
179
+ --env-file <path> Path to .env.example file (default: ".env.example")
180
+ --format, -f <format> Output format: text, json, github (default: "text")
181
+ --fail-on <condition> Exit with code 1 if condition met:
182
+ missing, unused, undocumented, all, none (default: "none")
183
+ --ignore, -i <pattern> Glob pattern to ignore (repeatable)
184
+ --config, -c <path> Load configuration from file
185
+ --watch, -w Watch mode - rerun on file changes
186
+ --fix Auto-fix issues by updating .env.example
187
+ --no-color Disable colored output
188
+ --no-suggestions Disable intelligent suggestions
189
+ --no-progress Disable progress indicators
190
+ --quiet, -q Suppress output when no issues found
191
+ --repl, -r Start interactive REPL mode
192
+ --version, -v Display version number
193
+ --help, -h Display this help message
194
+
195
+ EXAMPLES:
196
+ envcheck .
197
+ envcheck ./src --env-file .env.production.example
198
+ envcheck . --format json --fail-on missing
199
+ envcheck . --ignore "**/*.test.js" --ignore "**/dist/**"
200
+ envcheck . --format github --fail-on all
201
+ envcheck . --watch
202
+ envcheck . --fix
203
+ envcheck . --config .envcheckrc.json
204
+ envcheck --repl
205
+
206
+ REPL MODE:
207
+ Start an interactive session with persistent configuration:
208
+ envcheck
209
+ envcheck --repl
210
+
211
+ REPL Commands:
212
+ :help Show REPL help
213
+ :config Show current configuration
214
+ :set <key> <value> Set configuration (path, format, etc.)
215
+ :history Show command history
216
+ :results Show previous results
217
+ :exit Exit REPL
218
+
219
+ REPL Examples:
220
+ :set path ./src
221
+ :set format json
222
+ . --fail-on missing
223
+ envcheck . --format github
224
+
225
+ EXIT CODES:
226
+ 0 Success (no issues or issues don't match --fail-on)
227
+ 1 Validation failed (issues match --fail-on condition)
228
+ 2 Error (invalid arguments, file not found, etc.)
229
+
230
+ For more information, visit: https://github.com/yourusername/envcheck
231
+ `;
232
+
233
+ console.log(helpText.trim());
234
+ }
235
+
236
+ /**
237
+ * Display version number
238
+ */
239
+ export function displayVersion() {
240
+ try {
241
+ const packageJsonPath = join(__dirname, '..', 'package.json');
242
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
243
+ console.log(`envcheck v${packageJson.version}`);
244
+ } catch (error) {
245
+ console.log('envcheck (version unknown)');
246
+ }
247
+ }
248
+
249
+ /**
250
+ * @typedef {Object} CLIOptions
251
+ * @property {string} path - Directory or file to scan
252
+ * @property {string} envFile - Path to .env.example file
253
+ * @property {'text'|'json'|'github'} format - Output format
254
+ * @property {'missing'|'unused'|'undocumented'|'all'|'none'} failOn - Exit code condition
255
+ * @property {string[]} ignore - Glob patterns to ignore
256
+ * @property {boolean} noColor - Disable colored output
257
+ * @property {boolean} quiet - Suppress output when no issues
258
+ * @property {boolean} version - Display version
259
+ * @property {boolean} help - Display help
260
+ */
261
+
262
+ /**
263
+ * Determine exit code based on analysis results and --fail-on flag
264
+ *
265
+ * @param {{missing: Array, unused: Array, undocumented: Array}} result - Analysis result
266
+ * @param {'missing'|'unused'|'undocumented'|'all'|'none'} failOn - Exit code condition
267
+ * @returns {number} Exit code (0 = success, 1 = validation failed)
268
+ *
269
+ * Preconditions:
270
+ * - result is a valid AnalysisResult object
271
+ * - failOn is one of: 'missing', 'unused', 'undocumented', 'all', 'none'
272
+ *
273
+ * Postconditions:
274
+ * - Returns 0 if no issues match failOn criteria
275
+ * - Returns 1 if issues match failOn criteria
276
+ * - 'all' fails if any category has issues
277
+ * - 'none' always returns 0
278
+ *
279
+ * Requirements: 1.8.1-1.8.5
280
+ */
281
+ export function determineExitCode(result, failOn) {
282
+ if (failOn === 'none') {
283
+ return 0;
284
+ }
285
+
286
+ if (failOn === 'all') {
287
+ return (result.missing.length > 0 || result.unused.length > 0 || result.undocumented.length > 0) ? 1 : 0;
288
+ }
289
+
290
+ if (failOn === 'missing') {
291
+ return result.missing.length > 0 ? 1 : 0;
292
+ }
293
+
294
+ if (failOn === 'unused') {
295
+ return result.unused.length > 0 ? 1 : 0;
296
+ }
297
+
298
+ if (failOn === 'undocumented') {
299
+ return result.undocumented.length > 0 ? 1 : 0;
300
+ }
301
+
302
+ return 0;
303
+ }
304
+
305
+ /**
306
+ * Main CLI runner - orchestrates the entire workflow
307
+ *
308
+ * @param {string[]} args - Command-line arguments (typically process.argv.slice(2))
309
+ * @returns {Promise<number>} Exit code (0 = success, 1 = validation failed, 2 = error)
310
+ *
311
+ * Preconditions:
312
+ * - args is a valid array of strings
313
+ * - Node.js runtime is available with required modules
314
+ *
315
+ * Postconditions:
316
+ * - Returns exit code 0, 1, or 2
317
+ * - Output is written to stdout or stderr
318
+ * - No unhandled exceptions escape
319
+ *
320
+ * Requirements: 1.5.1-1.5.10, 1.8.1-1.8.5
321
+ */
322
+ export async function run(args) {
323
+ let spinner = null;
324
+
325
+ try {
326
+ // Step 1: Parse command-line arguments
327
+ const options = parseArguments(args);
328
+
329
+ // Handle special flags
330
+ if (options.help) {
331
+ displayHelp();
332
+ return 0;
333
+ }
334
+
335
+ if (options.version) {
336
+ displayVersion();
337
+ return 0;
338
+ }
339
+
340
+ // Load config file if specified or found
341
+ let fileConfig = null;
342
+ if (options.config) {
343
+ fileConfig = loadConfig(options.config);
344
+ } else {
345
+ fileConfig = loadConfig(options.path);
346
+ }
347
+
348
+ // Merge config file with CLI options
349
+ const mergedOptions = mergeConfig(options, fileConfig);
350
+
351
+ // Watch mode
352
+ if (mergedOptions.watch) {
353
+ const runValidation = async () => {
354
+ await runOnce(mergedOptions);
355
+ };
356
+ await startWatchMode(mergedOptions.path, mergedOptions, runValidation);
357
+ return 0;
358
+ }
359
+
360
+ // Run once
361
+ return await runOnce(mergedOptions);
362
+
363
+ } catch (error) {
364
+ if (spinner) spinner.fail();
365
+ // Handle errors gracefully with better messages
366
+ console.error(`\n❌ Error: ${error.message}`);
367
+
368
+ // Provide helpful hints for common errors
369
+ if (error.code === 'ENOENT') {
370
+ console.error(`\n💡 Tip: Check that the file or directory exists`);
371
+ } else if (error.message.includes('Invalid')) {
372
+ console.error(`\n💡 Tip: Run 'envcheck --help' to see valid options`);
373
+ }
374
+
375
+ return 2;
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Run validation once (used by both normal and watch mode)
381
+ */
382
+ async function runOnce(options) {
383
+ let spinner = null;
384
+
385
+ try {
386
+ // Step 2: Load ignore patterns
387
+ if (options.progress && !options.quiet) {
388
+ spinner = new Spinner('Loading configuration...');
389
+ spinner.start();
390
+ }
391
+
392
+ const { loadIgnorePatterns } = await import('./ignore.js');
393
+ const ignorePatterns = loadIgnorePatterns(options.path);
394
+
395
+ // Add CLI-provided ignore patterns
396
+ ignorePatterns.push(...options.ignore);
397
+
398
+ // Step 3: Scan codebase for files
399
+ if (spinner) spinner.update('Scanning files...');
400
+
401
+ const { scan: scanFiles } = await import('./scanner.js');
402
+ const filePaths = await scanFiles(options.path, ignorePatterns);
403
+
404
+ if (spinner) spinner.update(`Analyzing ${filePaths.length} files...`);
405
+
406
+ // Step 4: Scan files for environment variable references
407
+ const references = await scanFilesForEnvVars(filePaths);
408
+
409
+ // Step 5: Parse .env.example file
410
+ if (spinner) spinner.update('Parsing .env.example...');
411
+
412
+ const { parseEnvFile } = await import('./parser.js');
413
+ const definitions = await parseEnvFile(options.envFile);
414
+
415
+ // Step 6: Analyze and categorize issues
416
+ if (spinner) spinner.update('Analyzing issues...');
417
+
418
+ const { analyzeIssues } = await import('./analyzer.js');
419
+ const result = analyzeIssues(references, definitions);
420
+
421
+ if (spinner) {
422
+ const totalIssues = result.missing.length + result.unused.length + result.undocumented.length;
423
+ if (totalIssues === 0) {
424
+ spinner.succeed('Analysis complete - no issues found! ✨');
425
+ } else {
426
+ spinner.info(`Analysis complete - found ${totalIssues} issue(s)`);
427
+ }
428
+ }
429
+
430
+ // Step 7: Generate suggestions
431
+ if (options.suggestions && !options.quiet) {
432
+ generateSuggestions(result);
433
+
434
+ // Add typo detection for missing variables
435
+ for (const missing of result.missing) {
436
+ const definedVars = definitions.map(d => d.varName);
437
+ const similar = findSimilarVariables(missing.varName, definedVars);
438
+
439
+ if (similar.length > 0) {
440
+ console.log(`\n💡 Did you mean '${similar[0].name}' instead of '${missing.varName}'? (${similar[0].similarity}% similar)`);
441
+ }
442
+ }
443
+ }
444
+
445
+ // Step 8: Auto-fix if requested
446
+ if (options.fix && result.missing.length > 0) {
447
+ const { generateEnvExampleFix } = await import('./suggestions.js');
448
+ const { readFileSync, writeFileSync } = await import('fs');
449
+
450
+ try {
451
+ const existingContent = readFileSync(options.envFile, 'utf-8');
452
+ const fixedContent = generateEnvExampleFix(result, existingContent);
453
+ writeFileSync(options.envFile, fixedContent, 'utf-8');
454
+ console.log(`\n✅ Auto-fixed ${result.missing.length} missing variable(s) in ${options.envFile}`);
455
+ } catch (error) {
456
+ console.error(`\n⚠️ Failed to auto-fix: ${error.message}`);
457
+ }
458
+ }
459
+
460
+ // Step 9: Format and display output
461
+ const output = await formatOutput(result, options);
462
+
463
+ // Only output if not quiet mode or if there are issues
464
+ if (!options.quiet || (result.missing.length > 0 || result.unused.length > 0 || result.undocumented.length > 0)) {
465
+ console.log(output);
466
+ }
467
+
468
+ // Step 10: Determine exit code based on --fail-on flag
469
+ const exitCode = determineExitCode(result, options.failOn);
470
+
471
+ return exitCode;
472
+
473
+ } catch (error) {
474
+ if (spinner) spinner.fail('Analysis failed');
475
+ throw error;
476
+ }
477
+ }
478
+
479
+ /**
480
+ * Scan multiple files for environment variable references
481
+ *
482
+ * @param {string[]} filePaths - Array of file paths to scan
483
+ * @returns {Promise<Array<{varName: string, filePath: string, lineNumber: number, pattern: string}>>}
484
+ *
485
+ * Preconditions:
486
+ * - filePaths is a valid array of file paths
487
+ * - All files in filePaths exist and are readable
488
+ *
489
+ * Postconditions:
490
+ * - Returns array of all env var references found across all files
491
+ * - Each reference has valid varName, filePath, lineNumber, pattern
492
+ *
493
+ * Requirements: 1.1.1-1.1.8
494
+ */
495
+ async function scanFilesForEnvVars(filePaths) {
496
+ const { createReadStream } = await import('fs');
497
+ const { createInterface } = await import('readline');
498
+ const path = await import('path');
499
+
500
+ const jsScanner = await import('./scanners/javascript.js');
501
+ const pyScanner = await import('./scanners/python.js');
502
+ const goScanner = await import('./scanners/go.js');
503
+ const rbScanner = await import('./scanners/ruby.js');
504
+ const rsScanner = await import('./scanners/rust.js');
505
+ const shScanner = await import('./scanners/shell.js');
506
+
507
+ const scannersByExtension = new Map([
508
+ ['.js', jsScanner],
509
+ ['.jsx', jsScanner],
510
+ ['.ts', jsScanner],
511
+ ['.tsx', jsScanner],
512
+ ['.mjs', jsScanner],
513
+ ['.cjs', jsScanner],
514
+ ['.py', pyScanner],
515
+ ['.go', goScanner],
516
+ ['.rb', rbScanner],
517
+ ['.rs', rsScanner],
518
+ ['.sh', shScanner],
519
+ ['.bash', shScanner],
520
+ ['.zsh', shScanner],
521
+ ]);
522
+
523
+ const concurrency = Math.max(
524
+ 1,
525
+ Math.min(32, Number(process.env.ENVCHECK_SCAN_CONCURRENCY) || 8)
526
+ );
527
+ const workerCount = Math.min(concurrency, filePaths.length);
528
+ const referencesPerWorker = Array.from({ length: workerCount }, () => []);
529
+ let currentIndex = 0;
530
+
531
+ async function scanSingleFile(filePath, scanner) {
532
+ const references = [];
533
+ const stream = createReadStream(filePath, { encoding: 'utf-8' });
534
+ const lineReader = createInterface({
535
+ input: stream,
536
+ crlfDelay: Infinity,
537
+ });
538
+ let lineNumber = 0;
539
+
540
+ for await (const line of lineReader) {
541
+ lineNumber += 1;
542
+ references.push(...scanner.scanLine(line, filePath, lineNumber));
543
+ }
544
+
545
+ return references;
546
+ }
547
+
548
+ async function runWorker(workerIndex) {
549
+ while (currentIndex < filePaths.length) {
550
+ const index = currentIndex;
551
+ currentIndex += 1;
552
+ const filePath = filePaths[index];
553
+
554
+ try {
555
+ const ext = path.extname(filePath).toLowerCase();
556
+ const scanner = scannersByExtension.get(ext);
557
+ if (!scanner) {
558
+ continue;
559
+ }
560
+
561
+ const references = await scanSingleFile(filePath, scanner);
562
+ referencesPerWorker[workerIndex].push(...references);
563
+ } catch (error) {
564
+ console.warn(`Warning: Error scanning ${filePath}: ${error.message}`);
565
+ }
566
+ }
567
+ }
568
+
569
+ await Promise.all(
570
+ Array.from({ length: workerCount }, (_, workerIndex) => runWorker(workerIndex))
571
+ );
572
+
573
+ return referencesPerWorker.flat();
574
+ }
575
+
576
+ /**
577
+ * Format analysis results based on output format option
578
+ *
579
+ * @param {{missing: Array, unused: Array, undocumented: Array, summary: Object}} result - Analysis result
580
+ * @param {CLIOptions} options - CLI options including format and display flags
581
+ * @returns {Promise<string>} Formatted output string
582
+ *
583
+ * Preconditions:
584
+ * - result is a valid AnalysisResult object
585
+ * - options.format is one of: 'text', 'json', 'github'
586
+ *
587
+ * Postconditions:
588
+ * - Returns formatted string ready for console output
589
+ * - Format matches options.format
590
+ * - Colors are omitted if options.noColor is true
591
+ *
592
+ * Requirements: 1.6.1-1.6.6
593
+ */
594
+ async function formatOutput(result, options) {
595
+ if (options.format === 'json') {
596
+ const { formatJSON } = await import('./formatters/json.js');
597
+ return formatJSON(result);
598
+ } else if (options.format === 'github') {
599
+ const { formatGitHub } = await import('./formatters/github.js');
600
+ return formatGitHub(result);
601
+ } else {
602
+ // Default to text format
603
+ const { formatText } = await import('./formatters/text.js');
604
+ return formatText(result, { noColor: options.noColor, quiet: options.quiet });
605
+ }
606
+ }