envsetter 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/scanner.js ADDED
@@ -0,0 +1,411 @@
1
+ "use strict"
2
+
3
+ const fs = require("fs")
4
+ const path = require("path")
5
+ const glob = require("glob")
6
+
7
+ // ─── Patterns that catch every way devs reference env vars ───────────────────
8
+ const ENV_PATTERNS = [
9
+ // process.env.VARIABLE_NAME
10
+ /process\.env\.([A-Z][A-Z0-9_]+)/g,
11
+
12
+ // process.env['VARIABLE_NAME'] or process.env["VARIABLE_NAME"]
13
+ /process\.env\[['"]([A-Z][A-Z0-9_]+)['"]\]/g,
14
+
15
+ // import.meta.env.VARIABLE_NAME (Vite)
16
+ /import\.meta\.env\.([A-Z][A-Z0-9_]+)/g,
17
+
18
+ // NEXT_PUBLIC_, REACT_APP_, VITE_, NUXT_ prefixed in any context
19
+ /\b(NEXT_PUBLIC_[A-Z0-9_]+)\b/g,
20
+ /\b(REACT_APP_[A-Z0-9_]+)\b/g,
21
+ /\b(VITE_[A-Z0-9_]+)\b/g,
22
+ /\b(NUXT_[A-Z0-9_]+)\b/g,
23
+ /\b(EXPO_PUBLIC_[A-Z0-9_]+)\b/g,
24
+
25
+ // os.environ.get("VAR") or os.environ["VAR"] or os.getenv("VAR") — Python
26
+ /os\.environ\.get\(\s*['"]([A-Z][A-Z0-9_]+)['"]/g,
27
+ /os\.environ\[['"]([A-Z][A-Z0-9_]+)['"]\]/g,
28
+ /os\.getenv\(\s*['"]([A-Z][A-Z0-9_]+)['"]/g,
29
+
30
+ // ENV["VAR"] or ENV.fetch("VAR") — Ruby
31
+ /ENV\[['"]([A-Z][A-Z0-9_]+)['"]\]/g,
32
+ /ENV\.fetch\(\s*['"]([A-Z][A-Z0-9_]+)['"]/g,
33
+
34
+ // env("VAR") — Laravel/PHP
35
+ /env\(\s*['"]([A-Z][A-Z0-9_]+)['"]/g,
36
+
37
+ // System.getenv("VAR") — Java
38
+ /System\.getenv\(\s*['"]([A-Z][A-Z0-9_]+)['"]/g,
39
+
40
+ // os.Getenv("VAR") — Go
41
+ /os\.Getenv\(\s*['"]([A-Z][A-Z0-9_]+)['"]/g,
42
+
43
+ // std::env::var("VAR") — Rust
44
+ /std::env::var\(\s*['"]([A-Z][A-Z0-9_]+)['"]/g,
45
+
46
+ // ${VAR} in YAML / docker-compose
47
+ /\$\{([A-Z][A-Z0-9_]+)\}/g,
48
+
49
+ // $VAR in shell scripts / Dockerfiles
50
+ /\$([A-Z][A-Z0-9_]+)\b/g,
51
+ ]
52
+
53
+ // Env var names to always ignore (runtime / system provided)
54
+ const BLACKLIST = new Set([
55
+ "NODE_ENV",
56
+ "HOME",
57
+ "PATH",
58
+ "USER",
59
+ "SHELL",
60
+ "PWD",
61
+ "LANG",
62
+ "TERM",
63
+ "HOSTNAME",
64
+ "OLDPWD",
65
+ "EDITOR",
66
+ "TMPDIR",
67
+ "TMP",
68
+ "TEMP",
69
+ "CI",
70
+ "NODE",
71
+ "NPM",
72
+ "NVM",
73
+ "SHLVL",
74
+ "LOGNAME",
75
+ "LC_ALL",
76
+ "LC_CTYPE",
77
+ "DISPLAY",
78
+ "COLORTERM",
79
+ "COLUMNS",
80
+ "LINES",
81
+ "SSH_AUTH_SOCK",
82
+ "SSH_CLIENT",
83
+ "SSH_CONNECTION",
84
+ "SSH_TTY",
85
+ "XDG_SESSION_ID",
86
+ "XDG_RUNTIME_DIR",
87
+ "XDG_DATA_DIRS",
88
+ "XDG_CONFIG_DIRS",
89
+ "DBUS_SESSION_BUS_ADDRESS",
90
+ "MAIL",
91
+ "MANPATH",
92
+ "PAGER",
93
+ "LESS",
94
+ "_",
95
+ ])
96
+
97
+ // File extensions we actually care about
98
+ const CODE_EXTENSIONS = [
99
+ "js",
100
+ "jsx",
101
+ "ts",
102
+ "tsx",
103
+ "mjs",
104
+ "cjs",
105
+ "py",
106
+ "rb",
107
+ "php",
108
+ "go",
109
+ "rs",
110
+ "java",
111
+ "vue",
112
+ "svelte",
113
+ "astro",
114
+ "yml",
115
+ "yaml",
116
+ "toml",
117
+ "sh",
118
+ "bash",
119
+ "zsh",
120
+ "env.example",
121
+ "env.sample",
122
+ "env.template",
123
+ "Dockerfile",
124
+ ]
125
+
126
+ /**
127
+ * Build the glob pattern
128
+ */
129
+ function buildGlobPattern() {
130
+ const exts = CODE_EXTENSIONS.join(",")
131
+ return `**/*.{${exts}}`
132
+ }
133
+
134
+ /**
135
+ * Extra standalone filenames to scan (no extension match)
136
+ */
137
+ const EXTRA_FILES = [
138
+ "Dockerfile",
139
+ "docker-compose.yml",
140
+ "docker-compose.yaml",
141
+ ".env.example",
142
+ ".env.sample",
143
+ ".env.template",
144
+ ".env.local.example",
145
+ "Makefile",
146
+ ]
147
+
148
+ const ENV_SOURCE_FILES = [
149
+ ".env",
150
+ ".env.local",
151
+ ".env.development",
152
+ ".env.production",
153
+ ".env.example",
154
+ ".env.sample",
155
+ ".env.template",
156
+ ".env.local.example",
157
+ ]
158
+
159
+ /**
160
+ * Directories to always ignore
161
+ */
162
+ const IGNORE_DIRS = [
163
+ "node_modules/**",
164
+ ".git/**",
165
+ "dist/**",
166
+ "build/**",
167
+ ".next/**",
168
+ ".nuxt/**",
169
+ ".output/**",
170
+ "coverage/**",
171
+ "__pycache__/**",
172
+ "vendor/**",
173
+ ".venv/**",
174
+ "venv/**",
175
+ "target/**",
176
+ ".cache/**",
177
+ ".turbo/**",
178
+ ]
179
+
180
+ /**
181
+ * Files to skip when scanning EnvSetter's own source repo.
182
+ * This prevents false positives from documentation/examples in this tool itself.
183
+ */
184
+ const SELF_IGNORE_FILES = new Set([
185
+ "bin/envsetter.js",
186
+ "src/index.js",
187
+ "src/scanner.js",
188
+ "src/ui.js",
189
+ "src/writer.js",
190
+ "plan.md",
191
+ ])
192
+
193
+ function isSelfEnvsetterProject(cwd) {
194
+ const pkgPath = path.join(cwd, "package.json")
195
+ if (!fs.existsSync(pkgPath)) return false
196
+
197
+ try {
198
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"))
199
+ return pkg && pkg.name === "envsetter"
200
+ } catch {
201
+ return false
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Parse an existing .env file and return a Map of key→value
207
+ */
208
+ function parseExistingEnv(envPath) {
209
+ const existing = new Map()
210
+ if (!fs.existsSync(envPath)) return existing
211
+
212
+ const content = fs.readFileSync(envPath, "utf-8")
213
+ const lines = content.split("\n")
214
+
215
+ for (const line of lines) {
216
+ const trimmed = line.trim()
217
+ if (!trimmed || trimmed.startsWith("#")) continue
218
+
219
+ const eqIndex = trimmed.indexOf("=")
220
+ if (eqIndex === -1) continue
221
+
222
+ const key = trimmed.substring(0, eqIndex).trim()
223
+ let value = trimmed.substring(eqIndex + 1).trim()
224
+
225
+ // Remove surrounding quotes
226
+ if (
227
+ (value.startsWith('"') && value.endsWith('"')) ||
228
+ (value.startsWith("'") && value.endsWith("'"))
229
+ ) {
230
+ value = value.slice(1, -1)
231
+ }
232
+
233
+ existing.set(key, value)
234
+ }
235
+
236
+ return existing
237
+ }
238
+
239
+ function extractEnvKeysFromContent(content) {
240
+ const keys = new Set()
241
+ const lines = content.split("\n")
242
+
243
+ for (const line of lines) {
244
+ const trimmed = line.trim()
245
+ if (!trimmed || trimmed.startsWith("#")) continue
246
+
247
+ const eqIndex = trimmed.indexOf("=")
248
+ if (eqIndex === -1) continue
249
+
250
+ const key = trimmed.substring(0, eqIndex).trim()
251
+ if (!key) continue
252
+ if (!/^[A-Z][A-Z0-9_]+$/.test(key)) continue
253
+ if (BLACKLIST.has(key)) continue
254
+
255
+ keys.add(key)
256
+ }
257
+
258
+ return keys
259
+ }
260
+
261
+ function scanEnvFilesOnly(cwd) {
262
+ const foundVars = new Map()
263
+
264
+ for (const file of ENV_SOURCE_FILES) {
265
+ const fullPath = path.join(cwd, file)
266
+ if (!fs.existsSync(fullPath)) continue
267
+
268
+ let content
269
+ try {
270
+ content = fs.readFileSync(fullPath, "utf-8")
271
+ } catch {
272
+ continue
273
+ }
274
+
275
+ const keys = extractEnvKeysFromContent(content)
276
+ for (const key of keys) {
277
+ if (!foundVars.has(key)) {
278
+ foundVars.set(key, new Set())
279
+ }
280
+ foundVars.get(key).add(file)
281
+ }
282
+ }
283
+
284
+ return foundVars
285
+ }
286
+
287
+ /**
288
+ * Scan the entire codebase and return discovered env var names
289
+ */
290
+ function scanCodebase(cwd) {
291
+ const foundVars = new Map() // key → Set of files where found
292
+ const skipSelfFiles = isSelfEnvsetterProject(cwd)
293
+
294
+ // 1. Glob for code files
295
+ const pattern = buildGlobPattern()
296
+ const files = glob.sync(pattern, {
297
+ cwd,
298
+ ignore: IGNORE_DIRS,
299
+ nodir: true,
300
+ absolute: true,
301
+ dot: true,
302
+ })
303
+
304
+ // 2. Add extra standalone files
305
+ for (const extra of EXTRA_FILES) {
306
+ const fullPath = path.join(cwd, extra)
307
+ if (fs.existsSync(fullPath) && !files.includes(fullPath)) {
308
+ files.push(fullPath)
309
+ }
310
+ }
311
+
312
+ // 3. Scan each file
313
+ for (const filePath of files) {
314
+ const relPath = path.relative(cwd, filePath).replace(/\\/g, "/")
315
+
316
+ if (skipSelfFiles && SELF_IGNORE_FILES.has(relPath)) {
317
+ continue
318
+ }
319
+
320
+ // Skip actual .env file (we read it separately)
321
+ const basename = path.basename(filePath)
322
+ if (basename === ".env" || basename === ".env.local") continue
323
+
324
+ let content
325
+ try {
326
+ content = fs.readFileSync(filePath, "utf-8")
327
+ } catch {
328
+ continue // binary or permission issue
329
+ }
330
+
331
+ for (const regex of ENV_PATTERNS) {
332
+ // Reset lastIndex for global regex
333
+ regex.lastIndex = 0
334
+ let match
335
+ while ((match = regex.exec(content)) !== null) {
336
+ const varName = match[1]
337
+ if (!varName) continue
338
+ if (BLACKLIST.has(varName)) continue
339
+ if (varName.length < 3) continue // skip tiny names like "A"
340
+
341
+ if (!foundVars.has(varName)) {
342
+ foundVars.set(varName, new Set())
343
+ }
344
+ foundVars.get(varName).add(relPath)
345
+ }
346
+ }
347
+ }
348
+
349
+ return foundVars
350
+ }
351
+
352
+ /**
353
+ * Recursively discover all folders that contain env files.
354
+ * Returns array of { relPath, absPath, envFiles[] }
355
+ */
356
+ function discoverEnvFolders(cwd) {
357
+ const ENV_NAMES = [
358
+ ".env", ".env.local", ".env.development", ".env.production",
359
+ ".env.example", ".env.sample", ".env.template",
360
+ ]
361
+
362
+ const SKIP_DIRS = new Set([
363
+ "node_modules", ".git", "dist", "build", ".next", ".nuxt",
364
+ ".output", "coverage", "__pycache__", "vendor", ".venv",
365
+ "venv", "target", ".cache", ".turbo",
366
+ ])
367
+
368
+ const results = []
369
+
370
+ function walk(dir, depth) {
371
+ if (depth > 8) return // prevent too-deep traversal
372
+
373
+ // Check if this directory has any env files
374
+ const foundEnvFiles = []
375
+ for (const name of ENV_NAMES) {
376
+ const full = path.join(dir, name)
377
+ if (fs.existsSync(full)) {
378
+ foundEnvFiles.push(name)
379
+ }
380
+ }
381
+
382
+ if (foundEnvFiles.length > 0) {
383
+ const relPath = path.relative(cwd, dir).replace(/\\/g, "/") || "."
384
+ results.push({
385
+ relPath,
386
+ absPath: dir,
387
+ envFiles: foundEnvFiles,
388
+ })
389
+ }
390
+
391
+ // Recurse into subdirectories
392
+ let entries
393
+ try {
394
+ entries = fs.readdirSync(dir, {withFileTypes: true})
395
+ } catch {
396
+ return
397
+ }
398
+
399
+ for (const entry of entries) {
400
+ if (!entry.isDirectory()) continue
401
+ if (SKIP_DIRS.has(entry.name)) continue
402
+ if (entry.name.startsWith(".") && entry.name !== ".") continue
403
+ walk(path.join(dir, entry.name), depth + 1)
404
+ }
405
+ }
406
+
407
+ walk(cwd, 0)
408
+ return results
409
+ }
410
+
411
+ module.exports = {scanCodebase, scanEnvFilesOnly, parseExistingEnv, discoverEnvFolders}