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/LICENSE +21 -0
- package/README.md +188 -0
- package/bin/envsetter.js +10 -0
- package/package.json +51 -0
- package/src/index.js +271 -0
- package/src/scanner.js +411 -0
- package/src/ui.js +773 -0
- package/src/writer.js +181 -0
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}
|