@claude-agent/envcheck 1.4.0 → 1.5.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/README.md CHANGED
@@ -4,9 +4,9 @@
4
4
  [![npm downloads](https://img.shields.io/npm/dm/@claude-agent/envcheck.svg)](https://www.npmjs.com/package/@claude-agent/envcheck)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
6
 
7
- > Validate .env files, compare with .env.example, find missing or empty variables.
7
+ > Validate .env files, compare with .env.example, find missing or empty variables. **Now with monorepo support!**
8
8
 
9
- Never deploy with missing environment variables again.
9
+ Never deploy with missing environment variables again. Works across entire monorepos with a single command.
10
10
 
11
11
  **Built autonomously by [Claude](https://claude.ai)** - an AI assistant by Anthropic.
12
12
 
@@ -34,6 +34,9 @@ envcheck .env.production
34
34
  # Require specific variables
35
35
  envcheck -r "DATABASE_URL,API_KEY"
36
36
 
37
+ # Scan entire monorepo
38
+ envcheck monorepo
39
+
37
40
  # Compare two files
38
41
  envcheck compare .env .env.staging
39
42
 
@@ -101,6 +104,104 @@ envcheck list .env -j
101
104
  envcheck get .env DATABASE_URL
102
105
  ```
103
106
 
107
+ ### Monorepo Command
108
+
109
+ Scan all apps and packages in a monorepo with a single command:
110
+
111
+ ```bash
112
+ # Scan from current directory
113
+ envcheck monorepo
114
+
115
+ # Scan specific directory
116
+ envcheck monorepo ./my-monorepo
117
+
118
+ # With verbose output (shows all issues per app)
119
+ envcheck monorepo --verbose
120
+
121
+ # JSON output for CI/CD
122
+ envcheck monorepo --json
123
+
124
+ # Enable secret detection
125
+ envcheck monorepo --secrets
126
+ ```
127
+
128
+ ## Monorepo Support
129
+
130
+ envcheck can scan entire monorepos, validating environment variables across all apps and packages.
131
+
132
+ ### Supported Structures
133
+
134
+ envcheck automatically detects these common monorepo patterns:
135
+
136
+ ```
137
+ my-monorepo/
138
+ ├── apps/
139
+ │ ├── web/ # ✓ Scanned
140
+ │ │ ├── .env
141
+ │ │ └── .env.example
142
+ │ └── api/ # ✓ Scanned
143
+ │ ├── .env
144
+ │ └── .env.example
145
+ ├── packages/
146
+ │ ├── shared/ # ○ Skipped (no .env.example)
147
+ │ └── utils/ # ✓ Scanned
148
+ │ ├── .env
149
+ │ └── .env.example
150
+ └── .env.example # ✓ Root included if exists
151
+ ```
152
+
153
+ Supported directories: `apps/`, `packages/`, `workspaces/`, `services/`, `libs/`
154
+
155
+ ### Example Output
156
+
157
+ ```
158
+ $ envcheck monorepo
159
+
160
+ Monorepo Environment Check
161
+ Root: /path/to/monorepo
162
+
163
+ ✓ apps/web: passed
164
+ ✗ apps/api: 1 error(s)
165
+ ○ packages/shared: skipped (No .env.example found)
166
+ ✓ packages/utils: passed
167
+
168
+ Summary: 4 apps scanned
169
+ ✓ 2 passed
170
+ ✗ 1 failed
171
+ ○ 1 skipped
172
+
173
+ ✗ 1 error(s), 0 warning(s)
174
+ ```
175
+
176
+ ### Consistency Checks
177
+
178
+ envcheck can detect inconsistencies across apps:
179
+
180
+ - **Shared variables** - Track which variables appear in multiple apps
181
+ - **Type mismatches** - Detect when the same variable has different type hints in different apps
182
+
183
+ ```javascript
184
+ const { scanMonorepo } = require('@claude-agent/envcheck');
185
+
186
+ const result = scanMonorepo('.', { checkConsistency: true });
187
+
188
+ console.log(result.consistency.sharedVars);
189
+ // { API_URL: ['apps/web', 'apps/api'] }
190
+
191
+ console.log(result.consistency.mismatches);
192
+ // [{ variable: 'API_URL', issue: 'type_mismatch', details: [...] }]
193
+ ```
194
+
195
+ ### GitHub Action for Monorepos
196
+
197
+ ```yaml
198
+ - name: Validate all environment files
199
+ uses: claude-agent-tools/envcheck@v1
200
+ with:
201
+ monorepo: 'true'
202
+ strict: 'true'
203
+ ```
204
+
104
205
  ## API Usage
105
206
 
106
207
  ```javascript
@@ -357,22 +458,24 @@ WITH_EQUALS=postgres://user:pass@host/db?opt=val
357
458
  - **Auto-detection** - Finds .env.example automatically
358
459
  - **CI-friendly** - Exit codes and JSON output
359
460
  - **Comprehensive** - Parse, validate, compare, generate
360
- - **Well-tested** - 72 tests covering edge cases
361
-
362
- ## vs. dotenv-safe / envalid
363
-
364
- | Feature | envcheck | dotenv-safe | envalid |
365
- |---------|----------|-------------|---------|
366
- | Validates presence | ✅ | ✅ | ✅ |
367
- | Based on .env.example | ✅ | ✅ | ❌ (schema) |
368
- | **Static validation** | ✅ | ❌ | ❌ |
369
- | **CI/CD integration** | ✅ GitHub Action | ❌ | ❌ |
370
- | **Pre-commit hook** | ✅ | ❌ | ❌ |
371
- | Type validation | ✅ (static) | ❌ | (runtime) |
372
- | **Secret detection** | ✅ | ❌ | ❌ |
373
- | Zero dependencies | ✅ | ❌ | ❌ |
374
-
375
- **Key difference:** envcheck validates *before* deployment (shift-left), while dotenv-safe and envalid validate at runtime when your app starts. Catch missing env vars and type errors in CI, not in production.
461
+ - **Monorepo support** - Scan all apps/packages in one command
462
+ - **Well-tested** - 87 tests covering edge cases
463
+
464
+ ## vs. dotenv-safe / envalid / dotenv-mono
465
+
466
+ | Feature | envcheck | dotenv-safe | envalid | dotenv-mono |
467
+ |---------|----------|-------------|---------|-------------|
468
+ | Validates presence | | ✅ | ✅ | ❌ |
469
+ | Based on .env.example | ✅ | ✅ | (schema) | ❌ |
470
+ | **Static validation** | ✅ | | ❌ | ❌ |
471
+ | **CI/CD integration** | ✅ GitHub Action | ❌ | ❌ | ❌ |
472
+ | **Pre-commit hook** | ✅ | | ❌ | |
473
+ | Type validation | ✅ (static) | ❌ | ✅ (runtime) | ❌ |
474
+ | **Secret detection** | ✅ | ❌ | ❌ | ❌ |
475
+ | **Monorepo scan** | ✅ | ❌ | ❌ | ❌ |
476
+ | Zero dependencies | | | | |
477
+
478
+ **Key difference:** envcheck validates *before* deployment (shift-left), while dotenv-safe and envalid validate at runtime when your app starts. dotenv-mono helps load env vars in monorepos but doesn't validate them. envcheck is the only tool that validates across entire monorepos with a single command.
376
479
 
377
480
  ## License
378
481
 
package/bin/cli.js CHANGED
@@ -1,21 +1,23 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
- const { check, compare, validate, list, get, readEnvFile } = require('../src/index.js');
4
+ const { check, compare, validate, list, get, readEnvFile, scanMonorepo, formatMonorepoResult } = require('../src/index.js');
5
5
  const path = require('path');
6
6
  const fs = require('fs');
7
7
 
8
- const VERSION = '1.0.0';
8
+ const VERSION = '1.5.0';
9
9
 
10
10
  const HELP = `
11
11
  envcheck - Validate .env files
12
12
 
13
13
  USAGE
14
14
  envcheck [options] [file]
15
+ envcheck monorepo [directory]
15
16
  envcheck compare <env> <example>
16
17
 
17
18
  COMMANDS
18
19
  check (default) Check .env file, optionally against .env.example
20
+ monorepo Scan all apps/packages in a monorepo
19
21
  compare Compare two env files
20
22
  list List variables in a file
21
23
  get <key> Get a specific variable value
@@ -26,6 +28,8 @@ OPTIONS
26
28
  --no-empty Warn on empty values
27
29
  --no-extra Error on variables not in example
28
30
  --strict Treat warnings as errors
31
+ --secrets Enable secret detection (warn about real secrets)
32
+ --verbose Show detailed output (monorepo mode)
29
33
  -q, --quiet Only output errors
30
34
  -j, --json Output as JSON
31
35
  -v, --version Show version
@@ -35,6 +39,9 @@ EXAMPLES
35
39
  envcheck Check .env against .env.example
36
40
  envcheck .env.production Check specific file
37
41
  envcheck -r "API_KEY,DB_URL" Require specific variables
42
+ envcheck monorepo Scan monorepo from current directory
43
+ envcheck monorepo ./my-monorepo Scan specific directory
44
+ envcheck monorepo --verbose Show all issues per app
38
45
  envcheck compare .env .env.prod Compare two files
39
46
  envcheck list .env List all variables
40
47
  envcheck get .env API_KEY Get specific value
@@ -51,6 +58,8 @@ function parseArgs(args) {
51
58
  strict: false,
52
59
  quiet: false,
53
60
  json: false,
61
+ verbose: false,
62
+ detectSecrets: false,
54
63
  args: []
55
64
  };
56
65
 
@@ -78,11 +87,15 @@ function parseArgs(args) {
78
87
  result.noExtra = true;
79
88
  } else if (arg === '--strict') {
80
89
  result.strict = true;
90
+ } else if (arg === '--secrets') {
91
+ result.detectSecrets = true;
92
+ } else if (arg === '--verbose') {
93
+ result.verbose = true;
81
94
  } else if (arg === '-q' || arg === '--quiet') {
82
95
  result.quiet = true;
83
96
  } else if (arg === '-j' || arg === '--json') {
84
97
  result.json = true;
85
- } else if (arg === 'compare' || arg === 'list' || arg === 'get') {
98
+ } else if (arg === 'compare' || arg === 'list' || arg === 'get' || arg === 'monorepo') {
86
99
  result.command = arg;
87
100
  } else if (!arg.startsWith('-')) {
88
101
  result.args.push(arg);
@@ -277,6 +290,32 @@ function runGet(opts) {
277
290
  return 0;
278
291
  }
279
292
 
293
+ function runMonorepo(opts) {
294
+ const rootDir = opts.args[0] || '.';
295
+
296
+ const result = scanMonorepo(rootDir, {
297
+ noEmpty: opts.noEmpty,
298
+ noExtra: opts.noExtra,
299
+ strict: opts.strict,
300
+ detectSecrets: opts.detectSecrets,
301
+ checkConsistency: true
302
+ });
303
+
304
+ if (opts.json) {
305
+ console.log(JSON.stringify(result, null, 2));
306
+ return result.valid ? 0 : 1;
307
+ }
308
+
309
+ // Use formatted output
310
+ const output = formatMonorepoResult(result, {
311
+ colors: !opts.quiet,
312
+ verbose: opts.verbose
313
+ });
314
+ console.log(output);
315
+
316
+ return result.valid ? 0 : 1;
317
+ }
318
+
280
319
  function main() {
281
320
  const args = process.argv.slice(2);
282
321
  const opts = parseArgs(args);
@@ -287,6 +326,9 @@ function main() {
287
326
  case 'check':
288
327
  exitCode = runCheck(opts);
289
328
  break;
329
+ case 'monorepo':
330
+ exitCode = runMonorepo(opts);
331
+ break;
290
332
  case 'compare':
291
333
  exitCode = runCompare(opts);
292
334
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claude-agent/envcheck",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Validate .env files, compare with .env.example, find missing or empty variables",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -22,7 +22,10 @@
22
22
  "pre-commit",
23
23
  "git-hooks",
24
24
  "secrets",
25
- "security"
25
+ "security",
26
+ "monorepo",
27
+ "turborepo",
28
+ "workspaces"
26
29
  ],
27
30
  "author": "Claude Agent <claude-agent@agentmail.to>",
28
31
  "license": "MIT",
package/src/index.js CHANGED
@@ -683,6 +683,278 @@ function get(filePath, key) {
683
683
  return env.variables[key];
684
684
  }
685
685
 
686
+ /**
687
+ * Find directories that might contain apps/packages in a monorepo
688
+ * @param {string} rootDir - Root directory to scan
689
+ * @returns {string[]} Array of directory paths
690
+ */
691
+ function findMonorepoApps(rootDir) {
692
+ const apps = [];
693
+ const basePath = path.resolve(rootDir);
694
+
695
+ // Common monorepo patterns
696
+ const patterns = ['apps', 'packages', 'workspaces', 'services', 'libs'];
697
+
698
+ for (const pattern of patterns) {
699
+ const dir = path.join(basePath, pattern);
700
+ if (fs.existsSync(dir) && fs.statSync(dir).isDirectory()) {
701
+ // Get all subdirectories
702
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
703
+ for (const entry of entries) {
704
+ if (entry.isDirectory() && !entry.name.startsWith('.')) {
705
+ apps.push(path.join(dir, entry.name));
706
+ }
707
+ }
708
+ }
709
+ }
710
+
711
+ // Also check root for .env.example (some monorepos have root-level env)
712
+ if (fs.existsSync(path.join(basePath, '.env.example')) ||
713
+ fs.existsSync(path.join(basePath, '.env'))) {
714
+ apps.unshift(basePath);
715
+ }
716
+
717
+ return apps;
718
+ }
719
+
720
+ /**
721
+ * Scan a monorepo for env file issues
722
+ * @param {string} rootDir - Root directory of monorepo
723
+ * @param {Object} options - Scan options
724
+ * @returns {Object} Monorepo scan result
725
+ */
726
+ function scanMonorepo(rootDir, options = {}) {
727
+ const {
728
+ noEmpty = false,
729
+ noExtra = false,
730
+ strict = false,
731
+ checkConsistency = true,
732
+ detectSecrets = false
733
+ } = options;
734
+
735
+ const basePath = path.resolve(rootDir);
736
+ const apps = findMonorepoApps(basePath);
737
+
738
+ const result = {
739
+ root: basePath,
740
+ valid: true,
741
+ apps: [],
742
+ summary: {
743
+ total: apps.length,
744
+ passed: 0,
745
+ failed: 0,
746
+ skipped: 0,
747
+ errors: 0,
748
+ warnings: 0
749
+ },
750
+ consistency: {
751
+ sharedVars: {}, // Variables that appear in multiple apps
752
+ mismatches: [] // Variables with different types/values across apps
753
+ }
754
+ };
755
+
756
+ // Track all variables across apps for consistency checking
757
+ const allVars = {}; // { varName: [{ app, value, type }] }
758
+
759
+ for (const appDir of apps) {
760
+ const appName = path.relative(basePath, appDir) || '.';
761
+ const envPath = path.join(appDir, '.env');
762
+ const examplePath = path.join(appDir, '.env.example');
763
+
764
+ const appResult = {
765
+ name: appName,
766
+ path: appDir,
767
+ hasEnv: fs.existsSync(envPath),
768
+ hasExample: fs.existsSync(examplePath),
769
+ valid: true,
770
+ issues: [],
771
+ variables: []
772
+ };
773
+
774
+ // Skip if no example file (can't validate)
775
+ if (!appResult.hasExample) {
776
+ appResult.skipped = true;
777
+ appResult.reason = 'No .env.example found';
778
+ result.apps.push(appResult);
779
+ result.summary.skipped++;
780
+ continue;
781
+ }
782
+
783
+ // If no .env but has example, that's also a problem
784
+ if (!appResult.hasEnv) {
785
+ appResult.valid = false;
786
+ appResult.issues.push({
787
+ type: 'warning',
788
+ message: 'No .env file found (but .env.example exists)'
789
+ });
790
+ }
791
+
792
+ // Run check if both files exist
793
+ if (appResult.hasEnv) {
794
+ const checkResult = check(envPath, {
795
+ examplePath,
796
+ noEmpty,
797
+ noExtra,
798
+ strict,
799
+ validateTypes: true,
800
+ detectSecrets
801
+ });
802
+
803
+ appResult.valid = checkResult.valid;
804
+ appResult.issues = checkResult.issues;
805
+ appResult.variables = Object.keys(checkResult.env?.variables || {});
806
+
807
+ // Track variables for consistency check
808
+ if (checkConsistency && checkResult.env?.variables) {
809
+ const example = readEnvFile(examplePath);
810
+ for (const [varName, value] of Object.entries(checkResult.env.variables)) {
811
+ if (!allVars[varName]) {
812
+ allVars[varName] = [];
813
+ }
814
+ allVars[varName].push({
815
+ app: appName,
816
+ value,
817
+ type: example.typeHints?.[varName] || null
818
+ });
819
+ }
820
+ }
821
+ }
822
+
823
+ // Update summary
824
+ if (appResult.skipped) {
825
+ // Already counted above
826
+ } else if (appResult.valid) {
827
+ result.summary.passed++;
828
+ } else {
829
+ result.summary.failed++;
830
+ result.valid = false;
831
+ }
832
+
833
+ const errors = appResult.issues.filter(i => i.type === 'error').length;
834
+ const warnings = appResult.issues.filter(i => i.type === 'warning').length;
835
+ result.summary.errors += errors;
836
+ result.summary.warnings += warnings;
837
+
838
+ result.apps.push(appResult);
839
+ }
840
+
841
+ // Check consistency across apps
842
+ if (checkConsistency) {
843
+ for (const [varName, occurrences] of Object.entries(allVars)) {
844
+ if (occurrences.length > 1) {
845
+ result.consistency.sharedVars[varName] = occurrences.map(o => o.app);
846
+
847
+ // Check for type mismatches
848
+ const types = new Set(occurrences.map(o => o.type).filter(Boolean));
849
+ if (types.size > 1) {
850
+ result.consistency.mismatches.push({
851
+ variable: varName,
852
+ issue: 'type_mismatch',
853
+ details: occurrences.map(o => ({ app: o.app, type: o.type }))
854
+ });
855
+ result.valid = false;
856
+ }
857
+ }
858
+ }
859
+ }
860
+
861
+ return result;
862
+ }
863
+
864
+ /**
865
+ * Format monorepo result for CLI output
866
+ * @param {Object} result - Monorepo scan result
867
+ * @param {Object} options - Format options
868
+ * @returns {string} Formatted output
869
+ */
870
+ function formatMonorepoResult(result, options = {}) {
871
+ const { colors = true, verbose = false } = options;
872
+
873
+ const c = colors ? {
874
+ green: '\x1b[32m',
875
+ red: '\x1b[31m',
876
+ yellow: '\x1b[33m',
877
+ dim: '\x1b[2m',
878
+ reset: '\x1b[0m',
879
+ bold: '\x1b[1m'
880
+ } : { green: '', red: '', yellow: '', dim: '', reset: '', bold: '' };
881
+
882
+ const lines = [];
883
+
884
+ lines.push(`${c.bold}Monorepo Environment Check${c.reset}`);
885
+ lines.push(`Root: ${result.root}`);
886
+ lines.push('');
887
+
888
+ for (const app of result.apps) {
889
+ const icon = app.skipped ? `${c.dim}○${c.reset}` :
890
+ app.valid ? `${c.green}✓${c.reset}` :
891
+ `${c.red}✗${c.reset}`;
892
+
893
+ let status = '';
894
+ if (app.skipped) {
895
+ status = `${c.dim}skipped (${app.reason})${c.reset}`;
896
+ } else {
897
+ const errors = app.issues.filter(i => i.type === 'error').length;
898
+ const warnings = app.issues.filter(i => i.type === 'warning').length;
899
+
900
+ if (errors === 0 && warnings === 0) {
901
+ status = `${c.green}passed${c.reset}`;
902
+ } else if (errors > 0) {
903
+ status = `${c.red}${errors} error(s)${c.reset}`;
904
+ if (warnings > 0) status += `, ${c.yellow}${warnings} warning(s)${c.reset}`;
905
+ } else {
906
+ status = `${c.yellow}${warnings} warning(s)${c.reset}`;
907
+ }
908
+ }
909
+
910
+ lines.push(`${icon} ${app.name}: ${status}`);
911
+
912
+ // Show details in verbose mode
913
+ if (verbose && !app.skipped && app.issues.length > 0) {
914
+ for (const issue of app.issues) {
915
+ const prefix = issue.type === 'error' ? `${c.red} ✗${c.reset}` : `${c.yellow} !${c.reset}`;
916
+ lines.push(`${prefix} ${issue.message}`);
917
+ }
918
+ }
919
+ }
920
+
921
+ lines.push('');
922
+
923
+ // Summary
924
+ lines.push(`${c.bold}Summary:${c.reset} ${result.summary.total} apps scanned`);
925
+ if (result.summary.passed > 0) {
926
+ lines.push(` ${c.green}✓${c.reset} ${result.summary.passed} passed`);
927
+ }
928
+ if (result.summary.failed > 0) {
929
+ lines.push(` ${c.red}✗${c.reset} ${result.summary.failed} failed`);
930
+ }
931
+ if (result.summary.skipped > 0) {
932
+ lines.push(` ${c.dim}○${c.reset} ${result.summary.skipped} skipped`);
933
+ }
934
+
935
+ // Consistency issues
936
+ if (result.consistency.mismatches.length > 0) {
937
+ lines.push('');
938
+ lines.push(`${c.yellow}Consistency Issues:${c.reset}`);
939
+ for (const mismatch of result.consistency.mismatches) {
940
+ lines.push(` ${c.yellow}!${c.reset} ${mismatch.variable}: ${mismatch.issue}`);
941
+ for (const detail of mismatch.details) {
942
+ lines.push(` - ${detail.app}: type=${detail.type || 'unspecified'}`);
943
+ }
944
+ }
945
+ }
946
+
947
+ // Final status
948
+ lines.push('');
949
+ if (result.valid) {
950
+ lines.push(`${c.green}✓ All checks passed${c.reset}`);
951
+ } else {
952
+ lines.push(`${c.red}✗ ${result.summary.errors} error(s), ${result.summary.warnings} warning(s)${c.reset}`);
953
+ }
954
+
955
+ return lines.join('\n');
956
+ }
957
+
686
958
  module.exports = {
687
959
  parse: parseEnv,
688
960
  parseValue,
@@ -697,5 +969,9 @@ module.exports = {
697
969
  check,
698
970
  generate,
699
971
  list,
700
- get
972
+ get,
973
+ // Monorepo support
974
+ findMonorepoApps,
975
+ scanMonorepo,
976
+ formatMonorepoResult
701
977
  };