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/ui.js ADDED
@@ -0,0 +1,773 @@
1
+ "use strict"
2
+
3
+ const chalk = require("chalk")
4
+ const inquirer = require("inquirer")
5
+ const boxen = require("boxen")
6
+ const figures = require("figures")
7
+ const fs = require("fs")
8
+ const path = require("path")
9
+
10
+ // ─── Design System ──────────────────────────────────────────────────────────────
11
+ // Inspired by Vercel / OpenAI CLI aesthetics — clean, minimal, professional
12
+ const THEME = {
13
+ // Core palette
14
+ brand: "#FFFFFF",
15
+ brandDim: "#A1A1AA",
16
+ accent: "#3B82F6", // Blue
17
+ accentDim: "#1D4ED8",
18
+ cyan: "#22D3EE",
19
+ cyanDim: "#0891B2",
20
+ green: "#10B981",
21
+ greenDim: "#059669",
22
+ yellow: "#F59E0B",
23
+ yellowDim: "#D97706",
24
+ red: "#EF4444",
25
+ redDim: "#DC2626",
26
+ purple: "#A78BFA",
27
+ purpleDim: "#7C3AED",
28
+ // Text hierarchy
29
+ text: "#F4F4F5",
30
+ textSecondary: "#A1A1AA",
31
+ textMuted: "#71717A",
32
+ textSubtle: "#52525B",
33
+ // Borders
34
+ border: "#3F3F46",
35
+ borderDim: "#27272A",
36
+ }
37
+
38
+ // Unicode elements — geometric, clean
39
+ const SYM = {
40
+ bar: "│",
41
+ dash: "─",
42
+ dot: "●",
43
+ ring: "○",
44
+ tri: "▲",
45
+ triRight: "▶",
46
+ triSmall: "›",
47
+ check: "✓",
48
+ cross: "✕",
49
+ warn: "⚠",
50
+ diamond: "◆",
51
+ arrow: "→",
52
+ bullet: "•",
53
+ ellipsis: "…",
54
+ block: "█",
55
+ blockLight: "░",
56
+ blockMed: "▒",
57
+ }
58
+
59
+ // ─── Utility Helpers ────────────────────────────────────────────────────────────
60
+
61
+ function c(hex, text) { return chalk.hex(hex)(text) }
62
+ function cb(hex, text) { return chalk.bold.hex(hex)(text) }
63
+ function dim(text) { return chalk.hex(THEME.textMuted)(text) }
64
+ function subtle(text) { return chalk.hex(THEME.textSubtle)(text) }
65
+
66
+ function clamp(n, min, max) { return Math.max(min, Math.min(max, n)) }
67
+
68
+ function visLen(str) { return str.replace(/\x1b\[[0-9;]*m/g, "").length }
69
+
70
+ function pad(str, width) {
71
+ return str + " ".repeat(Math.max(0, width - visLen(str)))
72
+ }
73
+
74
+ // ─── Visual Components ──────────────────────────────────────────────────────────
75
+
76
+ function progressBar(done, total, width = 24) {
77
+ if (total <= 0) return subtle(SYM.blockLight.repeat(width))
78
+ const ratio = clamp(done / total, 0, 1)
79
+ const filled = Math.round(ratio * width)
80
+ const empty = width - filled
81
+ return c(THEME.accent, SYM.block.repeat(filled)) +
82
+ chalk.hex(THEME.borderDim)(SYM.blockLight.repeat(empty))
83
+ }
84
+
85
+ function statusDot(color) { return chalk.hex(color)(SYM.dot) }
86
+
87
+ function sectionLine(title, width = 48) {
88
+ const dashCount = Math.max(2, width - visLen(title) - 3)
89
+ return ` ${cb(THEME.text, title)} ${subtle(SYM.dash.repeat(dashCount))}`
90
+ }
91
+
92
+ // ─── Smart Value Hints ──────────────────────────────────────────────────────────
93
+
94
+ const VALUE_HINTS = [
95
+ {test: /^(DATABASE_URL|DB_URL|POSTGRES_URL)$/i, type: "URL", hint: "Database connection string"},
96
+ {test: /^(MONGO_URI|MONGODB_URI)$/i, type: "URL", hint: "MongoDB connection string"},
97
+ {test: /^(REDIS_URL|REDIS_URI)$/i, type: "URL", hint: "Redis connection string"},
98
+ {test: /^(NEXT_PUBLIC_|REACT_APP_|VITE_)?(API_URL|BASE_URL|APP_URL|SITE_URL|SERVER_URL|BACKEND_URL)$/i, type: "URL", hint: "HTTP endpoint"},
99
+ {test: /(SUPABASE_URL)$/i, type: "URL", hint: "Supabase project URL"},
100
+ {test: /PORT$/i, type: "Number", hint: "Port number"},
101
+ {test: /(SECRET|TOKEN|API_KEY|PRIVATE_KEY|ACCESS_KEY|ANON_KEY|SERVICE_ROLE_KEY)$/i, type: "Secret", hint: "Sensitive — hidden input"},
102
+ {test: /PASSWORD|PASS$/i, type: "Secret", hint: "Password — hidden input"},
103
+ {test: /(SMTP_HOST|MAIL_HOST|EMAIL_HOST)$/i, type: "Host", hint: "Mail server hostname"},
104
+ {test: /(SMTP_PORT|MAIL_PORT)$/i, type: "Number", hint: "Mail port"},
105
+ {test: /(SMTP_USER|MAIL_USER|EMAIL_USER|MAIL_FROM)$/i, type: "Email", hint: "Email address"},
106
+ {test: /(S3_BUCKET|AWS_BUCKET|BUCKET_NAME)$/i, type: "String", hint: "Bucket name"},
107
+ {test: /(AWS_REGION|REGION)$/i, type: "Region", hint: "Cloud region"},
108
+ {test: /(DEBUG|VERBOSE|LOG_LEVEL)$/i, type: "Flag", hint: "true / false"},
109
+ {test: /(NEXT_PUBLIC_)/, type: "Public", hint: "Exposed to browser"},
110
+ {test: /(REACT_APP_)/, type: "Public", hint: "Exposed to browser"},
111
+ {test: /(VITE_)/, type: "Public", hint: "Exposed to browser"},
112
+ ]
113
+
114
+ function getValueHint(keyName) {
115
+ for (const rule of VALUE_HINTS) {
116
+ if (rule.test.test(keyName)) return rule
117
+ }
118
+ return null
119
+ }
120
+
121
+ // ─── Category Detection ─────────────────────────────────────────────────────────
122
+
123
+ function getCategory(varName) {
124
+ if (/^DATABASE|^DB_|^MONGO|^POSTGRES|^MYSQL|^REDIS|^SUPABASE/.test(varName)) return "Database"
125
+ if (/^NEXT_PUBLIC_/.test(varName)) return "Next.js (Public)"
126
+ if (/^REACT_APP_/.test(varName)) return "React (Public)"
127
+ if (/^VITE_/.test(varName)) return "Vite (Public)"
128
+ if (/^AWS_|^S3_/.test(varName)) return "AWS"
129
+ if (/^SMTP_|^MAIL_|^EMAIL_/.test(varName)) return "Email"
130
+ if (/^STRIPE_/.test(varName)) return "Stripe"
131
+ if (/^FIREBASE_/.test(varName)) return "Firebase"
132
+ if (/^AUTH_|^JWT_|^SESSION_/.test(varName)) return "Auth"
133
+ if (/^SENTRY_/.test(varName)) return "Sentry"
134
+ return null
135
+ }
136
+
137
+ // ─── Inline Help ────────────────────────────────────────────────────────────────
138
+
139
+ function inlineHelp() {
140
+ const cmds = [
141
+ cb(THEME.textSecondary, "skip"),
142
+ cb(THEME.textSecondary, "back"),
143
+ cb(THEME.yellow, "clear"),
144
+ cb(THEME.textSecondary, "list"),
145
+ cb(THEME.red, "exit"),
146
+ ]
147
+ return ` ${subtle("Commands:")} ${cmds.join(subtle(" · "))} ${subtle("Enter = keep ? = help")}`
148
+ }
149
+
150
+ // ─── Full Command Panel ─────────────────────────────────────────────────────────
151
+
152
+ function commandPanel() {
153
+ const w = 20
154
+ const lines = [
155
+ "",
156
+ ` ${cb(THEME.text, "Commands")}`,
157
+ ` ${subtle(SYM.dash.repeat(40))}`,
158
+ "",
159
+ ` ${pad(cb(THEME.accent, "skip"), w)}${dim("Skip this variable")}`,
160
+ ` ${pad(cb(THEME.accent, "back"), w)}${dim("Go to previous variable")}`,
161
+ ` ${pad(cb(THEME.yellow, "clear"), w)}${dim("Set value to empty string")}`,
162
+ ` ${pad(cb(THEME.textSecondary, "list"), w)}${dim("Show remaining variables")}`,
163
+ ` ${pad(cb(THEME.textSecondary, "skipall"), w)}${dim("Skip all remaining")}`,
164
+ ` ${pad(cb(THEME.red, "exit"), w)}${dim("End session")}`,
165
+ "",
166
+ ` ${dim("Press")} ${cb(THEME.text, "Enter")} ${dim("without typing to keep current value")}`,
167
+ ` ${dim("Type")} ${cb(THEME.text, "?")} ${dim("to show this panel")}`,
168
+ "",
169
+ ]
170
+ return lines.join("\n")
171
+ }
172
+
173
+ // ─── Banner ─────────────────────────────────────────────────────────────────────
174
+
175
+ function showBanner() {
176
+ const version = "1.0.0"
177
+
178
+ const logo = [
179
+ " ███████╗███╗ ██╗██╗ ██╗",
180
+ " ██╔════╝████╗ ██║██║ ██║",
181
+ " █████╗ ██╔██╗ ██║██║ ██║",
182
+ " ██╔══╝ ██║╚██╗██║╚██╗ ██╔╝",
183
+ " ███████╗██║ ╚████║ ╚████╔╝ ",
184
+ " ╚══════╝╚═╝ ╚═══╝ ╚═══╝ ",
185
+ ]
186
+
187
+ // Gradient from cyan to blue to purple
188
+ const gradColors = ["#22D3EE", "#06B6D4", "#3B82F6", "#6366F1", "#8B5CF6", "#A78BFA"]
189
+ const coloredLogo = logo.map((l, i) => cb(gradColors[i], l))
190
+
191
+ console.log("")
192
+ coloredLogo.forEach(l => console.log(l))
193
+ console.log("")
194
+ console.log(` ${cb(THEME.text, "setter")} ${subtle("v" + version)}`)
195
+ console.log(` ${dim("Interactive environment variable manager")}`)
196
+ console.log("")
197
+ console.log(` ${subtle(SYM.dash.repeat(44))}`)
198
+ console.log(` ${dim("Created by")} ${cb(THEME.text, "Zain Afzal")} ${subtle(SYM.bullet)} ${c(THEME.accent, "zainafzal.dev")}`)
199
+ console.log(` ${subtle(SYM.dash.repeat(44))}`)
200
+ console.log("")
201
+ }
202
+
203
+ // ─── Scan Result Summary ────────────────────────────────────────────────────────
204
+
205
+ function showScanResult(foundVars, existingEnv) {
206
+ const hasUsableValue = key => {
207
+ if (!existingEnv.has(key)) return false
208
+ const value = existingEnv.get(key)
209
+ return typeof value === "string" && value.trim().length > 0
210
+ }
211
+
212
+ const total = foundVars.size
213
+ const alreadySet = [...foundVars.keys()].filter(hasUsableValue).length
214
+ const missing = total - alreadySet
215
+ const pct = total > 0 ? Math.round((alreadySet / total) * 100) : 0
216
+ const pctColor = pct >= 80 ? THEME.green : pct >= 50 ? THEME.yellow : THEME.red
217
+
218
+ console.log(sectionLine("Scan Results"))
219
+ console.log("")
220
+ console.log(` ${c(THEME.textSecondary, "Total")} ${cb(THEME.text, String(total))}`)
221
+ console.log(` ${c(THEME.green, "Set")} ${cb(THEME.green, String(alreadySet))}`)
222
+ console.log(` ${c(THEME.red, "Missing")} ${cb(THEME.red, String(missing))}`)
223
+ console.log("")
224
+ console.log(` ${progressBar(alreadySet, total)} ${cb(pctColor, pct + "%")} ${dim("coverage")}`)
225
+ console.log("")
226
+
227
+ return {total, alreadySet, missing}
228
+ }
229
+
230
+ // ─── Mode Selector ──────────────────────────────────────────────────────────────
231
+
232
+ async function askMode(missingCount, alreadySetCount) {
233
+ console.log(sectionLine("Action"))
234
+ console.log("")
235
+
236
+ const choices = []
237
+
238
+ if (missingCount > 0) {
239
+ choices.push({
240
+ name: ` ${c(THEME.accent, SYM.triRight)} Fill missing variables ${dim("(" + missingCount + ")")}`,
241
+ value: "missing",
242
+ })
243
+ }
244
+
245
+ if (alreadySetCount > 0) {
246
+ const editLabel = missingCount > 0 ? "Edit all variables" : "Edit existing variables"
247
+ const editCount = missingCount > 0 ? missingCount + alreadySetCount : alreadySetCount
248
+ choices.push({
249
+ name: ` ${c(THEME.purple, "✎")} ${editLabel} ${dim("(" + editCount + ")")}`,
250
+ value: "all",
251
+ })
252
+ }
253
+
254
+ choices.push({
255
+ name: ` ${c(THEME.cyan, "⬆")} Bulk paste ${dim("paste entire .env content")}`,
256
+ value: "bulk",
257
+ })
258
+
259
+ choices.push({
260
+ name: ` ${c(THEME.textMuted, SYM.cross)} Exit`,
261
+ value: "exit",
262
+ })
263
+
264
+ const {mode} = await inquirer.prompt([{
265
+ type: "list",
266
+ name: "mode",
267
+ message: cb(THEME.textSecondary, "Select mode"),
268
+ choices,
269
+ pageSize: 8,
270
+ prefix: c(THEME.accent, " ?"),
271
+ }])
272
+
273
+ return mode
274
+ }
275
+
276
+ // ─── Env File Selector ──────────────────────────────────────────────────────────
277
+
278
+ const WRITABLE_ENV_FILES = [".env", ".env.local", ".env.development", ".env.production"]
279
+
280
+ async function askEnvFile(cwd) {
281
+ console.log(sectionLine("Target File"))
282
+ console.log("")
283
+
284
+ let allFiles = []
285
+ try {
286
+ allFiles = fs.readdirSync(cwd).filter(f => {
287
+ if (!f.startsWith(".env")) return false
288
+ try { return fs.statSync(path.join(cwd, f)).isFile() } catch { return false }
289
+ }).sort()
290
+ } catch {
291
+ // fallback
292
+ }
293
+
294
+ const choices = []
295
+
296
+ if (allFiles.length > 0) {
297
+ allFiles.forEach(f => {
298
+ const isExample = f.includes("example") || f.includes("sample") || f.includes("template")
299
+ const tagText = isExample ? "template" : "exists"
300
+ choices.push({
301
+ name: ` ${c(THEME.green, SYM.check)} ${cb(THEME.text, f)} ${dim(tagText)}`,
302
+ value: f,
303
+ })
304
+ })
305
+ }
306
+
307
+ const missingStandard = WRITABLE_ENV_FILES.filter(f => !allFiles.includes(f))
308
+ if (missingStandard.length > 0) {
309
+ if (choices.length > 0) {
310
+ choices.push(new inquirer.Separator(subtle(" " + SYM.dash.repeat(30))))
311
+ }
312
+ missingStandard.forEach(f => {
313
+ choices.push({
314
+ name: ` ${c(THEME.textSubtle, "+")} ${c(THEME.textSecondary, f)} ${dim("create new")}`,
315
+ value: f,
316
+ })
317
+ })
318
+ }
319
+
320
+ choices.push({name: ` ${c(THEME.textMuted, SYM.ellipsis)} Custom path`, value: "custom"})
321
+
322
+ const {envFile} = await inquirer.prompt([{
323
+ type: "list",
324
+ name: "envFile",
325
+ message: cb(THEME.textSecondary, "Write to"),
326
+ choices,
327
+ pageSize: 12,
328
+ prefix: c(THEME.accent, " ?"),
329
+ }])
330
+
331
+ if (envFile === "custom") {
332
+ const {customPath} = await inquirer.prompt([{
333
+ type: "input",
334
+ name: "customPath",
335
+ message: cb(THEME.textSecondary, "Enter path"),
336
+ default: ".env",
337
+ prefix: c(THEME.accent, " ?"),
338
+ validate: input => {
339
+ if (!input || !input.trim()) return chalk.hex(THEME.red)("Path cannot be empty")
340
+ return true
341
+ },
342
+ }])
343
+ return customPath.trim()
344
+ }
345
+
346
+ return envFile
347
+ }
348
+
349
+ // ─── Interactive Prompt Loop ────────────────────────────────────────────────────
350
+
351
+ async function promptForValues(varsToFill, existingEnv, foundVars, onSetValue) {
352
+ const results = new Map()
353
+ const varList = [...varsToFill].sort()
354
+ const total = varList.length
355
+ let exitRequested = false
356
+ let skippedCount = 0
357
+ let lastCategory = null
358
+
359
+ console.log(commandPanel())
360
+
361
+ for (let i = 0; i < varList.length; i++) {
362
+ const varName = varList[i]
363
+ const currentValue = existingEnv.get(varName) || ""
364
+ const locations = foundVars.get(varName)
365
+ const secretLike = isSensitiveKey(varName)
366
+ const stepNum = i + 1
367
+ const hint = getValueHint(varName)
368
+
369
+ // ─── Category Header ─────────────────────────────
370
+ const category = getCategory(varName)
371
+ if (category && category !== lastCategory) {
372
+ console.log("")
373
+ console.log(` ${cb(THEME.purple, category)}`)
374
+ lastCategory = category
375
+ }
376
+
377
+ // ─── Variable Card ───────────────────────────────
378
+ const pct = Math.round((i / total) * 100)
379
+
380
+ const cardLines = []
381
+
382
+ // Header line: name + counter
383
+ cardLines.push(
384
+ `${cb(THEME.text, varName)} ${subtle(`${stepNum}/${total}`)} ${subtle(pct + "%")}`,
385
+ )
386
+
387
+ // Progress bar
388
+ cardLines.push(progressBar(i, total, 28))
389
+
390
+ // Type hint
391
+ if (hint) {
392
+ const typeColor = hint.type === "Secret" ? THEME.yellow : THEME.accent
393
+ cardLines.push(`${c(typeColor, hint.type)} ${subtle(SYM.triSmall)} ${dim(hint.hint)}`)
394
+ }
395
+
396
+ // Source files
397
+ if (locations && locations.size > 0) {
398
+ const files = [...locations].slice(0, 3)
399
+ const extra = locations.size > 3 ? dim(` +${locations.size - 3} more`) : ""
400
+ cardLines.push(`${dim("in")} ${files.map(f => c(THEME.textSecondary, f)).join(dim(", "))}${extra}`)
401
+ }
402
+
403
+ // Current value
404
+ if (currentValue) {
405
+ cardLines.push(`${statusDot(THEME.green)} ${dim("current:")} ${dim(maskValue(currentValue))}`)
406
+ } else {
407
+ cardLines.push(`${statusDot(THEME.yellow)} ${dim("not set")}`)
408
+ }
409
+
410
+ console.log("")
411
+ console.log(boxen(cardLines.join("\n"), {
412
+ padding: {top: 0, bottom: 0, left: 1, right: 1},
413
+ margin: {top: 0, bottom: 0, left: 1, right: 0},
414
+ borderStyle: "round",
415
+ borderColor: THEME.border,
416
+ }))
417
+
418
+ // ─── Value Input ─────────────────────────────────
419
+ while (true) {
420
+ const promptIcon = secretLike
421
+ ? c(THEME.yellow, " " + SYM.diamond)
422
+ : c(THEME.accent, " " + SYM.triSmall)
423
+
424
+ const {value} = await inquirer.prompt([{
425
+ type: secretLike ? "password" : "input",
426
+ mask: secretLike ? "•" : undefined,
427
+ name: "value",
428
+ message: cb(THEME.textSecondary, secretLike ? "Secret" : "Value"),
429
+ default: currentValue || undefined,
430
+ prefix: promptIcon,
431
+ }])
432
+
433
+ const trimmed = value.trim()
434
+ const cmd = trimmed.toLowerCase()
435
+
436
+ // ─── Command: exit ──────────────────────────────
437
+ if (cmd === "quit" || cmd === "exit") {
438
+ const {confirmExit} = await inquirer.prompt([{
439
+ type: "confirm",
440
+ name: "confirmExit",
441
+ message: dim("End session? Saved values are kept."),
442
+ default: true,
443
+ prefix: c(THEME.yellow, " " + SYM.warn),
444
+ }])
445
+ if (confirmExit) {
446
+ console.log("")
447
+ console.log(` ${c(THEME.yellow, SYM.warn)} ${dim("Session ended early.")}`)
448
+ console.log("")
449
+ exitRequested = true
450
+ break
451
+ }
452
+ continue
453
+ }
454
+
455
+ // ─── Command: help ──────────────────────────────
456
+ if (cmd === "help" || cmd === "?") {
457
+ console.log(commandPanel())
458
+ continue
459
+ }
460
+
461
+ // ─── Command: list ──────────────────────────────
462
+ if (cmd === "list") {
463
+ const remaining = varList.slice(i + 1)
464
+ if (remaining.length === 0) {
465
+ console.log(dim(" This is the last variable."))
466
+ } else {
467
+ console.log(dim(` Remaining (${remaining.length}):`))
468
+ remaining.forEach((v, idx) => {
469
+ const cat = getCategory(v)
470
+ const catLabel = cat ? dim(` [${cat}]`) : ""
471
+ const num = subtle(`${(idx + 1).toString().padStart(2)}.`)
472
+ console.log(` ${num} ${c(THEME.textSecondary, v)}${catLabel}`)
473
+ })
474
+ }
475
+ console.log("")
476
+ continue
477
+ }
478
+
479
+ // ─── Command: back ──────────────────────────────
480
+ if (cmd === "back") {
481
+ if (i === 0) {
482
+ console.log(dim(` ${SYM.arrow} Already at first variable`))
483
+ continue
484
+ }
485
+ i -= 2
486
+ lastCategory = null
487
+ console.log(dim(` ${SYM.arrow} Going back...`))
488
+ break
489
+ }
490
+
491
+ // ─── Command: skip ──────────────────────────────
492
+ if (cmd === "skip") {
493
+ skippedCount += 1
494
+ console.log(` ${subtle(SYM.arrow)} ${dim("skipped")}`)
495
+ break
496
+ }
497
+
498
+ // ─── Command: skipall ───────────────────────────
499
+ if (cmd === "skipall") {
500
+ const remaining = total - (i + 1)
501
+ const {confirmSkip} = await inquirer.prompt([{
502
+ type: "confirm",
503
+ name: "confirmSkip",
504
+ message: dim(`Skip all ${remaining + 1} remaining?`),
505
+ default: false,
506
+ prefix: c(THEME.yellow, " " + SYM.warn),
507
+ }])
508
+ if (confirmSkip) {
509
+ skippedCount += remaining + 1
510
+ console.log(dim(` ${SYM.arrow} Skipped ${remaining + 1} variables`))
511
+ exitRequested = true
512
+ break
513
+ }
514
+ continue
515
+ }
516
+
517
+ // ─── Save Value ────────────────────────────────
518
+ const finalValue = cmd === "clear" ? "" : value
519
+
520
+ if (typeof onSetValue === "function") {
521
+ await onSetValue(varName, finalValue)
522
+ }
523
+
524
+ results.set(varName, finalValue)
525
+ const unchanged = currentValue === finalValue
526
+ const icon = unchanged ? subtle(SYM.check) : c(THEME.green, SYM.check)
527
+ const saveText = unchanged ? dim("unchanged") : c(THEME.green, "saved")
528
+ const remaining = total - (i + 1)
529
+ const stats = subtle(`${results.size} done ${SYM.bullet} ${skippedCount} skipped ${SYM.bullet} ${remaining} left`)
530
+
531
+ console.log(` ${icon} ${saveText} ${subtle(SYM.bar)} ${stats}`)
532
+ break
533
+ }
534
+
535
+ if (exitRequested) break
536
+ }
537
+
538
+ return results
539
+ }
540
+
541
+ // ─── Masking ────────────────────────────────────────────────────────────────────
542
+
543
+ function maskValue(value) {
544
+ if (!value) return dim("(empty)")
545
+ if (value.length <= 6) return dim("••••••")
546
+ return dim(value.substring(0, 3) + "••••" + value.substring(value.length - 2))
547
+ }
548
+
549
+ function isSensitiveKey(key) {
550
+ return /(SECRET|TOKEN|PASSWORD|PASS|KEY|PRIVATE|AUTH|CREDENTIAL)/i.test(key)
551
+ }
552
+
553
+ // ─── Final Summary ──────────────────────────────────────────────────────────────
554
+
555
+ function showSummary(saved, envFilePath) {
556
+ console.log("")
557
+
558
+ if (saved === 0) {
559
+ console.log(` ${c(THEME.yellow, SYM.warn)} ${cb(THEME.yellow, "No changes made")}`)
560
+ console.log(` ${dim("All variables were skipped or already set.")}`)
561
+ console.log("")
562
+ return
563
+ }
564
+
565
+ const lines = [
566
+ "",
567
+ ` ${c(THEME.green, SYM.check)} ${cb(THEME.green, "Done")}`,
568
+ "",
569
+ ` ${dim("Saved")} ${cb(THEME.text, String(saved))} ${dim(saved > 1 ? "variables" : "variable")}`,
570
+ ` ${dim("Target")} ${cb(THEME.accent, envFilePath)}`,
571
+ "",
572
+ ` ${subtle("Make sure " + envFilePath + " is in .gitignore")}`,
573
+ "",
574
+ ].join("\n")
575
+
576
+ console.log(lines)
577
+ }
578
+
579
+ // ─── Folder Picker ──────────────────────────────────────────────────────────────
580
+
581
+ function askFolder(folders) {
582
+ if (folders.length <= 1) {
583
+ return Promise.resolve(folders[0] || null)
584
+ }
585
+
586
+ console.log(sectionLine("Project Folders"))
587
+ console.log("")
588
+ console.log(dim(" Multiple folders with env files detected:"))
589
+ console.log("")
590
+
591
+ const choices = folders.map(folder => {
592
+ const isRoot = folder.relPath === "."
593
+ const folderName = isRoot ? "./ (root)" : folder.relPath
594
+ const fileCount = folder.envFiles.length
595
+ const fileList = folder.envFiles
596
+ .map(f => c(THEME.textSecondary, f))
597
+ .join(dim(", "))
598
+
599
+ return {
600
+ name: ` ${c(THEME.accent, SYM.triSmall)} ${cb(THEME.text, folderName)} ${dim(`${fileCount} file${fileCount > 1 ? "s" : ""}:`)} ${fileList}`,
601
+ value: folder,
602
+ short: folderName,
603
+ }
604
+ })
605
+
606
+ choices.push(new inquirer.Separator(subtle(" " + SYM.dash.repeat(30))))
607
+ choices.push({
608
+ name: ` ${c(THEME.purple, "+")} ${c(THEME.purple, "Edit all folders")} ${dim(`(${folders.length})`)}`,
609
+ value: "all",
610
+ short: "All folders",
611
+ })
612
+
613
+ return inquirer.prompt([{
614
+ type: "list",
615
+ name: "folder",
616
+ message: cb(THEME.textSecondary, "Select folder"),
617
+ choices,
618
+ pageSize: 15,
619
+ prefix: c(THEME.accent, " ?"),
620
+ }]).then(a => a.folder)
621
+ }
622
+
623
+ // ─── Bulk Paste ─────────────────────────────────────────────────────────────────
624
+
625
+ function parseBulkInput(raw) {
626
+ const result = new Map()
627
+ const lines = raw.split(/\r?\n/)
628
+
629
+ for (const line of lines) {
630
+ const trimmed = line.trim()
631
+ if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("//")) continue
632
+
633
+ const eqIndex = trimmed.indexOf("=")
634
+ if (eqIndex === -1) continue
635
+
636
+ const key = trimmed.substring(0, eqIndex).trim()
637
+ let value = trimmed.substring(eqIndex + 1).trim()
638
+
639
+ if (!key || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue
640
+
641
+ if (
642
+ (value.startsWith('"') && value.endsWith('"')) ||
643
+ (value.startsWith("'") && value.endsWith("'"))
644
+ ) {
645
+ value = value.slice(1, -1)
646
+ }
647
+
648
+ const commentIdx = value.indexOf(" #")
649
+ if (commentIdx > -1) {
650
+ value = value.substring(0, commentIdx).trim()
651
+ }
652
+
653
+ result.set(key, value)
654
+ }
655
+ return result
656
+ }
657
+
658
+ async function askBulkPaste() {
659
+ const readline = require("readline")
660
+
661
+ console.log("")
662
+ console.log(sectionLine("Bulk Paste"))
663
+ console.log("")
664
+ console.log(dim(" Paste your env content below and press Enter."))
665
+ console.log(dim(" Trailing empty lines are automatically ignored."))
666
+ console.log("")
667
+
668
+ const collectedLines = []
669
+
670
+ const content = await new Promise((resolve) => {
671
+ const rl = readline.createInterface({
672
+ input: process.stdin,
673
+ output: process.stdout,
674
+ prompt: c(THEME.accent, " " + SYM.triSmall + " "),
675
+ })
676
+
677
+ let timer = null
678
+ const DEBOUNCE_MS = 500
679
+
680
+ const finish = () => {
681
+ if (timer) clearTimeout(timer)
682
+ rl.close()
683
+ resolve(collectedLines.join("\n"))
684
+ }
685
+
686
+ rl.prompt()
687
+
688
+ rl.on("line", (line) => {
689
+ if (line.trim() === "" && collectedLines.length > 0) {
690
+ finish()
691
+ return
692
+ }
693
+
694
+ if (line.trim() === "" && collectedLines.length === 0) {
695
+ rl.prompt()
696
+ return
697
+ }
698
+
699
+ collectedLines.push(line)
700
+
701
+ const varCount = parseBulkInput(collectedLines.join("\n")).size
702
+ console.log(dim(` ${SYM.check} ${varCount} var${varCount !== 1 ? "s" : ""} detected`))
703
+
704
+ if (timer) clearTimeout(timer)
705
+ timer = setTimeout(finish, DEBOUNCE_MS)
706
+
707
+ rl.prompt()
708
+ })
709
+
710
+ rl.on("close", () => {
711
+ if (timer) clearTimeout(timer)
712
+ resolve(collectedLines.join("\n"))
713
+ })
714
+ })
715
+
716
+ if (!content || !content.trim()) {
717
+ console.log(dim(" No content pasted."))
718
+ return null
719
+ }
720
+
721
+ const parsed = parseBulkInput(content)
722
+
723
+ if (parsed.size === 0) {
724
+ console.log(` ${c(THEME.yellow, SYM.warn)} ${dim("No valid KEY=VALUE pairs found.")}`)
725
+ console.log(dim(" Expected format: KEY=value or KEY=\"value\""))
726
+ console.log("")
727
+ return null
728
+ }
729
+
730
+ const keys = [...parsed.keys()]
731
+ const secretKeys = keys.filter(k => isSensitiveKey(k))
732
+ const publicKeys = keys.filter(k => !isSensitiveKey(k))
733
+
734
+ console.log("")
735
+ console.log(sectionLine(`Found ${keys.length} variable${keys.length > 1 ? "s" : ""}`))
736
+ console.log("")
737
+
738
+ publicKeys.forEach(k => {
739
+ const val = parsed.get(k)
740
+ const displayVal = val ? c(THEME.textSecondary, val.length > 40 ? val.substring(0, 37) + "..." : val) : dim("(empty)")
741
+ console.log(` ${c(THEME.green, SYM.check)} ${c(THEME.text, k)} ${subtle("=")} ${displayVal}`)
742
+ })
743
+ secretKeys.forEach(k => {
744
+ console.log(` ${c(THEME.green, SYM.check)} ${c(THEME.text, k)} ${subtle("=")} ${dim(maskValue(parsed.get(k)))} ${dim("[secret]")}`)
745
+ })
746
+ console.log("")
747
+
748
+ const {confirm} = await inquirer.prompt([{
749
+ type: "confirm",
750
+ name: "confirm",
751
+ message: cb(THEME.textSecondary, `Write ${keys.length} variable${keys.length > 1 ? "s" : ""}?`),
752
+ default: true,
753
+ prefix: c(THEME.accent, " ?"),
754
+ }])
755
+
756
+ if (!confirm) {
757
+ console.log(dim(" Cancelled."))
758
+ return null
759
+ }
760
+
761
+ return parsed
762
+ }
763
+
764
+ module.exports = {
765
+ showBanner,
766
+ showScanResult,
767
+ askMode,
768
+ askEnvFile,
769
+ askFolder,
770
+ promptForValues,
771
+ askBulkPaste,
772
+ showSummary,
773
+ }