@env-lane/vault 0.1.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 +22 -0
- package/dist/index.cjs +787 -0
- package/dist/index.d.cts +173 -0
- package/dist/index.d.ts +173 -0
- package/dist/index.js +740 -0
- package/package.json +60 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
// src/config.ts
|
|
2
|
+
import { existsSync, readFileSync } from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { loadConfig as c12LoadConfig } from "c12";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
var schema = z.object({
|
|
7
|
+
envFiles: z.array(z.string().min(1)),
|
|
8
|
+
outputDir: z.string().min(1).default(".env-lane-vault"),
|
|
9
|
+
outputFile: z.string().min(1).default("store.dat"),
|
|
10
|
+
trackDeletions: z.boolean().default(true),
|
|
11
|
+
exclude: z.array(
|
|
12
|
+
z.object({
|
|
13
|
+
files: z.array(z.string().min(1)).or(z.string().min(1)),
|
|
14
|
+
keys: z.array(z.string().min(1)).or(z.string().min(1))
|
|
15
|
+
})
|
|
16
|
+
).default([]),
|
|
17
|
+
sort: z.record(
|
|
18
|
+
z.string(),
|
|
19
|
+
z.object({
|
|
20
|
+
file: z.string().min(1),
|
|
21
|
+
template: z.string().min(1),
|
|
22
|
+
files: z.record(z.string(), z.string().min(1)).optional()
|
|
23
|
+
})
|
|
24
|
+
).optional(),
|
|
25
|
+
disableUnsafeWarning: z.boolean().optional()
|
|
26
|
+
});
|
|
27
|
+
function stringList(value, fieldName) {
|
|
28
|
+
const values = Array.isArray(value) ? value : [value];
|
|
29
|
+
return values.map((item) => {
|
|
30
|
+
if (typeof item !== "string" || !item.trim()) {
|
|
31
|
+
throw new Error(`${fieldName} must contain non-empty strings.`);
|
|
32
|
+
}
|
|
33
|
+
return item.trim();
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
function normalizeExclude(rawExclude) {
|
|
37
|
+
if (rawExclude === void 0) return [];
|
|
38
|
+
const rawRules = [];
|
|
39
|
+
if (Array.isArray(rawExclude)) {
|
|
40
|
+
rawRules.push(...rawExclude);
|
|
41
|
+
} else if (rawExclude && typeof rawExclude === "object") {
|
|
42
|
+
for (const [filePattern, keyPatterns] of Object.entries(rawExclude)) {
|
|
43
|
+
rawRules.push({ files: [filePattern], keys: keyPatterns });
|
|
44
|
+
}
|
|
45
|
+
} else {
|
|
46
|
+
throw new Error("config.exclude must be an array or an object when provided.");
|
|
47
|
+
}
|
|
48
|
+
return rawRules.map((rawRule, index) => {
|
|
49
|
+
if (!rawRule || typeof rawRule !== "object" || Array.isArray(rawRule)) {
|
|
50
|
+
throw new Error(`config.exclude[${index}] must be an object.`);
|
|
51
|
+
}
|
|
52
|
+
const rule = rawRule;
|
|
53
|
+
return {
|
|
54
|
+
files: stringList(
|
|
55
|
+
rule.files ?? rule.file ?? rule.filePattern ?? rule.filePatterns,
|
|
56
|
+
`config.exclude[${index}].files`
|
|
57
|
+
),
|
|
58
|
+
keys: stringList(
|
|
59
|
+
rule.keys ?? rule.key ?? rule.keyPattern ?? rule.keyPatterns,
|
|
60
|
+
`config.exclude[${index}].keys`
|
|
61
|
+
)
|
|
62
|
+
};
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
function uniqueResolvedFiles(baseDir, files) {
|
|
66
|
+
return [...new Set(files.map((file) => path.resolve(baseDir, file)))];
|
|
67
|
+
}
|
|
68
|
+
function defineVaultConfig(config) {
|
|
69
|
+
return config;
|
|
70
|
+
}
|
|
71
|
+
async function loadVaultConfig(configPath) {
|
|
72
|
+
const abs = path.resolve(configPath);
|
|
73
|
+
if (!existsSync(abs)) throw new Error(`Vault config does not exist: ${abs}`);
|
|
74
|
+
const baseDir = path.dirname(abs);
|
|
75
|
+
const raw = path.extname(abs) === ".json" ? JSON.parse(readFileSync(abs, "utf8").replace(/^\uFEFF/, "")) : (await c12LoadConfig({
|
|
76
|
+
cwd: baseDir,
|
|
77
|
+
configFile: abs,
|
|
78
|
+
packageJson: false,
|
|
79
|
+
dotenv: false,
|
|
80
|
+
rcFile: false,
|
|
81
|
+
globalRc: false,
|
|
82
|
+
configFileRequired: true
|
|
83
|
+
})).config ?? {};
|
|
84
|
+
const parsed = schema.parse({
|
|
85
|
+
...raw,
|
|
86
|
+
exclude: normalizeExclude(raw.exclude ?? raw.excludes)
|
|
87
|
+
});
|
|
88
|
+
const outputDir = path.resolve(baseDir, parsed.outputDir);
|
|
89
|
+
const envFiles = uniqueResolvedFiles(baseDir, parsed.envFiles);
|
|
90
|
+
const storePath = path.resolve(outputDir, parsed.outputFile);
|
|
91
|
+
if (envFiles.includes(storePath)) {
|
|
92
|
+
throw new Error("The vault store file must not overlap with any env file.");
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
baseDir,
|
|
96
|
+
envFiles,
|
|
97
|
+
outputDir,
|
|
98
|
+
outputFile: parsed.outputFile,
|
|
99
|
+
storePath,
|
|
100
|
+
trackDeletions: parsed.trackDeletions,
|
|
101
|
+
exclude: parsed.exclude.map((rule) => ({
|
|
102
|
+
files: Array.isArray(rule.files) ? rule.files : [rule.files],
|
|
103
|
+
keys: Array.isArray(rule.keys) ? rule.keys : [rule.keys]
|
|
104
|
+
})),
|
|
105
|
+
disableUnsafeWarning: parsed.disableUnsafeWarning ?? false
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// src/crypto.ts
|
|
110
|
+
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "crypto";
|
|
111
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
112
|
+
import path2 from "path";
|
|
113
|
+
var ALGO = "aes-256-gcm";
|
|
114
|
+
var IV_LEN = 12;
|
|
115
|
+
var TAG_LEN = 16;
|
|
116
|
+
var KDF_SALT = Buffer.from("env-store-v1-kdf-salt", "utf8");
|
|
117
|
+
var KDF_OPTS = { N: 16384, r: 8, p: 1 };
|
|
118
|
+
function deriveVaultKey(keyFilePath) {
|
|
119
|
+
const abs = path2.resolve(keyFilePath);
|
|
120
|
+
if (!existsSync2(abs)) throw new Error(`Key file does not exist: ${abs}`);
|
|
121
|
+
const material = readFileSync2(abs);
|
|
122
|
+
if (!material.length) throw new Error(`Key file is empty: ${abs}`);
|
|
123
|
+
return scryptSync(material, KDF_SALT, 32, KDF_OPTS);
|
|
124
|
+
}
|
|
125
|
+
function encryptRecord(key, plaintext) {
|
|
126
|
+
const iv = randomBytes(IV_LEN);
|
|
127
|
+
const cipher = createCipheriv(ALGO, key, iv);
|
|
128
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
129
|
+
const tag = cipher.getAuthTag();
|
|
130
|
+
return Buffer.concat([iv, tag, ciphertext]).toString("base64");
|
|
131
|
+
}
|
|
132
|
+
function decryptRecord(key, line) {
|
|
133
|
+
const buf = Buffer.from(line, "base64");
|
|
134
|
+
if (buf.length <= IV_LEN + TAG_LEN) throw new Error("Encrypted record is too short.");
|
|
135
|
+
const iv = buf.subarray(0, IV_LEN);
|
|
136
|
+
const tag = buf.subarray(IV_LEN, IV_LEN + TAG_LEN);
|
|
137
|
+
const ciphertext = buf.subarray(IV_LEN + TAG_LEN);
|
|
138
|
+
const decipher = createDecipheriv(ALGO, key, iv);
|
|
139
|
+
decipher.setAuthTag(tag);
|
|
140
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// src/store.ts
|
|
144
|
+
import { appendFileSync, existsSync as existsSync3, mkdirSync, readFileSync as readFileSync3, writeFileSync } from "fs";
|
|
145
|
+
import path3 from "path";
|
|
146
|
+
import { getLogger as getLogger2 } from "@env-lane/core";
|
|
147
|
+
import picomatch from "picomatch";
|
|
148
|
+
|
|
149
|
+
// src/warning.ts
|
|
150
|
+
import { getLogger } from "@env-lane/core";
|
|
151
|
+
var VAULT_UNSAFE_WARNING = `[env-lane:vault] WARNING: This vault is not a production secret-management system.
|
|
152
|
+
[env-lane:vault] It stores reversible encrypted .env records and depends on local key-file handling.
|
|
153
|
+
[env-lane:vault] Use CI/CD secrets, cloud KMS, HashiCorp Vault, SOPS, age, or a platform Secret Manager for production.`;
|
|
154
|
+
function warnUnsafeVault(options = {}) {
|
|
155
|
+
if (options.disableUnsafeWarning) return;
|
|
156
|
+
if (options.stderr) {
|
|
157
|
+
options.stderr.write(`${VAULT_UNSAFE_WARNING}
|
|
158
|
+
`);
|
|
159
|
+
} else {
|
|
160
|
+
getLogger().warn(VAULT_UNSAFE_WARNING);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// src/store.ts
|
|
165
|
+
var ENV_ENTRY_RE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/;
|
|
166
|
+
var COMMENTED_ENV_ENTRY_RE = /^\s*#\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/;
|
|
167
|
+
var PREVIEW_VALUE_LIMIT = 160;
|
|
168
|
+
var excludeRuleCache = /* @__PURE__ */ new WeakMap();
|
|
169
|
+
var managedFileAliasCache = /* @__PURE__ */ new WeakMap();
|
|
170
|
+
function portable(file) {
|
|
171
|
+
return file.replace(/\\/g, "/").replaceAll(path3.sep, "/");
|
|
172
|
+
}
|
|
173
|
+
function emitStructuredChange(command, payload) {
|
|
174
|
+
getLogger2().info(`[env-store-change] ${JSON.stringify({ command, ...payload })}`);
|
|
175
|
+
}
|
|
176
|
+
function getCompiledExcludeRules(config) {
|
|
177
|
+
const cached = excludeRuleCache.get(config);
|
|
178
|
+
if (cached) return cached;
|
|
179
|
+
const compiled = config.exclude.map((rule) => ({
|
|
180
|
+
fileMatch: picomatch(rule.files, { dot: true }),
|
|
181
|
+
keyMatch: picomatch(rule.keys, { dot: true })
|
|
182
|
+
}));
|
|
183
|
+
excludeRuleCache.set(config, compiled);
|
|
184
|
+
return compiled;
|
|
185
|
+
}
|
|
186
|
+
function isExcluded(config, filePath, key) {
|
|
187
|
+
const rel = portable(path3.relative(config.baseDir, filePath));
|
|
188
|
+
for (const { fileMatch, keyMatch } of getCompiledExcludeRules(config)) {
|
|
189
|
+
if ((fileMatch(rel) || fileMatch(path3.basename(filePath))) && keyMatch(key)) return true;
|
|
190
|
+
}
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
function isNewerRecord(candidate, existing) {
|
|
194
|
+
if (candidate.t !== existing.t) return candidate.t > existing.t;
|
|
195
|
+
return (candidate.order ?? 0) > (existing.order ?? 0);
|
|
196
|
+
}
|
|
197
|
+
function getManagedFileAliases(config) {
|
|
198
|
+
const cached = managedFileAliasCache.get(config);
|
|
199
|
+
if (cached) return cached;
|
|
200
|
+
const aliases = [...new Set(config.envFiles.map((filePath) => path3.resolve(filePath)))].map(
|
|
201
|
+
(filePath) => ({
|
|
202
|
+
filePath,
|
|
203
|
+
relativePath: portable(path3.relative(config.baseDir, filePath))
|
|
204
|
+
})
|
|
205
|
+
);
|
|
206
|
+
managedFileAliasCache.set(config, aliases);
|
|
207
|
+
return aliases;
|
|
208
|
+
}
|
|
209
|
+
function remapManagedStoreFilePath(filePath, config) {
|
|
210
|
+
const resolvedFilePath = path3.resolve(filePath);
|
|
211
|
+
const aliases = getManagedFileAliases(config);
|
|
212
|
+
if (aliases.some((alias) => alias.filePath === resolvedFilePath)) {
|
|
213
|
+
return { filePath: resolvedFilePath, aliased: false };
|
|
214
|
+
}
|
|
215
|
+
const portableFilePath = portable(resolvedFilePath);
|
|
216
|
+
let matchedAlias;
|
|
217
|
+
for (const alias of aliases) {
|
|
218
|
+
if (!alias.relativePath || alias.relativePath.startsWith("..")) continue;
|
|
219
|
+
if (portableFilePath === alias.relativePath || portableFilePath.endsWith(`/${alias.relativePath}`)) {
|
|
220
|
+
if (!matchedAlias || alias.relativePath.length > matchedAlias.relativePath.length) {
|
|
221
|
+
matchedAlias = alias;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return matchedAlias ? { filePath: matchedAlias.filePath, aliased: true } : { filePath: resolvedFilePath, aliased: false };
|
|
226
|
+
}
|
|
227
|
+
function validateStoreRecord(decoded, order) {
|
|
228
|
+
if (!decoded || typeof decoded !== "object" || Array.isArray(decoded)) {
|
|
229
|
+
throw new Error("Store record must be a JSON object.");
|
|
230
|
+
}
|
|
231
|
+
const raw = decoded;
|
|
232
|
+
if (typeof raw.f !== "string" || !raw.f.trim())
|
|
233
|
+
throw new Error("Store record is missing file path.");
|
|
234
|
+
if (typeof raw.k !== "string" || !raw.k.trim())
|
|
235
|
+
throw new Error("Store record is missing key name.");
|
|
236
|
+
const timestamp = Number(raw.t);
|
|
237
|
+
if (!Number.isFinite(timestamp) || timestamp < 0)
|
|
238
|
+
throw new Error("Store record has invalid timestamp.");
|
|
239
|
+
const op = raw.op === void 0 ? "set" : raw.op;
|
|
240
|
+
if (op !== "set" && op !== "delete")
|
|
241
|
+
throw new Error(`Unsupported record operation: ${String(op)}`);
|
|
242
|
+
if (op === "set" && typeof raw.v !== "string")
|
|
243
|
+
throw new Error("Set record is missing string value.");
|
|
244
|
+
return {
|
|
245
|
+
f: path3.resolve(raw.f),
|
|
246
|
+
k: raw.k,
|
|
247
|
+
t: timestamp,
|
|
248
|
+
op,
|
|
249
|
+
v: op === "set" ? raw.v : void 0,
|
|
250
|
+
order
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
function readStore(config, key, options = {}) {
|
|
254
|
+
const state = /* @__PURE__ */ new Map();
|
|
255
|
+
if (!existsSync3(config.storePath)) {
|
|
256
|
+
if (options.allowMissing) {
|
|
257
|
+
return { state, failedRecords: 0, parsedRecords: 0, rawRecords: 0, aliasedRecords: 0 };
|
|
258
|
+
}
|
|
259
|
+
throw new Error(`Store file does not exist: ${config.storePath}`);
|
|
260
|
+
}
|
|
261
|
+
const lines = readFileSync3(config.storePath, "utf8").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
262
|
+
let failedRecords = 0;
|
|
263
|
+
let parsedRecords = 0;
|
|
264
|
+
let aliasedRecords = 0;
|
|
265
|
+
lines.forEach((line, order) => {
|
|
266
|
+
try {
|
|
267
|
+
const parsedRecord = validateStoreRecord(JSON.parse(decryptRecord(key, line)), order);
|
|
268
|
+
const remapped = remapManagedStoreFilePath(parsedRecord.f, config);
|
|
269
|
+
const record = remapped.aliased ? { ...parsedRecord, f: remapped.filePath } : parsedRecord;
|
|
270
|
+
if (remapped.aliased) aliasedRecords++;
|
|
271
|
+
const perFile = state.get(record.f) ?? /* @__PURE__ */ new Map();
|
|
272
|
+
const existing = perFile.get(record.k);
|
|
273
|
+
if (!existing || isNewerRecord(record, existing)) perFile.set(record.k, record);
|
|
274
|
+
state.set(record.f, perFile);
|
|
275
|
+
parsedRecords++;
|
|
276
|
+
} catch {
|
|
277
|
+
failedRecords++;
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
if (lines.length > 0 && parsedRecords === 0) {
|
|
281
|
+
throw new Error(`No readable vault records found in ${config.storePath}. Check the key file.`);
|
|
282
|
+
}
|
|
283
|
+
if (failedRecords > 0 && !options.ignoreCorruptRecords) {
|
|
284
|
+
throw new Error(
|
|
285
|
+
`Vault store contains ${failedRecords} unreadable record(s) in ${config.storePath}. Refusing to continue from partial state; pass ignoreCorruptRecords only after inspecting the store.`
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
return { state, failedRecords, parsedRecords, rawRecords: lines.length, aliasedRecords };
|
|
289
|
+
}
|
|
290
|
+
function append(config, key, record) {
|
|
291
|
+
mkdirSync(path3.dirname(config.storePath), { recursive: true });
|
|
292
|
+
appendFileSync(config.storePath, `${encryptRecord(key, JSON.stringify(record))}
|
|
293
|
+
`, "utf8");
|
|
294
|
+
}
|
|
295
|
+
function createEmptyDocument() {
|
|
296
|
+
return { hasBom: false, eol: "\n", hasFinalNewline: true, lines: [] };
|
|
297
|
+
}
|
|
298
|
+
function createTextDocument(content) {
|
|
299
|
+
const hasBom = content.startsWith("\uFEFF");
|
|
300
|
+
const raw = hasBom ? content.slice(1) : content;
|
|
301
|
+
const eol = raw.includes("\r\n") ? "\r\n" : "\n";
|
|
302
|
+
const hasFinalNewline = raw.length > 0 && /\r?\n$/.test(raw);
|
|
303
|
+
let lines = raw.length > 0 ? raw.split(/\r\n|\n/) : [];
|
|
304
|
+
if (hasFinalNewline && lines.at(-1) === "") lines = lines.slice(0, -1);
|
|
305
|
+
return { hasBom, eol, hasFinalNewline, lines };
|
|
306
|
+
}
|
|
307
|
+
function renderTextDocument(document, lines) {
|
|
308
|
+
const body = lines.join(document.eol);
|
|
309
|
+
const withNewline = lines.length > 0 && document.hasFinalNewline ? `${body}${document.eol}` : body;
|
|
310
|
+
return document.hasBom ? `\uFEFF${withNewline}` : withNewline;
|
|
311
|
+
}
|
|
312
|
+
function parseEnvLine(line) {
|
|
313
|
+
const trimmed = line.trim();
|
|
314
|
+
if (!trimmed) return { kind: "empty", rawLine: line };
|
|
315
|
+
if (trimmed.startsWith("#")) {
|
|
316
|
+
const commentedEntryMatch = line.match(COMMENTED_ENV_ENTRY_RE);
|
|
317
|
+
if (commentedEntryMatch) {
|
|
318
|
+
const eqIdx2 = line.indexOf("=");
|
|
319
|
+
return {
|
|
320
|
+
kind: "commented-entry",
|
|
321
|
+
rawLine: line,
|
|
322
|
+
key: commentedEntryMatch[1],
|
|
323
|
+
prefix: line.slice(0, eqIdx2 + 1),
|
|
324
|
+
rawValue: line.slice(eqIdx2 + 1)
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
return { kind: "comment", rawLine: line };
|
|
328
|
+
}
|
|
329
|
+
const eqIdx = line.indexOf("=");
|
|
330
|
+
if (eqIdx < 0) return { kind: "invalid", rawLine: line, reason: "missing equals sign" };
|
|
331
|
+
const match = line.match(ENV_ENTRY_RE);
|
|
332
|
+
if (!match) return { kind: "invalid", rawLine: line, reason: "invalid env key" };
|
|
333
|
+
return {
|
|
334
|
+
kind: "entry",
|
|
335
|
+
rawLine: line,
|
|
336
|
+
key: match[1],
|
|
337
|
+
prefix: line.slice(0, eqIdx + 1),
|
|
338
|
+
rawValue: line.slice(eqIdx + 1)
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
function loadEnvDocument(filePath) {
|
|
342
|
+
const fileExists = existsSync3(filePath);
|
|
343
|
+
const document = fileExists ? createTextDocument(readFileSync3(filePath, "utf8")) : createEmptyDocument();
|
|
344
|
+
const parsedLines = document.lines.map((line, index) => ({
|
|
345
|
+
lineNumber: index + 1,
|
|
346
|
+
...parseEnvLine(line)
|
|
347
|
+
}));
|
|
348
|
+
const currentMap = /* @__PURE__ */ new Map();
|
|
349
|
+
const occurrencesMap = /* @__PURE__ */ new Map();
|
|
350
|
+
let invalidLineCount = 0;
|
|
351
|
+
let shadowedEntryCount = 0;
|
|
352
|
+
for (const line of parsedLines) {
|
|
353
|
+
if (line.kind === "entry") {
|
|
354
|
+
const occurrences = occurrencesMap.get(line.key) ?? [];
|
|
355
|
+
occurrences.push({ value: line.rawValue, prefix: line.prefix, lineNumber: line.lineNumber });
|
|
356
|
+
occurrencesMap.set(line.key, occurrences);
|
|
357
|
+
if (currentMap.has(line.key)) shadowedEntryCount++;
|
|
358
|
+
currentMap.set(line.key, { value: line.rawValue });
|
|
359
|
+
} else if (line.kind === "invalid") {
|
|
360
|
+
invalidLineCount++;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return {
|
|
364
|
+
exists: fileExists,
|
|
365
|
+
document,
|
|
366
|
+
parsedLines,
|
|
367
|
+
currentMap,
|
|
368
|
+
occurrencesMap,
|
|
369
|
+
invalidLineCount,
|
|
370
|
+
shadowedEntryCount
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
function desiredRecordsForFile(config, filePath, state) {
|
|
374
|
+
const desired = /* @__PURE__ */ new Map();
|
|
375
|
+
let excludedRecordsIgnored = 0;
|
|
376
|
+
for (const record of state.get(filePath)?.values() ?? []) {
|
|
377
|
+
if (isExcluded(config, filePath, record.k)) {
|
|
378
|
+
excludedRecordsIgnored++;
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
desired.set(record.k, record);
|
|
382
|
+
}
|
|
383
|
+
return { desired, excludedRecordsIgnored };
|
|
384
|
+
}
|
|
385
|
+
function formatPreviewValue(value) {
|
|
386
|
+
const escaped = value.replace(/\\/g, "\\\\").replace(/\r/g, "\\r").replace(/\n/g, "\\n").replace(/\t/g, "\\t").replace(/"/g, '\\"');
|
|
387
|
+
const quoted = `"${escaped}"`;
|
|
388
|
+
if (quoted.length <= PREVIEW_VALUE_LIMIT) return quoted;
|
|
389
|
+
return `"${escaped.slice(0, PREVIEW_VALUE_LIMIT - 24)}"... (${value.length} chars)`;
|
|
390
|
+
}
|
|
391
|
+
function formatPreviewValues(values, occurrenceCount) {
|
|
392
|
+
const uniqueValues = [...new Set(values)];
|
|
393
|
+
const valuePreview = uniqueValues.length === 1 ? formatPreviewValue(uniqueValues[0]) : `[${uniqueValues.map(formatPreviewValue).join(", ")}]`;
|
|
394
|
+
return occurrenceCount > 1 ? `${valuePreview} (${occurrenceCount} occurrences)` : valuePreview;
|
|
395
|
+
}
|
|
396
|
+
function buildRestorePlanFromState(config, store) {
|
|
397
|
+
const files = [];
|
|
398
|
+
const summary = {
|
|
399
|
+
add: 0,
|
|
400
|
+
modify: 0,
|
|
401
|
+
delete: 0,
|
|
402
|
+
identical: 0,
|
|
403
|
+
filesWithChanges: 0
|
|
404
|
+
};
|
|
405
|
+
const managedFiles = new Set(config.envFiles);
|
|
406
|
+
const unmanagedStoreFiles = [...store.state.keys()].filter(
|
|
407
|
+
(filePath) => !managedFiles.has(filePath)
|
|
408
|
+
);
|
|
409
|
+
let excludedRecordsIgnored = 0;
|
|
410
|
+
for (const filePath of config.envFiles) {
|
|
411
|
+
const { desired, excludedRecordsIgnored: fileExcludedRecordsIgnored } = desiredRecordsForFile(
|
|
412
|
+
config,
|
|
413
|
+
filePath,
|
|
414
|
+
store.state
|
|
415
|
+
);
|
|
416
|
+
excludedRecordsIgnored += fileExcludedRecordsIgnored;
|
|
417
|
+
const envDoc = loadEnvDocument(filePath);
|
|
418
|
+
const entries = [];
|
|
419
|
+
for (const record of desired.values()) {
|
|
420
|
+
const occurrences = envDoc.occurrencesMap.get(record.k) ?? [];
|
|
421
|
+
const currentValues = occurrences.map((item) => item.value);
|
|
422
|
+
let action;
|
|
423
|
+
if (record.op === "delete") action = occurrences.length === 0 ? "identical" : "delete";
|
|
424
|
+
else if (occurrences.length === 0) action = "add";
|
|
425
|
+
else action = currentValues.every((value) => value === record.v) ? "identical" : "modify";
|
|
426
|
+
summary[action]++;
|
|
427
|
+
entries.push({
|
|
428
|
+
filePath,
|
|
429
|
+
key: record.k,
|
|
430
|
+
action,
|
|
431
|
+
currentValues,
|
|
432
|
+
occurrenceCount: occurrences.length,
|
|
433
|
+
nextValue: record.op === "set" ? record.v : void 0
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
entries.sort((left, right) => left.key.localeCompare(right.key));
|
|
437
|
+
const changed = entries.some((entry) => entry.action !== "identical");
|
|
438
|
+
if (changed) summary.filesWithChanges++;
|
|
439
|
+
files.push({ filePath, entries, changed });
|
|
440
|
+
}
|
|
441
|
+
return {
|
|
442
|
+
storePath: config.storePath,
|
|
443
|
+
files,
|
|
444
|
+
summary,
|
|
445
|
+
failedRecords: store.failedRecords,
|
|
446
|
+
parsedRecords: store.parsedRecords,
|
|
447
|
+
rawRecords: store.rawRecords,
|
|
448
|
+
aliasedRecords: store.aliasedRecords,
|
|
449
|
+
unmanagedStoreFiles,
|
|
450
|
+
excludedRecordsIgnored
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
function printRestorePreview(plan) {
|
|
454
|
+
const logger = getLogger2();
|
|
455
|
+
logger.log("[env-lane:vault] Restore preview:");
|
|
456
|
+
if (plan.summary.filesWithChanges === 0) {
|
|
457
|
+
logger.log("[env-lane:vault] No file changes detected.");
|
|
458
|
+
logger.log(`[env-lane:vault] Skipped identical key-value pairs: ${plan.summary.identical}`);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
for (const file of plan.files) {
|
|
462
|
+
const changedEntries = file.entries.filter((entry) => entry.action !== "identical");
|
|
463
|
+
if (changedEntries.length === 0) continue;
|
|
464
|
+
logger.log(`
|
|
465
|
+
[env-lane:vault] File: ${file.filePath}`);
|
|
466
|
+
for (const entry of changedEntries) {
|
|
467
|
+
if (entry.action === "add") {
|
|
468
|
+
logger.log(` ADD ${entry.key}=${formatPreviewValue(entry.nextValue ?? "")}`);
|
|
469
|
+
} else if (entry.action === "modify") {
|
|
470
|
+
logger.log(
|
|
471
|
+
` MODIFY ${entry.key}: ${formatPreviewValues(entry.currentValues, entry.occurrenceCount)} -> ${formatPreviewValue(entry.nextValue ?? "")}`
|
|
472
|
+
);
|
|
473
|
+
} else {
|
|
474
|
+
logger.log(
|
|
475
|
+
` DELETE ${entry.key}: ${formatPreviewValues(entry.currentValues, entry.occurrenceCount)}`
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
logger.log("");
|
|
481
|
+
logger.log(
|
|
482
|
+
`[env-lane:vault] Summary: ${plan.summary.modify} modify, ${plan.summary.add} add, ${plan.summary.delete} delete, ${plan.summary.identical} identical skipped`
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
async function confirmRestore(options = {}) {
|
|
486
|
+
const logger = getLogger2();
|
|
487
|
+
if (options.dryRun) return false;
|
|
488
|
+
if (options.autoApprove) {
|
|
489
|
+
logger.log("[env-lane:vault] Auto-approved restore.");
|
|
490
|
+
return true;
|
|
491
|
+
}
|
|
492
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
493
|
+
throw new Error(
|
|
494
|
+
"Restore confirmation requires an interactive terminal. Re-run with --yes to apply or --dry-run to preview."
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
logger.write("[env-lane:vault] Press y to apply restore, any other key to cancel: ");
|
|
498
|
+
return new Promise((resolve) => {
|
|
499
|
+
const stdin = process.stdin;
|
|
500
|
+
const cleanup = () => {
|
|
501
|
+
stdin.off("data", onData);
|
|
502
|
+
stdin.pause();
|
|
503
|
+
if (typeof stdin.setRawMode === "function") stdin.setRawMode(false);
|
|
504
|
+
};
|
|
505
|
+
const onData = (chunk) => {
|
|
506
|
+
cleanup();
|
|
507
|
+
logger.write("\n");
|
|
508
|
+
const input = String(chunk);
|
|
509
|
+
if (input === "") {
|
|
510
|
+
resolve(false);
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
resolve(
|
|
514
|
+
input.replace(/[\r\n]+/g, "").trim().toLowerCase() === "y"
|
|
515
|
+
);
|
|
516
|
+
};
|
|
517
|
+
stdin.resume();
|
|
518
|
+
if (typeof stdin.setRawMode === "function") stdin.setRawMode(true);
|
|
519
|
+
stdin.once("data", onData);
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
function applyRestoreFile(config, filePath, state) {
|
|
523
|
+
const { desired } = desiredRecordsForFile(config, filePath, state);
|
|
524
|
+
const envDoc = loadEnvDocument(filePath);
|
|
525
|
+
const seenKeys = /* @__PURE__ */ new Set();
|
|
526
|
+
const nextLines = [];
|
|
527
|
+
for (const line of envDoc.parsedLines) {
|
|
528
|
+
if (line.kind !== "entry") {
|
|
529
|
+
nextLines.push(line.rawLine);
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
const desiredRecord = desired.get(line.key);
|
|
533
|
+
if (!desiredRecord) {
|
|
534
|
+
nextLines.push(line.rawLine);
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
seenKeys.add(line.key);
|
|
538
|
+
if (desiredRecord.op === "delete") continue;
|
|
539
|
+
nextLines.push(`${line.prefix}${desiredRecord.v ?? ""}`);
|
|
540
|
+
}
|
|
541
|
+
const additions = [...desired.entries()].filter(([key, record]) => record.op === "set" && !seenKeys.has(key)).sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey));
|
|
542
|
+
for (const [key, record] of additions) nextLines.push(`${key}=${record.v ?? ""}`);
|
|
543
|
+
const current = envDoc.exists ? renderTextDocument(envDoc.document, envDoc.document.lines) : "";
|
|
544
|
+
const next = renderTextDocument(envDoc.document, nextLines);
|
|
545
|
+
if (current === next) return false;
|
|
546
|
+
mkdirSync(path3.dirname(filePath), { recursive: true });
|
|
547
|
+
writeFileSync(filePath, next, "utf8");
|
|
548
|
+
return true;
|
|
549
|
+
}
|
|
550
|
+
async function encryptEnvFiles(configPath, keyFilePath, options = {}) {
|
|
551
|
+
const config = await loadVaultConfig(configPath);
|
|
552
|
+
warnUnsafeVault({
|
|
553
|
+
disableUnsafeWarning: options.disableUnsafeWarning ?? config.disableUnsafeWarning
|
|
554
|
+
});
|
|
555
|
+
const key = deriveVaultKey(keyFilePath);
|
|
556
|
+
const store = readStore(config, key, {
|
|
557
|
+
allowMissing: true,
|
|
558
|
+
ignoreCorruptRecords: options.ignoreCorruptRecords
|
|
559
|
+
});
|
|
560
|
+
const state = store.state;
|
|
561
|
+
let setRecordsWritten = 0;
|
|
562
|
+
let deleteRecordsWritten = 0;
|
|
563
|
+
let skippedUnchanged = 0;
|
|
564
|
+
let excludedEntriesIgnored = 0;
|
|
565
|
+
let missingFilesSkipped = 0;
|
|
566
|
+
let invalidLinesIgnored = 0;
|
|
567
|
+
let shadowedEntriesIgnored = 0;
|
|
568
|
+
for (const filePath of config.envFiles) {
|
|
569
|
+
if (!existsSync3(filePath)) {
|
|
570
|
+
missingFilesSkipped++;
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
const envDoc = loadEnvDocument(filePath);
|
|
574
|
+
const prev = state.get(filePath) ?? /* @__PURE__ */ new Map();
|
|
575
|
+
const current = /* @__PURE__ */ new Map();
|
|
576
|
+
for (const [keyName, { value }] of envDoc.currentMap) {
|
|
577
|
+
if (isExcluded(config, filePath, keyName)) {
|
|
578
|
+
excludedEntriesIgnored++;
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
current.set(keyName, value);
|
|
582
|
+
const old = prev.get(keyName);
|
|
583
|
+
if (old?.op === "set" && old.v === value) {
|
|
584
|
+
skippedUnchanged++;
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
append(config, key, { f: filePath, k: keyName, v: value, op: "set", t: Date.now() });
|
|
588
|
+
emitStructuredChange("encrypt", {
|
|
589
|
+
action: old?.op === "set" ? "update" : "set",
|
|
590
|
+
filePath: portable(filePath),
|
|
591
|
+
key: keyName
|
|
592
|
+
});
|
|
593
|
+
setRecordsWritten++;
|
|
594
|
+
}
|
|
595
|
+
if (config.trackDeletions) {
|
|
596
|
+
for (const [keyName, old] of prev.entries()) {
|
|
597
|
+
if (isExcluded(config, filePath, keyName)) continue;
|
|
598
|
+
if (old.op === "set" && !current.has(keyName)) {
|
|
599
|
+
append(config, key, { f: filePath, k: keyName, op: "delete", t: Date.now() });
|
|
600
|
+
emitStructuredChange("encrypt", {
|
|
601
|
+
action: "delete",
|
|
602
|
+
filePath: portable(filePath),
|
|
603
|
+
key: keyName
|
|
604
|
+
});
|
|
605
|
+
deleteRecordsWritten++;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
invalidLinesIgnored += envDoc.invalidLineCount;
|
|
610
|
+
shadowedEntriesIgnored += envDoc.shadowedEntryCount;
|
|
611
|
+
}
|
|
612
|
+
return {
|
|
613
|
+
storePath: config.storePath,
|
|
614
|
+
setRecordsWritten,
|
|
615
|
+
deleteRecordsWritten,
|
|
616
|
+
skippedUnchanged,
|
|
617
|
+
excludedEntriesIgnored,
|
|
618
|
+
missingFilesSkipped,
|
|
619
|
+
invalidLinesIgnored,
|
|
620
|
+
shadowedEntriesIgnored,
|
|
621
|
+
rawRecords: store.rawRecords,
|
|
622
|
+
parsedRecords: store.parsedRecords,
|
|
623
|
+
failedRecords: store.failedRecords,
|
|
624
|
+
aliasedRecords: store.aliasedRecords
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
async function buildRestorePlan(configPath, keyFilePath, options = {}) {
|
|
628
|
+
const config = await loadVaultConfig(configPath);
|
|
629
|
+
warnUnsafeVault({
|
|
630
|
+
disableUnsafeWarning: options.disableUnsafeWarning ?? config.disableUnsafeWarning
|
|
631
|
+
});
|
|
632
|
+
const key = deriveVaultKey(keyFilePath);
|
|
633
|
+
return buildRestorePlanFromState(
|
|
634
|
+
config,
|
|
635
|
+
readStore(config, key, { ignoreCorruptRecords: options.ignoreCorruptRecords })
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
async function decryptEnvFiles(configPath, keyFilePath, options = {}) {
|
|
639
|
+
const config = await loadVaultConfig(configPath);
|
|
640
|
+
warnUnsafeVault({
|
|
641
|
+
disableUnsafeWarning: options.disableUnsafeWarning ?? config.disableUnsafeWarning
|
|
642
|
+
});
|
|
643
|
+
const key = deriveVaultKey(keyFilePath);
|
|
644
|
+
const store = readStore(config, key, { ignoreCorruptRecords: options.ignoreCorruptRecords });
|
|
645
|
+
const plan = buildRestorePlanFromState(config, store);
|
|
646
|
+
printRestorePreview(plan);
|
|
647
|
+
const logger = getLogger2();
|
|
648
|
+
if (plan.failedRecords > 0) {
|
|
649
|
+
logger.warn(
|
|
650
|
+
`[env-lane:vault] Warning: skipped ${plan.failedRecords} unreadable store record(s).`
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
if (plan.aliasedRecords > 0) {
|
|
654
|
+
logger.log(
|
|
655
|
+
`[env-lane:vault] Remapped ${plan.aliasedRecords} store record(s) from previous checkout paths to current env files.`
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
if (plan.unmanagedStoreFiles.length > 0) {
|
|
659
|
+
logger.warn(
|
|
660
|
+
`[env-lane:vault] Warning: ignored ${plan.unmanagedStoreFiles.length} store file(s) not listed in config.envFiles.`
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
if (plan.excludedRecordsIgnored > 0) {
|
|
664
|
+
logger.log(
|
|
665
|
+
`[env-lane:vault] Ignored ${plan.excludedRecordsIgnored} excluded store record(s) during restore.`
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
const results = [];
|
|
669
|
+
if (plan.summary.filesWithChanges === 0 || options.dryRun) {
|
|
670
|
+
for (const file of plan.files) {
|
|
671
|
+
results.push({
|
|
672
|
+
filePath: file.filePath,
|
|
673
|
+
keys: file.entries.filter((entry) => entry.action !== "delete").length,
|
|
674
|
+
changed: file.changed,
|
|
675
|
+
entries: file.entries
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
return { ...plan, applied: false, filesWritten: 0, results };
|
|
679
|
+
}
|
|
680
|
+
const confirmed = await confirmRestore(options);
|
|
681
|
+
if (!confirmed) {
|
|
682
|
+
logger.log("[env-lane:vault] Restore cancelled. No files were changed.");
|
|
683
|
+
for (const file of plan.files) {
|
|
684
|
+
results.push({
|
|
685
|
+
filePath: file.filePath,
|
|
686
|
+
keys: file.entries.filter((entry) => entry.action !== "delete").length,
|
|
687
|
+
changed: file.changed,
|
|
688
|
+
entries: file.entries
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
return { ...plan, applied: false, filesWritten: 0, results };
|
|
692
|
+
}
|
|
693
|
+
let filesWritten = 0;
|
|
694
|
+
for (const file of plan.files) {
|
|
695
|
+
if (!file.changed) {
|
|
696
|
+
results.push({
|
|
697
|
+
filePath: file.filePath,
|
|
698
|
+
keys: file.entries.filter((entry) => entry.action !== "delete").length,
|
|
699
|
+
changed: false,
|
|
700
|
+
entries: file.entries
|
|
701
|
+
});
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
const changed = applyRestoreFile(config, file.filePath, store.state);
|
|
705
|
+
if (changed) {
|
|
706
|
+
filesWritten++;
|
|
707
|
+
for (const entry of file.entries.filter((item) => item.action !== "identical")) {
|
|
708
|
+
emitStructuredChange("decrypt", {
|
|
709
|
+
action: entry.action,
|
|
710
|
+
filePath: portable(file.filePath),
|
|
711
|
+
key: entry.key
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
emitStructuredChange("decrypt", { action: "write-file", filePath: portable(file.filePath) });
|
|
715
|
+
}
|
|
716
|
+
results.push({
|
|
717
|
+
filePath: file.filePath,
|
|
718
|
+
keys: file.entries.filter((entry) => entry.action !== "delete").length,
|
|
719
|
+
changed,
|
|
720
|
+
entries: file.entries
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
return { ...plan, applied: filesWritten > 0, filesWritten, results };
|
|
724
|
+
}
|
|
725
|
+
async function runVault(configPath, keyFilePath, mode, options = {}) {
|
|
726
|
+
return mode === "encrypt" ? encryptEnvFiles(configPath, keyFilePath, options) : decryptEnvFiles(configPath, keyFilePath, options);
|
|
727
|
+
}
|
|
728
|
+
export {
|
|
729
|
+
VAULT_UNSAFE_WARNING,
|
|
730
|
+
buildRestorePlan,
|
|
731
|
+
decryptEnvFiles,
|
|
732
|
+
decryptRecord,
|
|
733
|
+
defineVaultConfig,
|
|
734
|
+
deriveVaultKey,
|
|
735
|
+
encryptEnvFiles,
|
|
736
|
+
encryptRecord,
|
|
737
|
+
loadVaultConfig,
|
|
738
|
+
runVault,
|
|
739
|
+
warnUnsafeVault
|
|
740
|
+
};
|