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/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
|
+
}
|