envprism 0.3.0 → 0.3.1
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 +46 -43
- package/dist/bin/envprism.mjs +254 -262
- package/dist/bin/envprism.mjs.map +1 -1
- package/dist/chunks/app-Bp7QXiLx.mjs +1965 -0
- package/dist/chunks/app-Bp7QXiLx.mjs.map +1 -0
- package/dist/chunks/matrix-CG1Msljr.mjs +740 -0
- package/dist/chunks/matrix-CG1Msljr.mjs.map +1 -0
- package/dist/chunks/serialize-DWF9VxrS.mjs +59 -0
- package/dist/chunks/serialize-DWF9VxrS.mjs.map +1 -0
- package/dist/config/define.mjs +14 -5
- package/dist/config/define.mjs.map +1 -1
- package/dist/index.mjs +3 -18
- package/package.json +16 -16
- package/dist/chunks/app-DgjyG_HE.mjs +0 -1800
- package/dist/chunks/app-DgjyG_HE.mjs.map +0 -1
- package/dist/chunks/matrix-C1pErf9Z.mjs +0 -676
- package/dist/chunks/matrix-C1pErf9Z.mjs.map +0 -1
- package/dist/chunks/serialize-D_ZNIgRa.mjs +0 -41
- package/dist/chunks/serialize-D_ZNIgRa.mjs.map +0 -1
- package/dist/index.mjs.map +0 -1
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
import { readFile, readdir, stat } from "node:fs/promises";
|
|
2
|
+
import consola from "consola";
|
|
3
|
+
import { basename, dirname, join } from "pathe";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { loadConfig } from "c12";
|
|
6
|
+
import { defu } from "defu";
|
|
7
|
+
//#region src/core/mask.ts
|
|
8
|
+
var SECRET_TOKENS = [
|
|
9
|
+
"SECRET",
|
|
10
|
+
"TOKEN",
|
|
11
|
+
"PASSWORD",
|
|
12
|
+
"PASSWD",
|
|
13
|
+
"PWD",
|
|
14
|
+
"KEY",
|
|
15
|
+
"PRIVATE",
|
|
16
|
+
"CREDENTIAL",
|
|
17
|
+
"AUTH",
|
|
18
|
+
"DSN"
|
|
19
|
+
];
|
|
20
|
+
/**
|
|
21
|
+
* Heuristic: should a value with this key be masked by default? Matches any
|
|
22
|
+
* underscore-separated segment of {@link SECRET_TOKENS} (case-insensitive),
|
|
23
|
+
* with carve-outs for false positives like `*_PUBLIC_KEY`, `PUBLIC_*`, and
|
|
24
|
+
* keys whose final segment is `ID` (e.g. `API_KEY_ID` is an identifier, not
|
|
25
|
+
* a secret).
|
|
26
|
+
*/
|
|
27
|
+
function isSecretKey(key, tokens = SECRET_TOKENS) {
|
|
28
|
+
const segments = key.toUpperCase().split("_").filter(Boolean);
|
|
29
|
+
if (segments.length === 0) return false;
|
|
30
|
+
if (segments[segments.length - 1] === "ID") return false;
|
|
31
|
+
if (segments[0] === "PUBLIC") return false;
|
|
32
|
+
if (segments.includes("PUBLIC")) return false;
|
|
33
|
+
return segments.some((seg) => tokens.some((token) => seg === token || seg.endsWith(token)));
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Render a masked placeholder that hints at the original value's length
|
|
37
|
+
* without leaking content. Returns `••••` when the input is empty.
|
|
38
|
+
*/
|
|
39
|
+
function maskValue(value) {
|
|
40
|
+
if (value.length === 0) return "••••";
|
|
41
|
+
const dots = "•".repeat(Math.min(value.length, 8));
|
|
42
|
+
return value.length > 8 ? `${dots} (${value.length})` : dots;
|
|
43
|
+
}
|
|
44
|
+
//#endregion
|
|
45
|
+
//#region src/config/schema.ts
|
|
46
|
+
/**
|
|
47
|
+
* Built-in defaults — the single source of truth for every tunable value.
|
|
48
|
+
* Mirrors the previously hardcoded constants (mask.ts SECRET_TOKENS,
|
|
49
|
+
* format.ts PLACEHOLDER_RE atoms, discover.ts SKIP_SUFFIXES, theme.ts layout).
|
|
50
|
+
*/
|
|
51
|
+
var DEFAULT_CONFIG = {
|
|
52
|
+
discovery: {
|
|
53
|
+
paths: ["."],
|
|
54
|
+
skipSuffixes: [
|
|
55
|
+
".swp",
|
|
56
|
+
"~",
|
|
57
|
+
".bak"
|
|
58
|
+
],
|
|
59
|
+
skipSuffixesExtra: [],
|
|
60
|
+
exampleFirst: true
|
|
61
|
+
},
|
|
62
|
+
base: {
|
|
63
|
+
name: ".env.example",
|
|
64
|
+
priority: []
|
|
65
|
+
},
|
|
66
|
+
heuristics: {
|
|
67
|
+
secretTokens: [
|
|
68
|
+
"SECRET",
|
|
69
|
+
"TOKEN",
|
|
70
|
+
"PASSWORD",
|
|
71
|
+
"PASSWD",
|
|
72
|
+
"PWD",
|
|
73
|
+
"KEY",
|
|
74
|
+
"PRIVATE",
|
|
75
|
+
"CREDENTIAL",
|
|
76
|
+
"AUTH",
|
|
77
|
+
"DSN"
|
|
78
|
+
],
|
|
79
|
+
secretTokensExtra: [],
|
|
80
|
+
placeholders: [
|
|
81
|
+
"todo",
|
|
82
|
+
"fixme",
|
|
83
|
+
"changeme",
|
|
84
|
+
"placeholder",
|
|
85
|
+
"tbd",
|
|
86
|
+
"x{3,}",
|
|
87
|
+
"your[_-]?(secret|key|token|password|api[_-]?key)(_here)?",
|
|
88
|
+
"replace[_-]?me"
|
|
89
|
+
],
|
|
90
|
+
placeholdersExtra: [],
|
|
91
|
+
grouping: "auto"
|
|
92
|
+
},
|
|
93
|
+
diff: {
|
|
94
|
+
json: false,
|
|
95
|
+
checkExitCode: 1
|
|
96
|
+
},
|
|
97
|
+
tui: {
|
|
98
|
+
theme: {},
|
|
99
|
+
layout: {
|
|
100
|
+
keyColWidth: 22,
|
|
101
|
+
valueColMin: 18,
|
|
102
|
+
sidebarWidth: 30,
|
|
103
|
+
rowGap: 0,
|
|
104
|
+
cellPadX: 1
|
|
105
|
+
},
|
|
106
|
+
undoLimit: 50,
|
|
107
|
+
maskSecrets: true
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
//#endregion
|
|
111
|
+
//#region src/tui/format.ts
|
|
112
|
+
var PLACEHOLDER_RE = /^(todo|fixme|changeme|placeholder|tbd|x{3,}|your[_-]?(secret|key|token|password|api[_-]?key)(_here)?|replace[_-]?me)$/i;
|
|
113
|
+
function isPlaceholderValue(value, re = PLACEHOLDER_RE) {
|
|
114
|
+
const v = value.trim();
|
|
115
|
+
if (v.length === 0) return false;
|
|
116
|
+
return re.test(v);
|
|
117
|
+
}
|
|
118
|
+
function formatValue(value, secret) {
|
|
119
|
+
if (value === void 0) return "";
|
|
120
|
+
if (secret) return maskValue(value);
|
|
121
|
+
return value;
|
|
122
|
+
}
|
|
123
|
+
function matchesFilter(key, filter) {
|
|
124
|
+
if (!filter) return true;
|
|
125
|
+
return key.toLowerCase().includes(filter.toLowerCase());
|
|
126
|
+
}
|
|
127
|
+
function truncate(text, width) {
|
|
128
|
+
if (width <= 0) return "";
|
|
129
|
+
if (text.length <= width) return text;
|
|
130
|
+
if (width <= 1) return "…";
|
|
131
|
+
return `${text.slice(0, width - 1)}…`;
|
|
132
|
+
}
|
|
133
|
+
function findKvEntry(file, key) {
|
|
134
|
+
for (const e of file.entries) if (e.kind === "kv" && e.key === key) return e;
|
|
135
|
+
}
|
|
136
|
+
//#endregion
|
|
137
|
+
//#region src/config/resolve.ts
|
|
138
|
+
/** Hex source of truth for the TUI palette (resolved to RGBA in theme.ts). */
|
|
139
|
+
var DEFAULT_THEME_HEX = {
|
|
140
|
+
fg: "#cccccc",
|
|
141
|
+
fgDim: "#666666",
|
|
142
|
+
fgHeader: "#ffffff",
|
|
143
|
+
fgBase: "#82aaff",
|
|
144
|
+
fgSection: "#82aaff",
|
|
145
|
+
differs: "#ffd866",
|
|
146
|
+
extra: "#ffd866",
|
|
147
|
+
placeholder: "#ffd866",
|
|
148
|
+
modified: "#7fce6a",
|
|
149
|
+
fgDirty: "#7fce6a",
|
|
150
|
+
missing: "#ff6b6b",
|
|
151
|
+
focusBg: "#3a3f4b"
|
|
152
|
+
};
|
|
153
|
+
var HEX_RE = /^#[0-9a-fA-F]{6}$/;
|
|
154
|
+
/**
|
|
155
|
+
* Merge user hex overrides onto {@link DEFAULT_THEME_HEX}. Invalid hex values
|
|
156
|
+
* warn (light runtime guard) and fall back to the default. RGBA-free so it is
|
|
157
|
+
* unit-testable under Node without loading the TUI runtime; theme.ts wraps the
|
|
158
|
+
* result with RGBA.fromHex.
|
|
159
|
+
*/
|
|
160
|
+
function resolveThemeHex(theme = {}, warn = consola.warn) {
|
|
161
|
+
const out = { ...DEFAULT_THEME_HEX };
|
|
162
|
+
for (const key of Object.keys(DEFAULT_THEME_HEX)) {
|
|
163
|
+
const override = theme[key];
|
|
164
|
+
if (override === void 0) continue;
|
|
165
|
+
if (HEX_RE.test(override)) out[key] = override;
|
|
166
|
+
else warn(`envprism: ignoring invalid hex for theme.${key}: "${override}"`);
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Resolve a replace-or-extend list field. An explicit `replace` list wins over
|
|
172
|
+
* the built-in `fallback`; `extra` is always appended to whatever the current
|
|
173
|
+
* list is. De-duplicated so repeated entries are harmless.
|
|
174
|
+
*/
|
|
175
|
+
function pickList(replace, extra, fallback) {
|
|
176
|
+
return [.../* @__PURE__ */ new Set([...replace ?? fallback, ...extra ?? []])];
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Merge a deep-partial user config onto {@link DEFAULT_CONFIG}. Scalars and
|
|
180
|
+
* plain objects use defu (user wins, gaps fall back). List fields are handled
|
|
181
|
+
* manually because defu concatenates arrays — wrong for our replace semantics:
|
|
182
|
+
* `*` lists replace the default, `*Extra` lists append. The folded result puts
|
|
183
|
+
* everything into the canonical field and leaves `*Extra` empty.
|
|
184
|
+
*/
|
|
185
|
+
function mergeConfig(user = {}) {
|
|
186
|
+
return {
|
|
187
|
+
discovery: {
|
|
188
|
+
paths: user.discovery?.paths ?? DEFAULT_CONFIG.discovery.paths,
|
|
189
|
+
skipSuffixes: pickList(user.discovery?.skipSuffixes, user.discovery?.skipSuffixesExtra, DEFAULT_CONFIG.discovery.skipSuffixes),
|
|
190
|
+
skipSuffixesExtra: [],
|
|
191
|
+
exampleFirst: user.discovery?.exampleFirst ?? DEFAULT_CONFIG.discovery.exampleFirst
|
|
192
|
+
},
|
|
193
|
+
base: {
|
|
194
|
+
name: user.base?.name ?? DEFAULT_CONFIG.base.name,
|
|
195
|
+
priority: user.base?.priority ?? DEFAULT_CONFIG.base.priority
|
|
196
|
+
},
|
|
197
|
+
heuristics: {
|
|
198
|
+
secretTokens: pickList(user.heuristics?.secretTokens, user.heuristics?.secretTokensExtra, DEFAULT_CONFIG.heuristics.secretTokens),
|
|
199
|
+
secretTokensExtra: [],
|
|
200
|
+
placeholders: pickList(user.heuristics?.placeholders, user.heuristics?.placeholdersExtra, DEFAULT_CONFIG.heuristics.placeholders),
|
|
201
|
+
placeholdersExtra: [],
|
|
202
|
+
grouping: user.heuristics?.grouping ?? DEFAULT_CONFIG.heuristics.grouping
|
|
203
|
+
},
|
|
204
|
+
diff: defu(user.diff, DEFAULT_CONFIG.diff),
|
|
205
|
+
tui: {
|
|
206
|
+
theme: defu(user.tui?.theme, DEFAULT_CONFIG.tui.theme),
|
|
207
|
+
layout: defu(user.tui?.layout, DEFAULT_CONFIG.tui.layout),
|
|
208
|
+
undoLimit: user.tui?.undoLimit ?? DEFAULT_CONFIG.tui.undoLimit,
|
|
209
|
+
maskSecrets: user.tui?.maskSecrets ?? DEFAULT_CONFIG.tui.maskSecrets
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Compile the resolved heuristics config into ready-to-call matchers. Secret
|
|
215
|
+
* tokens are upper-cased (the matcher upper-cases keys); placeholder atoms are
|
|
216
|
+
* compiled into a single case-insensitive alternation. RGBA-free.
|
|
217
|
+
*/
|
|
218
|
+
function resolveHeuristics(c) {
|
|
219
|
+
const tokens = c.heuristics.secretTokens.map((t) => t.toUpperCase());
|
|
220
|
+
const placeholderRe = new RegExp(`^(${c.heuristics.placeholders.join("|")})$`, "i");
|
|
221
|
+
return {
|
|
222
|
+
isSecretKey: (key) => isSecretKey(key, tokens),
|
|
223
|
+
isPlaceholderValue: (value) => isPlaceholderValue(value, placeholderRe),
|
|
224
|
+
grouping: c.heuristics.grouping
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
//#endregion
|
|
228
|
+
//#region src/config/load.ts
|
|
229
|
+
var CONFIG_EXTS = [
|
|
230
|
+
"ts",
|
|
231
|
+
"js",
|
|
232
|
+
"mjs",
|
|
233
|
+
"json"
|
|
234
|
+
];
|
|
235
|
+
/**
|
|
236
|
+
* Walk up from `start` looking for envprism.config.{ts,js,mjs,json}. c12 does
|
|
237
|
+
* not climb ancestors itself, so we resolve the file path here and hand it to
|
|
238
|
+
* c12 explicitly. Returns the first match or undefined at the filesystem root.
|
|
239
|
+
*/
|
|
240
|
+
function findConfigUp(start) {
|
|
241
|
+
let dir = start;
|
|
242
|
+
for (;;) {
|
|
243
|
+
for (const ext of CONFIG_EXTS) {
|
|
244
|
+
const candidate = join(dir, `envprism.config.${ext}`);
|
|
245
|
+
if (existsSync(candidate)) return candidate;
|
|
246
|
+
}
|
|
247
|
+
const parent = dirname(dir);
|
|
248
|
+
if (parent === dir) return void 0;
|
|
249
|
+
dir = parent;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Load and resolve the envprism config. Precedence for *where* to look:
|
|
254
|
+
* explicit `configFile` > `ENVPRISM_CONFIG` env var > walk-up from cwd.
|
|
255
|
+
* c12 handles the walk-up (envprism.config.{ts,js,mjs,json}); we keep
|
|
256
|
+
* rc/global/dotenv off and apply our own merge (defu deep-merges defaults
|
|
257
|
+
* into user lists, which we don't want — see resolve.ts).
|
|
258
|
+
*/
|
|
259
|
+
async function loadEnvprismConfig(options = {}) {
|
|
260
|
+
const envConfig = process.env.ENVPRISM_CONFIG;
|
|
261
|
+
const startCwd = options.cwd ?? process.cwd();
|
|
262
|
+
const resolved = options.configFile ?? envConfig ?? findConfigUp(startCwd);
|
|
263
|
+
const cwd = resolved ? dirname(resolved) : startCwd;
|
|
264
|
+
if (!resolved) return {
|
|
265
|
+
config: mergeConfig({}),
|
|
266
|
+
configFile: void 0,
|
|
267
|
+
cwd
|
|
268
|
+
};
|
|
269
|
+
const { config } = await loadConfig({
|
|
270
|
+
name: "envprism",
|
|
271
|
+
cwd,
|
|
272
|
+
configFile: resolved,
|
|
273
|
+
rcFile: false,
|
|
274
|
+
globalRc: false,
|
|
275
|
+
dotenv: false
|
|
276
|
+
});
|
|
277
|
+
return {
|
|
278
|
+
config: mergeConfig(config ?? {}),
|
|
279
|
+
configFile: resolved,
|
|
280
|
+
cwd
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
//#endregion
|
|
284
|
+
//#region src/core/base.ts
|
|
285
|
+
/**
|
|
286
|
+
* Resolve which {@link EnvFile} acts as the base (reference) for diff. Order:
|
|
287
|
+
*
|
|
288
|
+
* 1. `override` path argument, matched by full path or basename.
|
|
289
|
+
* 2. `options.name` (default `.env.example`) if present.
|
|
290
|
+
* 3. The first file matching `options.priority`, in priority order.
|
|
291
|
+
* 4. The first file (already sorted by {@link discoverEnvFiles}).
|
|
292
|
+
*
|
|
293
|
+
* Returns `null` if `files` is empty.
|
|
294
|
+
*/
|
|
295
|
+
function resolveBase(files, override, options = {}) {
|
|
296
|
+
if (files.length === 0) return null;
|
|
297
|
+
if (override) {
|
|
298
|
+
const match = files.find((f) => f.path === override || basename(f.path) === override);
|
|
299
|
+
if (!match) throw new Error(`--base ${override} did not match any discovered env file`);
|
|
300
|
+
return match;
|
|
301
|
+
}
|
|
302
|
+
const name = options.name ?? ".env.example";
|
|
303
|
+
const example = files.find((f) => basename(f.path) === name);
|
|
304
|
+
if (example) return example;
|
|
305
|
+
for (const candidate of options.priority ?? []) {
|
|
306
|
+
const match = files.find((f) => basename(f.path) === candidate);
|
|
307
|
+
if (match) return match;
|
|
308
|
+
}
|
|
309
|
+
return files[0] ?? null;
|
|
310
|
+
}
|
|
311
|
+
//#endregion
|
|
312
|
+
//#region src/core/diff.ts
|
|
313
|
+
var DRIFT_STATES = /* @__PURE__ */ new Set([
|
|
314
|
+
"differs",
|
|
315
|
+
"missing",
|
|
316
|
+
"extra"
|
|
317
|
+
]);
|
|
318
|
+
function computeDiff(matrix) {
|
|
319
|
+
const files = matrix.files.filter((f) => f !== matrix.base).map((f) => buildFileReport(matrix, f));
|
|
320
|
+
return {
|
|
321
|
+
base: matrix.base.path,
|
|
322
|
+
files,
|
|
323
|
+
inSync: files.every((f) => f.drift === 0)
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
function buildFileReport(matrix, file) {
|
|
327
|
+
const keys = {};
|
|
328
|
+
let drift = 0;
|
|
329
|
+
for (const key of matrix.keys) {
|
|
330
|
+
const { state } = matrix.cell(key, file);
|
|
331
|
+
keys[key] = state;
|
|
332
|
+
if (DRIFT_STATES.has(state)) drift++;
|
|
333
|
+
}
|
|
334
|
+
return {
|
|
335
|
+
path: file.path,
|
|
336
|
+
keys,
|
|
337
|
+
drift
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
function formatDiffText(report) {
|
|
341
|
+
const baseName = basename(report.base);
|
|
342
|
+
const otherNames = report.files.map((f) => basename(f.path));
|
|
343
|
+
const lines = [];
|
|
344
|
+
lines.push(`Base: ${baseName} (vs. ${otherNames.join(", ")})`);
|
|
345
|
+
lines.push("");
|
|
346
|
+
if (report.files.length === 0) {
|
|
347
|
+
lines.push("No other env files to compare.");
|
|
348
|
+
return lines.join("\n") + "\n";
|
|
349
|
+
}
|
|
350
|
+
const driftKeys = /* @__PURE__ */ new Set();
|
|
351
|
+
for (const f of report.files) for (const [k, s] of Object.entries(f.keys)) if (DRIFT_STATES.has(s)) driftKeys.add(k);
|
|
352
|
+
if (driftKeys.size === 0) {
|
|
353
|
+
lines.push("All env files are in sync with the base.");
|
|
354
|
+
return lines.join("\n") + "\n";
|
|
355
|
+
}
|
|
356
|
+
const keyWidth = Math.max(3, ...[...driftKeys].map((k) => k.length));
|
|
357
|
+
const colWidth = Math.max(12, ...otherNames.map((n) => n.length));
|
|
358
|
+
lines.push(formatRow("KEY", otherNames, keyWidth, colWidth, (n) => n.padEnd(colWidth)));
|
|
359
|
+
for (const key of [...driftKeys].sort()) {
|
|
360
|
+
const cells = report.files.map((f) => stateLabel(f.keys[key] ?? "missing"));
|
|
361
|
+
lines.push(formatRow(key, cells, keyWidth, colWidth, (n) => n.padEnd(colWidth)));
|
|
362
|
+
}
|
|
363
|
+
lines.push("");
|
|
364
|
+
const totalDrift = report.files.reduce((sum, f) => sum + f.drift, 0);
|
|
365
|
+
lines.push(`${driftKeys.size} key(s) differ across ${report.files.length} file(s) (${totalDrift} cell drift).`);
|
|
366
|
+
return lines.join("\n") + "\n";
|
|
367
|
+
}
|
|
368
|
+
function formatRow(key, cells, keyWidth, colWidth, pad) {
|
|
369
|
+
return [key.padEnd(keyWidth), ...cells.map(pad)].join(" ");
|
|
370
|
+
}
|
|
371
|
+
function stateLabel(state) {
|
|
372
|
+
switch (state) {
|
|
373
|
+
case "same": return "— same";
|
|
374
|
+
case "differs": return "≠ differs";
|
|
375
|
+
case "missing": return "✗ missing";
|
|
376
|
+
case "extra": return "★ extra";
|
|
377
|
+
case "base": return "· base";
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
//#endregion
|
|
381
|
+
//#region src/core/parse.ts
|
|
382
|
+
var KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
383
|
+
/**
|
|
384
|
+
* Parse a `.env` file into a structured, round-trippable representation.
|
|
385
|
+
* Each entry stores its original raw line so the serializer can emit it
|
|
386
|
+
* byte-for-byte when nothing changes.
|
|
387
|
+
*/
|
|
388
|
+
function parseEnv(source, path = "") {
|
|
389
|
+
const trailingNewline = source.endsWith("\n");
|
|
390
|
+
const body = trailingNewline ? source.slice(0, -1) : source;
|
|
391
|
+
const lines = body.length === 0 ? [] : body.split("\n");
|
|
392
|
+
const entries = [];
|
|
393
|
+
for (const raw of lines) entries.push(parseLine(raw));
|
|
394
|
+
return {
|
|
395
|
+
path,
|
|
396
|
+
entries,
|
|
397
|
+
trailingNewline
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
function parseLine(raw) {
|
|
401
|
+
if (raw.trim().length === 0) return {
|
|
402
|
+
kind: "blank",
|
|
403
|
+
raw
|
|
404
|
+
};
|
|
405
|
+
if (raw.replace(/^\s+/, "").startsWith("#")) return {
|
|
406
|
+
kind: "comment",
|
|
407
|
+
raw
|
|
408
|
+
};
|
|
409
|
+
const kv = tryParseKv(raw);
|
|
410
|
+
if (kv) return kv;
|
|
411
|
+
return {
|
|
412
|
+
kind: "comment",
|
|
413
|
+
raw
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
function tryParseKv(raw) {
|
|
417
|
+
let rest = raw;
|
|
418
|
+
const leadingWs = rest.match(/^[ \t]*/)?.[0] ?? "";
|
|
419
|
+
rest = rest.slice(leadingWs.length);
|
|
420
|
+
let exportPrefix = false;
|
|
421
|
+
if (rest.startsWith("export ")) {
|
|
422
|
+
exportPrefix = true;
|
|
423
|
+
rest = rest.slice(7).replace(/^[ \t]*/, "");
|
|
424
|
+
}
|
|
425
|
+
const eqIdx = rest.indexOf("=");
|
|
426
|
+
if (eqIdx < 0) return null;
|
|
427
|
+
const key = rest.slice(0, eqIdx).trimEnd();
|
|
428
|
+
if (!KEY_RE.test(key)) return null;
|
|
429
|
+
const after = rest.slice(eqIdx + 1);
|
|
430
|
+
const valueStart = after.replace(/^[ \t]*/, "");
|
|
431
|
+
after.slice(0, after.length - valueStart.length);
|
|
432
|
+
const parsed = parseValue(valueStart);
|
|
433
|
+
if (!parsed) return null;
|
|
434
|
+
return {
|
|
435
|
+
kind: "kv",
|
|
436
|
+
key,
|
|
437
|
+
rawValue: parsed.rawValue,
|
|
438
|
+
value: parsed.value,
|
|
439
|
+
quoting: parsed.quoting,
|
|
440
|
+
exportPrefix,
|
|
441
|
+
inlineComment: parsed.inlineComment,
|
|
442
|
+
raw
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
function parseValue(input) {
|
|
446
|
+
if (input.length === 0) return {
|
|
447
|
+
rawValue: "",
|
|
448
|
+
value: "",
|
|
449
|
+
quoting: "none",
|
|
450
|
+
inlineComment: ""
|
|
451
|
+
};
|
|
452
|
+
const first = input[0];
|
|
453
|
+
if (first === "\"" || first === "'") {
|
|
454
|
+
const close = findClosingQuote(input, first);
|
|
455
|
+
if (close < 0) return null;
|
|
456
|
+
const rawValue = input.slice(1, close);
|
|
457
|
+
const value = first === "\"" ? decodeDoubleQuoted(rawValue) : rawValue;
|
|
458
|
+
const inlineComment = extractInlineComment(input.slice(close + 1));
|
|
459
|
+
return {
|
|
460
|
+
rawValue,
|
|
461
|
+
value,
|
|
462
|
+
quoting: first === "\"" ? "double" : "single",
|
|
463
|
+
inlineComment
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
const hashIdx = findUnquotedCommentStart(input);
|
|
467
|
+
const valuePart = (hashIdx < 0 ? input : input.slice(0, hashIdx)).trimEnd();
|
|
468
|
+
return {
|
|
469
|
+
rawValue: valuePart,
|
|
470
|
+
value: valuePart,
|
|
471
|
+
quoting: "none",
|
|
472
|
+
inlineComment: hashIdx < 0 ? "" : input.slice(valuePart.length)
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
function findClosingQuote(input, quote) {
|
|
476
|
+
for (let i = 1; i < input.length; i++) {
|
|
477
|
+
const ch = input[i];
|
|
478
|
+
if (quote === "\"" && ch === "\\") {
|
|
479
|
+
i++;
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
if (ch === quote) return i;
|
|
483
|
+
}
|
|
484
|
+
return -1;
|
|
485
|
+
}
|
|
486
|
+
function findUnquotedCommentStart(input) {
|
|
487
|
+
for (let i = 0; i < input.length; i++) {
|
|
488
|
+
if (input[i] !== "#") continue;
|
|
489
|
+
if (i === 0) return i;
|
|
490
|
+
const prev = input[i - 1];
|
|
491
|
+
if (prev === " " || prev === " ") return i;
|
|
492
|
+
}
|
|
493
|
+
return -1;
|
|
494
|
+
}
|
|
495
|
+
function extractInlineComment(tail) {
|
|
496
|
+
return tail;
|
|
497
|
+
}
|
|
498
|
+
function decodeDoubleQuoted(raw) {
|
|
499
|
+
return raw.replace(/\\(.)/g, (_, ch) => {
|
|
500
|
+
switch (ch) {
|
|
501
|
+
case "n": return "\n";
|
|
502
|
+
case "r": return "\r";
|
|
503
|
+
case "t": return " ";
|
|
504
|
+
case "\\": return "\\";
|
|
505
|
+
case "\"": return "\"";
|
|
506
|
+
default: return `\\${ch}`;
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
//#endregion
|
|
511
|
+
//#region src/core/discover.ts
|
|
512
|
+
var SKIP_SUFFIXES = [
|
|
513
|
+
".swp",
|
|
514
|
+
"~",
|
|
515
|
+
".bak"
|
|
516
|
+
];
|
|
517
|
+
/**
|
|
518
|
+
* Discover `.env*` files in the given path(s). Each path may be a directory
|
|
519
|
+
* (glob-like discovery happens at its top level) or an explicit file. Editor
|
|
520
|
+
* and backup files are skipped.
|
|
521
|
+
*
|
|
522
|
+
* Files are returned sorted: `.env.example` first if present (unless
|
|
523
|
+
* `exampleFirst` is false), then the rest alphabetically. Base-resolution
|
|
524
|
+
* lives in `base.ts` and depends on this order.
|
|
525
|
+
*/
|
|
526
|
+
async function discoverEnvFiles(paths, options = {}) {
|
|
527
|
+
const skipSuffixes = options.skipSuffixes ?? SKIP_SUFFIXES;
|
|
528
|
+
const exampleFirst = options.exampleFirst ?? true;
|
|
529
|
+
const filePaths = /* @__PURE__ */ new Set();
|
|
530
|
+
for (const p of paths) if ((await stat(p)).isDirectory()) {
|
|
531
|
+
const entries = await readdir(p);
|
|
532
|
+
for (const name of entries) {
|
|
533
|
+
if (!looksLikeEnvFile(name, skipSuffixes)) continue;
|
|
534
|
+
filePaths.add(join(p, name));
|
|
535
|
+
}
|
|
536
|
+
} else filePaths.add(p);
|
|
537
|
+
const sorted = [...filePaths].sort((a, b) => envPathOrder(a, b, exampleFirst));
|
|
538
|
+
const files = [];
|
|
539
|
+
for (const filePath of sorted) {
|
|
540
|
+
const source = await readFile(filePath, "utf8");
|
|
541
|
+
files.push(parseEnv(source, filePath));
|
|
542
|
+
}
|
|
543
|
+
return files;
|
|
544
|
+
}
|
|
545
|
+
function looksLikeEnvFile(name, skipSuffixes) {
|
|
546
|
+
if (!name.startsWith(".env")) return false;
|
|
547
|
+
if (skipSuffixes.some((s) => name.endsWith(s))) return false;
|
|
548
|
+
return true;
|
|
549
|
+
}
|
|
550
|
+
function envPathOrder(a, b, exampleFirst) {
|
|
551
|
+
const an = basename(a);
|
|
552
|
+
const bn = basename(b);
|
|
553
|
+
if (exampleFirst) {
|
|
554
|
+
if (an === ".env.example") return -1;
|
|
555
|
+
if (bn === ".env.example") return 1;
|
|
556
|
+
}
|
|
557
|
+
return an.localeCompare(bn);
|
|
558
|
+
}
|
|
559
|
+
//#endregion
|
|
560
|
+
//#region src/core/sections.ts
|
|
561
|
+
var SEP_CHARS = /* @__PURE__ */ new Set([
|
|
562
|
+
"=",
|
|
563
|
+
"-",
|
|
564
|
+
"#",
|
|
565
|
+
"~",
|
|
566
|
+
"*"
|
|
567
|
+
]);
|
|
568
|
+
var SPACE_CHARS = /* @__PURE__ */ new Set([" ", " "]);
|
|
569
|
+
/**
|
|
570
|
+
* Walk the base file in source order and decide which section each kv entry
|
|
571
|
+
* belongs to. Returns a `key → section` map; keys with no inferred section
|
|
572
|
+
* are absent from the map.
|
|
573
|
+
*
|
|
574
|
+
* A "section header" is one of:
|
|
575
|
+
* 1. An inline banner: `# === Section name ===` (or `---`, `~~~`, `***`).
|
|
576
|
+
* 2. A block banner — a single comment line `# Section name` whose
|
|
577
|
+
* immediately preceding or following comment line is purely decorative
|
|
578
|
+
* (e.g. `# ===========================`).
|
|
579
|
+
*
|
|
580
|
+
* The detected name applies to every subsequent kv entry until the next
|
|
581
|
+
* detected section header.
|
|
582
|
+
*/
|
|
583
|
+
function computeSections(base) {
|
|
584
|
+
const out = /* @__PURE__ */ new Map();
|
|
585
|
+
let current = null;
|
|
586
|
+
for (let i = 0; i < base.entries.length; i++) {
|
|
587
|
+
const name = detectSectionName(base.entries, i);
|
|
588
|
+
if (name !== null) current = name;
|
|
589
|
+
const e = base.entries[i];
|
|
590
|
+
if (e.kind === "kv" && current) out.set(e.key, current);
|
|
591
|
+
}
|
|
592
|
+
return out;
|
|
593
|
+
}
|
|
594
|
+
function detectSectionName(entries, idx) {
|
|
595
|
+
const e = entries[idx];
|
|
596
|
+
if (!e || e.kind !== "comment") return null;
|
|
597
|
+
if (isDecorativeLine(e.raw)) return null;
|
|
598
|
+
const inline = parseInlineBanner(e.raw);
|
|
599
|
+
if (inline !== null) return inline;
|
|
600
|
+
const text = stripCommentPrefix(e.raw);
|
|
601
|
+
if (!text) return null;
|
|
602
|
+
if (isDecorative(entries[idx - 1]) || isDecorative(entries[idx + 1])) return text;
|
|
603
|
+
return null;
|
|
604
|
+
}
|
|
605
|
+
function isDecorative(e) {
|
|
606
|
+
if (!e || e.kind !== "comment") return false;
|
|
607
|
+
return isDecorativeLine(e.raw);
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* True for comment lines whose body is nothing but separator chars and
|
|
611
|
+
* whitespace (e.g. "# ======", "###", "# -=-=-=-").
|
|
612
|
+
*
|
|
613
|
+
* Implemented with a manual scan rather than a regex with overlapping
|
|
614
|
+
* `\s*` / `[\s…]+` groups, which CodeQL flags as ReDoS-prone.
|
|
615
|
+
*/
|
|
616
|
+
function isDecorativeLine(raw) {
|
|
617
|
+
const start = skipSpaces(raw, 0);
|
|
618
|
+
if (start >= raw.length || raw[start] !== "#") return false;
|
|
619
|
+
let i = start + 1;
|
|
620
|
+
let sawSeparator = false;
|
|
621
|
+
while (i < raw.length) {
|
|
622
|
+
const ch = raw[i];
|
|
623
|
+
if (SEP_CHARS.has(ch)) sawSeparator = true;
|
|
624
|
+
else if (!SPACE_CHARS.has(ch)) return false;
|
|
625
|
+
i++;
|
|
626
|
+
}
|
|
627
|
+
return sawSeparator;
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Parse a single-line inline banner like `# === Name ===` (also `---`,
|
|
631
|
+
* `~~~`, `***`) and return the inner name, or null if the line isn't an
|
|
632
|
+
* inline banner.
|
|
633
|
+
*
|
|
634
|
+
* Manual scan, again to avoid the overlapping `\s*` regex backtracking
|
|
635
|
+
* CodeQL warned about.
|
|
636
|
+
*/
|
|
637
|
+
function parseInlineBanner(raw) {
|
|
638
|
+
const start = skipSpaces(raw, 0);
|
|
639
|
+
if (start >= raw.length || raw[start] !== "#") return null;
|
|
640
|
+
let i = skipSpaces(raw, start + 1);
|
|
641
|
+
if (i >= raw.length || !SEP_CHARS.has(raw[i])) return null;
|
|
642
|
+
let leadCount = 0;
|
|
643
|
+
while (i < raw.length && SEP_CHARS.has(raw[i])) {
|
|
644
|
+
leadCount++;
|
|
645
|
+
i++;
|
|
646
|
+
}
|
|
647
|
+
if (leadCount < 2) return null;
|
|
648
|
+
let j = raw.length;
|
|
649
|
+
while (j > i && SPACE_CHARS.has(raw[j - 1])) j--;
|
|
650
|
+
if (j <= i || !SEP_CHARS.has(raw[j - 1])) return null;
|
|
651
|
+
let trailCount = 0;
|
|
652
|
+
while (j > i && SEP_CHARS.has(raw[j - 1])) {
|
|
653
|
+
trailCount++;
|
|
654
|
+
j--;
|
|
655
|
+
}
|
|
656
|
+
if (trailCount < 2) return null;
|
|
657
|
+
const inner = raw.slice(i, j).trim();
|
|
658
|
+
return inner.length > 0 ? inner : null;
|
|
659
|
+
}
|
|
660
|
+
function stripCommentPrefix(raw) {
|
|
661
|
+
let i = skipSpaces(raw, 0);
|
|
662
|
+
while (i < raw.length && raw[i] === "#") i++;
|
|
663
|
+
i = skipSpaces(raw, i);
|
|
664
|
+
let j = raw.length;
|
|
665
|
+
while (j > i && (SPACE_CHARS.has(raw[j - 1]) || SEP_CHARS.has(raw[j - 1]))) j--;
|
|
666
|
+
return raw.slice(i, j);
|
|
667
|
+
}
|
|
668
|
+
function skipSpaces(raw, from) {
|
|
669
|
+
let i = from;
|
|
670
|
+
while (i < raw.length && SPACE_CHARS.has(raw[i])) i++;
|
|
671
|
+
return i;
|
|
672
|
+
}
|
|
673
|
+
//#endregion
|
|
674
|
+
//#region src/core/matrix.ts
|
|
675
|
+
function buildMatrix(files, base) {
|
|
676
|
+
const keys = collectKeys(files, base);
|
|
677
|
+
const lookups = /* @__PURE__ */ new Map();
|
|
678
|
+
for (const file of files) lookups.set(file, indexKv(file));
|
|
679
|
+
const baseIndex = lookups.get(base);
|
|
680
|
+
if (!baseIndex) throw new Error("base file is not in the files list");
|
|
681
|
+
const sections = computeSections(base);
|
|
682
|
+
return {
|
|
683
|
+
keys,
|
|
684
|
+
files,
|
|
685
|
+
base,
|
|
686
|
+
sectionOf(key) {
|
|
687
|
+
return sections.get(key);
|
|
688
|
+
},
|
|
689
|
+
cell(key, file) {
|
|
690
|
+
const ownIndex = lookups.get(file);
|
|
691
|
+
if (!ownIndex) throw new Error(`file not in matrix: ${file.path}`);
|
|
692
|
+
const own = ownIndex.get(key);
|
|
693
|
+
const baseEntry = baseIndex.get(key);
|
|
694
|
+
if (file === base) return {
|
|
695
|
+
state: own ? "base" : "missing",
|
|
696
|
+
value: own?.value
|
|
697
|
+
};
|
|
698
|
+
if (!own && !baseEntry) return {
|
|
699
|
+
state: "missing",
|
|
700
|
+
value: void 0
|
|
701
|
+
};
|
|
702
|
+
if (!own) return {
|
|
703
|
+
state: "missing",
|
|
704
|
+
value: void 0
|
|
705
|
+
};
|
|
706
|
+
if (!baseEntry) return {
|
|
707
|
+
state: "extra",
|
|
708
|
+
value: own.value
|
|
709
|
+
};
|
|
710
|
+
return {
|
|
711
|
+
state: own.value === baseEntry.value ? "same" : "differs",
|
|
712
|
+
value: own.value
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
function collectKeys(files, base) {
|
|
718
|
+
const seen = /* @__PURE__ */ new Set();
|
|
719
|
+
const out = [];
|
|
720
|
+
for (const e of base.entries) if (e.kind === "kv" && !seen.has(e.key)) {
|
|
721
|
+
seen.add(e.key);
|
|
722
|
+
out.push(e.key);
|
|
723
|
+
}
|
|
724
|
+
const extras = /* @__PURE__ */ new Set();
|
|
725
|
+
for (const file of files) {
|
|
726
|
+
if (file === base) continue;
|
|
727
|
+
for (const e of file.entries) if (e.kind === "kv" && !seen.has(e.key)) extras.add(e.key);
|
|
728
|
+
}
|
|
729
|
+
for (const k of [...extras].sort()) out.push(k);
|
|
730
|
+
return out;
|
|
731
|
+
}
|
|
732
|
+
function indexKv(file) {
|
|
733
|
+
const m = /* @__PURE__ */ new Map();
|
|
734
|
+
for (const e of file.entries) if (e.kind === "kv") m.set(e.key, e);
|
|
735
|
+
return m;
|
|
736
|
+
}
|
|
737
|
+
//#endregion
|
|
738
|
+
export { isSecretKey as _, computeDiff as a, loadEnvprismConfig as c, resolveThemeHex as d, findKvEntry as f, DEFAULT_CONFIG as g, truncate as h, parseEnv as i, DEFAULT_THEME_HEX as l, matchesFilter as m, computeSections as n, formatDiffText as o, formatValue as p, discoverEnvFiles as r, resolveBase as s, buildMatrix as t, resolveHeuristics as u, maskValue as v };
|
|
739
|
+
|
|
740
|
+
//# sourceMappingURL=matrix-CG1Msljr.mjs.map
|