@envsave/cli 1.0.37
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 +223 -0
- package/dist/cli.js +1604 -0
- package/dist/index.js +159 -0
- package/package.json +48 -0
- package/python/envsave/__init__.py +79 -0
- package/python/evault.py +85 -0
- package/python/pyproject.toml +18 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1604 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __returnValue = (v) => v;
|
|
4
|
+
function __exportSetter(name, newValue) {
|
|
5
|
+
this[name] = __returnValue.bind(null, newValue);
|
|
6
|
+
}
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, {
|
|
10
|
+
get: all[name],
|
|
11
|
+
enumerable: true,
|
|
12
|
+
configurable: true,
|
|
13
|
+
set: __exportSetter.bind(all, name)
|
|
14
|
+
});
|
|
15
|
+
};
|
|
16
|
+
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
17
|
+
|
|
18
|
+
// src/crypto.ts
|
|
19
|
+
import { createCipheriv, createDecipheriv, createHmac, randomBytes, pbkdf2Sync } from "node:crypto";
|
|
20
|
+
function deriveKey(password, salt) {
|
|
21
|
+
return pbkdf2Sync(password, salt, PBKDF2_ITERATIONS, KEY_LENGTH, "sha512");
|
|
22
|
+
}
|
|
23
|
+
function encrypt(data, password) {
|
|
24
|
+
const salt = randomBytes(SALT_LENGTH);
|
|
25
|
+
const iv = randomBytes(IV_LENGTH);
|
|
26
|
+
const key = deriveKey(password, salt);
|
|
27
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
28
|
+
const encrypted = Buffer.concat([cipher.update(data, "utf8"), cipher.final()]);
|
|
29
|
+
const authTag = cipher.getAuthTag();
|
|
30
|
+
return Buffer.concat([salt, iv, encrypted, authTag]);
|
|
31
|
+
}
|
|
32
|
+
function decrypt(payload, password) {
|
|
33
|
+
const salt = payload.subarray(0, SALT_LENGTH);
|
|
34
|
+
const iv = payload.subarray(SALT_LENGTH, SALT_LENGTH + IV_LENGTH);
|
|
35
|
+
const authTag = payload.subarray(payload.length - AUTH_TAG_LENGTH);
|
|
36
|
+
const encrypted = payload.subarray(SALT_LENGTH + IV_LENGTH, payload.length - AUTH_TAG_LENGTH);
|
|
37
|
+
const key = deriveKey(password, salt);
|
|
38
|
+
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
|
39
|
+
decipher.setAuthTag(authTag);
|
|
40
|
+
return decipher.update(encrypted) + decipher.final("utf8");
|
|
41
|
+
}
|
|
42
|
+
function encryptWithKey(data, key) {
|
|
43
|
+
const iv = randomBytes(IV_LENGTH);
|
|
44
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
45
|
+
const encrypted = Buffer.concat([cipher.update(data, "utf8"), cipher.final()]);
|
|
46
|
+
const authTag = cipher.getAuthTag();
|
|
47
|
+
return Buffer.concat([iv, encrypted, authTag]);
|
|
48
|
+
}
|
|
49
|
+
function decryptWithKey(payload, key) {
|
|
50
|
+
const iv = payload.subarray(0, IV_LENGTH);
|
|
51
|
+
const authTag = payload.subarray(payload.length - AUTH_TAG_LENGTH);
|
|
52
|
+
const encrypted = payload.subarray(IV_LENGTH, payload.length - AUTH_TAG_LENGTH);
|
|
53
|
+
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
|
54
|
+
decipher.setAuthTag(authTag);
|
|
55
|
+
return decipher.update(encrypted) + decipher.final("utf8");
|
|
56
|
+
}
|
|
57
|
+
function hmacSha256(seedHex, data) {
|
|
58
|
+
return createHmac("sha256", Buffer.from(seedHex, "hex")).update(data).digest("hex");
|
|
59
|
+
}
|
|
60
|
+
var PBKDF2_ITERATIONS = 1e5, SALT_LENGTH = 32, IV_LENGTH = 12, KEY_LENGTH = 32, AUTH_TAG_LENGTH = 16;
|
|
61
|
+
var init_crypto = () => {};
|
|
62
|
+
|
|
63
|
+
// src/env-parser.ts
|
|
64
|
+
import { readFileSync } from "node:fs";
|
|
65
|
+
function parseEnvFile(filePath) {
|
|
66
|
+
const content = readFileSync(filePath, "utf8");
|
|
67
|
+
const result = {};
|
|
68
|
+
for (const line of content.split(`
|
|
69
|
+
`)) {
|
|
70
|
+
let trimmed = line.trim();
|
|
71
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
72
|
+
continue;
|
|
73
|
+
if (trimmed.startsWith("export ")) {
|
|
74
|
+
trimmed = trimmed.substring(7).trim();
|
|
75
|
+
}
|
|
76
|
+
const eqIndex = trimmed.indexOf("=");
|
|
77
|
+
if (eqIndex === -1)
|
|
78
|
+
continue;
|
|
79
|
+
const key = trimmed.substring(0, eqIndex).trim();
|
|
80
|
+
let value = trimmed.substring(eqIndex + 1).trim();
|
|
81
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
82
|
+
continue;
|
|
83
|
+
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
84
|
+
value = value.slice(1, -1);
|
|
85
|
+
}
|
|
86
|
+
if (key) {
|
|
87
|
+
result[key] = value;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
var init_env_parser = () => {};
|
|
93
|
+
|
|
94
|
+
// src/license.ts
|
|
95
|
+
import { readFileSync as readFileSync2, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
96
|
+
import { join } from "node:path";
|
|
97
|
+
import { homedir } from "node:os";
|
|
98
|
+
function ensureDir() {
|
|
99
|
+
if (!existsSync(ENVSAVE_DIR)) {
|
|
100
|
+
mkdirSync(ENVSAVE_DIR, { recursive: true, mode: 448 });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function parseToken(token) {
|
|
104
|
+
if (!token.startsWith("es_"))
|
|
105
|
+
return null;
|
|
106
|
+
try {
|
|
107
|
+
const b64 = token.slice(3);
|
|
108
|
+
const pad = b64.length % 4 === 0 ? "" : "=".repeat(4 - b64.length % 4);
|
|
109
|
+
const raw = Buffer.from(b64.replace(/-/g, "+").replace(/_/g, "/") + pad, "base64").toString("utf8");
|
|
110
|
+
const parts = raw.split("|");
|
|
111
|
+
if (parts.length !== 4)
|
|
112
|
+
return null;
|
|
113
|
+
return {
|
|
114
|
+
email: parts[0],
|
|
115
|
+
createdAt: new Date(Number(parts[1]) * 1000),
|
|
116
|
+
expiresAt: new Date(Number(parts[2]) * 1000)
|
|
117
|
+
};
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async function activateLicense(token, offline = false) {
|
|
123
|
+
const dates = parseToken(token);
|
|
124
|
+
const OFFLINE_ALLOWED_EMAILS = [
|
|
125
|
+
"support@envsave.com"
|
|
126
|
+
];
|
|
127
|
+
if (offline) {
|
|
128
|
+
if (!dates) {
|
|
129
|
+
console.error("Invalid token format. Must be a signed es_ token.");
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
if (!OFFLINE_ALLOWED_EMAILS.includes(dates.email)) {
|
|
133
|
+
console.error("Offline activation is not available for this account.");
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
if (dates.expiresAt < new Date) {
|
|
137
|
+
console.error("Token is expired.");
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
ensureDir();
|
|
141
|
+
writeFileSync(LICENSE_FILE, JSON.stringify({
|
|
142
|
+
token,
|
|
143
|
+
validatedAt: new Date().toISOString(),
|
|
144
|
+
expiresAt: dates.expiresAt.toISOString()
|
|
145
|
+
}, null, 2), { mode: 384 });
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
const response = await fetch(LICENSE_API_URL, {
|
|
150
|
+
method: "POST",
|
|
151
|
+
headers: { "Content-Type": "application/json" },
|
|
152
|
+
body: JSON.stringify({ token })
|
|
153
|
+
});
|
|
154
|
+
if (!response.ok) {
|
|
155
|
+
console.error("License validation failed: server returned", response.status);
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
const data = await response.json();
|
|
159
|
+
if (!data.valid) {
|
|
160
|
+
console.error("Invalid license token.");
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
ensureDir();
|
|
164
|
+
const licenseData = {
|
|
165
|
+
token,
|
|
166
|
+
validatedAt: new Date().toISOString(),
|
|
167
|
+
expiresAt: dates?.expiresAt.toISOString() ?? data.expiresAt ?? null
|
|
168
|
+
};
|
|
169
|
+
writeFileSync(LICENSE_FILE, JSON.stringify(licenseData, null, 2), { mode: 384 });
|
|
170
|
+
return true;
|
|
171
|
+
} catch (err) {
|
|
172
|
+
console.error("Failed to contact license server:", err.message);
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function getLicenseToken() {
|
|
177
|
+
if (!existsSync(LICENSE_FILE))
|
|
178
|
+
return null;
|
|
179
|
+
try {
|
|
180
|
+
const data = JSON.parse(readFileSync2(LICENSE_FILE, "utf8"));
|
|
181
|
+
return data.token ?? null;
|
|
182
|
+
} catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
async function tryAutoRenew(token) {
|
|
187
|
+
try {
|
|
188
|
+
const response = await fetch(RENEW_API_URL, {
|
|
189
|
+
method: "POST",
|
|
190
|
+
headers: { "Content-Type": "application/json" },
|
|
191
|
+
body: JSON.stringify({ token })
|
|
192
|
+
});
|
|
193
|
+
if (!response.ok)
|
|
194
|
+
return false;
|
|
195
|
+
const result = await response.json();
|
|
196
|
+
if (!result.token || !result.renewed)
|
|
197
|
+
return false;
|
|
198
|
+
const newDates = parseToken(result.token);
|
|
199
|
+
ensureDir();
|
|
200
|
+
writeFileSync(LICENSE_FILE, JSON.stringify({
|
|
201
|
+
token: result.token,
|
|
202
|
+
validatedAt: new Date().toISOString(),
|
|
203
|
+
expiresAt: newDates?.expiresAt.toISOString() ?? result.expiresAt ?? null
|
|
204
|
+
}, null, 2), { mode: 384 });
|
|
205
|
+
console.error("License renewed automatically.");
|
|
206
|
+
return true;
|
|
207
|
+
} catch {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
function hasValidLicense() {
|
|
212
|
+
if (!existsSync(LICENSE_FILE))
|
|
213
|
+
return false;
|
|
214
|
+
try {
|
|
215
|
+
const data = JSON.parse(readFileSync2(LICENSE_FILE, "utf8"));
|
|
216
|
+
const dates = parseToken(data.token);
|
|
217
|
+
if (dates)
|
|
218
|
+
return dates.expiresAt > new Date;
|
|
219
|
+
if (data.expiresAt)
|
|
220
|
+
return new Date(data.expiresAt) > new Date;
|
|
221
|
+
return true;
|
|
222
|
+
} catch {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
async function checkLicense() {
|
|
227
|
+
if (!existsSync(LICENSE_FILE))
|
|
228
|
+
return false;
|
|
229
|
+
try {
|
|
230
|
+
const data = JSON.parse(readFileSync2(LICENSE_FILE, "utf8"));
|
|
231
|
+
const dates = parseToken(data.token);
|
|
232
|
+
if (dates) {
|
|
233
|
+
if (dates.expiresAt > new Date)
|
|
234
|
+
return true;
|
|
235
|
+
return await tryAutoRenew(data.token);
|
|
236
|
+
}
|
|
237
|
+
if (data.expiresAt) {
|
|
238
|
+
if (new Date(data.expiresAt) > new Date)
|
|
239
|
+
return true;
|
|
240
|
+
return await tryAutoRenew(data.token);
|
|
241
|
+
}
|
|
242
|
+
return true;
|
|
243
|
+
} catch {
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
var ENVSAVE_DIR, LICENSE_FILE, LICENSE_API_URL, RENEW_API_URL;
|
|
248
|
+
var init_license = __esm(() => {
|
|
249
|
+
ENVSAVE_DIR = join(homedir(), ".envsave");
|
|
250
|
+
LICENSE_FILE = join(ENVSAVE_DIR, "license.json");
|
|
251
|
+
LICENSE_API_URL = process.env.ENVSAVE_LICENSE_API || "https://api.envsave.com/api/license/validate";
|
|
252
|
+
RENEW_API_URL = (process.env.ENVSAVE_LICENSE_API || "https://api.envsave.com/api/license/validate").replace("/validate", "/renew");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// src/vault.ts
|
|
256
|
+
var exports_vault = {};
|
|
257
|
+
__export(exports_vault, {
|
|
258
|
+
vaultExists: () => vaultExists,
|
|
259
|
+
showSecret: () => showSecret,
|
|
260
|
+
setSecret: () => setSecret,
|
|
261
|
+
searchKeys: () => searchKeys,
|
|
262
|
+
revealKey: () => revealKey,
|
|
263
|
+
listKeys: () => listKeys,
|
|
264
|
+
initVault: () => initVault,
|
|
265
|
+
importEnv: () => importEnv,
|
|
266
|
+
hasSecretWithSession: () => hasSecretWithSession,
|
|
267
|
+
getSecretWithSession: () => getSecretWithSession,
|
|
268
|
+
getSecretFromCache: () => getSecretFromCache,
|
|
269
|
+
getSecret: () => getSecret,
|
|
270
|
+
getRateLimitConfig: () => getRateLimitConfig,
|
|
271
|
+
getDeploySecrets: () => getDeploySecrets,
|
|
272
|
+
exportEnv: () => exportEnv,
|
|
273
|
+
deleteSecret: () => deleteSecret,
|
|
274
|
+
configureRateLimit: () => configureRateLimit,
|
|
275
|
+
changePassword: () => changePassword
|
|
276
|
+
});
|
|
277
|
+
import { readFileSync as readFileSync3, writeFileSync as writeFileSync2, existsSync as existsSync2, mkdirSync as mkdirSync2, chmodSync, appendFileSync, statSync } from "node:fs";
|
|
278
|
+
import { join as join2 } from "node:path";
|
|
279
|
+
import { homedir as homedir2 } from "node:os";
|
|
280
|
+
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
281
|
+
function getEnvsaveDir() {
|
|
282
|
+
return process.env.ENVSAVE_DIR || join2(homedir2(), ".envsave");
|
|
283
|
+
}
|
|
284
|
+
function getVaultFile() {
|
|
285
|
+
return join2(getEnvsaveDir(), "vault.enc");
|
|
286
|
+
}
|
|
287
|
+
function getCacheFile() {
|
|
288
|
+
return join2(getEnvsaveDir(), "cache.enc");
|
|
289
|
+
}
|
|
290
|
+
function getSessionFile() {
|
|
291
|
+
return join2(getEnvsaveDir(), ".session");
|
|
292
|
+
}
|
|
293
|
+
function getRateFile() {
|
|
294
|
+
return join2(getEnvsaveDir(), ".rate");
|
|
295
|
+
}
|
|
296
|
+
function getAccessLog() {
|
|
297
|
+
return join2(getEnvsaveDir(), "access.log");
|
|
298
|
+
}
|
|
299
|
+
function getConfigFile() {
|
|
300
|
+
return join2(getEnvsaveDir(), "config.json");
|
|
301
|
+
}
|
|
302
|
+
function guardRealVault() {
|
|
303
|
+
if (false) {}
|
|
304
|
+
}
|
|
305
|
+
function readConfig() {
|
|
306
|
+
if (!existsSync2(getConfigFile())) {
|
|
307
|
+
return { maxGetsPerMinute: DEFAULT_RATE_LIMIT_MAX, sessionTtlHours: DEFAULT_SESSION_TTL_MS / 3600000 };
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
return JSON.parse(readFileSync3(getConfigFile(), "utf8"));
|
|
311
|
+
} catch {
|
|
312
|
+
return { maxGetsPerMinute: DEFAULT_RATE_LIMIT_MAX, sessionTtlHours: DEFAULT_SESSION_TTL_MS / 3600000 };
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
function writeConfig(config) {
|
|
316
|
+
ensureDir2();
|
|
317
|
+
writeFileSync2(getConfigFile(), JSON.stringify(config, null, 2), { mode: 384 });
|
|
318
|
+
}
|
|
319
|
+
function configureRateLimit(password, options) {
|
|
320
|
+
readMainVault(password);
|
|
321
|
+
const config = readConfig();
|
|
322
|
+
if (options.maxGetsPerMinute !== undefined)
|
|
323
|
+
config.maxGetsPerMinute = options.maxGetsPerMinute;
|
|
324
|
+
if (options.sessionTtlHours !== undefined)
|
|
325
|
+
config.sessionTtlHours = options.sessionTtlHours;
|
|
326
|
+
writeConfig(config);
|
|
327
|
+
return config;
|
|
328
|
+
}
|
|
329
|
+
function getRateLimitConfig() {
|
|
330
|
+
return readConfig();
|
|
331
|
+
}
|
|
332
|
+
function hexToBaseN(hex, length, chars) {
|
|
333
|
+
let result = "";
|
|
334
|
+
for (let i = 0;i < length; i++) {
|
|
335
|
+
const hexPair = hex.slice(i * 2 % hex.length, i * 2 % hex.length + 2) || hex.slice(0, 2);
|
|
336
|
+
const byte = parseInt(hexPair, 16);
|
|
337
|
+
result += chars[byte % chars.length];
|
|
338
|
+
}
|
|
339
|
+
return result;
|
|
340
|
+
}
|
|
341
|
+
function computeOpaqueNameV3(hmacSeed, cleanName, sshKeyContent) {
|
|
342
|
+
let effectiveSeed = hmacSeed;
|
|
343
|
+
if (sshKeyContent) {
|
|
344
|
+
effectiveSeed = hmacSha256(hmacSeed, sshKeyContent);
|
|
345
|
+
}
|
|
346
|
+
const hash1 = hmacSha256(effectiveSeed, cleanName);
|
|
347
|
+
const hash2 = hmacSha256(effectiveSeed, cleanName + ":2");
|
|
348
|
+
return "es_" + hexToBaseN(hash1 + hash2, 48, BASE_CHARS_V3);
|
|
349
|
+
}
|
|
350
|
+
function computeOpaqueName(hmacSeed, cleanName, sshKeyContent) {
|
|
351
|
+
let effectiveSeed = hmacSeed;
|
|
352
|
+
if (sshKeyContent) {
|
|
353
|
+
effectiveSeed = hmacSha256(hmacSeed, sshKeyContent);
|
|
354
|
+
}
|
|
355
|
+
const hash1 = hmacSha256(effectiveSeed, cleanName);
|
|
356
|
+
const hash2 = hmacSha256(effectiveSeed, cleanName + ":2");
|
|
357
|
+
return "es_" + hexToBaseN(hash1 + hash2, 48, BASE_CHARS);
|
|
358
|
+
}
|
|
359
|
+
function computeOpaqueNameV4(hmacSeed, cleanName, sshKeyContent) {
|
|
360
|
+
let effectiveSeed = hmacSeed;
|
|
361
|
+
if (sshKeyContent) {
|
|
362
|
+
effectiveSeed = hmacSha256(hmacSeed, sshKeyContent);
|
|
363
|
+
}
|
|
364
|
+
const hash1 = hmacSha256(effectiveSeed, cleanName);
|
|
365
|
+
const hash2 = hmacSha256(effectiveSeed, cleanName + ":2");
|
|
366
|
+
return "es_" + hexToBaseN(hash1 + hash2, 48, BASE_CHARS_V4);
|
|
367
|
+
}
|
|
368
|
+
function computeOpaqueNameV2(hmacSeed, cleanName, sshKeyContent) {
|
|
369
|
+
let effectiveSeed = hmacSeed;
|
|
370
|
+
if (sshKeyContent) {
|
|
371
|
+
effectiveSeed = hmacSha256(hmacSeed, sshKeyContent);
|
|
372
|
+
}
|
|
373
|
+
const hash = hmacSha256(effectiveSeed, cleanName);
|
|
374
|
+
return "es_" + hash.slice(0, 24);
|
|
375
|
+
}
|
|
376
|
+
function ensureDir2() {
|
|
377
|
+
guardRealVault();
|
|
378
|
+
if (!existsSync2(getEnvsaveDir())) {
|
|
379
|
+
mkdirSync2(getEnvsaveDir(), { recursive: true, mode: 448 });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
function vaultExists() {
|
|
383
|
+
return existsSync2(getVaultFile());
|
|
384
|
+
}
|
|
385
|
+
function readMainVault(password) {
|
|
386
|
+
guardRealVault();
|
|
387
|
+
if (!existsSync2(getVaultFile())) {
|
|
388
|
+
throw new Error("Vault not found. Run 'envsave init' first.");
|
|
389
|
+
}
|
|
390
|
+
const payload = readFileSync3(getVaultFile());
|
|
391
|
+
const json = decrypt(payload, password);
|
|
392
|
+
const data = JSON.parse(json);
|
|
393
|
+
if (!data.version || data.version < 2) {
|
|
394
|
+
throw new Error("Vault format outdated. Delete ~/.envsave/ and re-initialize.");
|
|
395
|
+
}
|
|
396
|
+
if (data.version === 2 && data.registry?.length > 0) {
|
|
397
|
+
const sshKeyContent = data.sshKeyPath && existsSync2(data.sshKeyPath) ? readFileSync3(data.sshKeyPath, "utf8") : undefined;
|
|
398
|
+
const newSecrets = {};
|
|
399
|
+
for (const cleanName of data.registry) {
|
|
400
|
+
const oldKey = computeOpaqueNameV2(data.hmacSeed, cleanName, sshKeyContent);
|
|
401
|
+
const newKey = computeOpaqueName(data.hmacSeed, cleanName, sshKeyContent);
|
|
402
|
+
if (oldKey in data.secrets) {
|
|
403
|
+
newSecrets[newKey] = data.secrets[oldKey];
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
data.secrets = newSecrets;
|
|
407
|
+
data.version = 3;
|
|
408
|
+
writeMainVault(data, password);
|
|
409
|
+
console.error("Vault migrated to v3 (stronger opaque keys).");
|
|
410
|
+
}
|
|
411
|
+
if (data.version === 3 && data.registry?.length > 0) {
|
|
412
|
+
const sshKeyContent = data.sshKeyPath && existsSync2(data.sshKeyPath) ? readFileSync3(data.sshKeyPath, "utf8") : undefined;
|
|
413
|
+
const newSecrets = {};
|
|
414
|
+
for (const cleanName of data.registry) {
|
|
415
|
+
const oldKey = computeOpaqueNameV3(data.hmacSeed, cleanName, sshKeyContent);
|
|
416
|
+
const newKey = computeOpaqueName(data.hmacSeed, cleanName, sshKeyContent);
|
|
417
|
+
if (oldKey in data.secrets) {
|
|
418
|
+
newSecrets[newKey] = data.secrets[oldKey];
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
data.secrets = newSecrets;
|
|
422
|
+
data.version = 4;
|
|
423
|
+
writeMainVault(data, password);
|
|
424
|
+
console.error("Vault migrated to v4 (shell-safe opaque keys).");
|
|
425
|
+
}
|
|
426
|
+
if (data.version === 4 && data.registry?.length > 0) {
|
|
427
|
+
const sshKeyContent = data.sshKeyPath && existsSync2(data.sshKeyPath) ? readFileSync3(data.sshKeyPath, "utf8") : undefined;
|
|
428
|
+
const newSecrets = {};
|
|
429
|
+
for (const cleanName of data.registry) {
|
|
430
|
+
const oldKey = computeOpaqueNameV4(data.hmacSeed, cleanName, sshKeyContent);
|
|
431
|
+
const newKey = computeOpaqueName(data.hmacSeed, cleanName, sshKeyContent);
|
|
432
|
+
if (oldKey in data.secrets) {
|
|
433
|
+
newSecrets[newKey] = data.secrets[oldKey];
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
data.secrets = newSecrets;
|
|
437
|
+
data.version = 5;
|
|
438
|
+
writeMainVault(data, password);
|
|
439
|
+
console.error("Vault migrated to v5 (Vercel-compatible opaque keys).");
|
|
440
|
+
}
|
|
441
|
+
return data;
|
|
442
|
+
}
|
|
443
|
+
function writeMainVault(data, password) {
|
|
444
|
+
ensureDir2();
|
|
445
|
+
const json = JSON.stringify(data);
|
|
446
|
+
const encrypted = encrypt(json, password);
|
|
447
|
+
writeFileSync2(getVaultFile(), encrypted, { mode: 384 });
|
|
448
|
+
}
|
|
449
|
+
function writeSessionCache(secrets, registry) {
|
|
450
|
+
ensureDir2();
|
|
451
|
+
const sessionKey = randomBytes2(32);
|
|
452
|
+
const cache = { secrets, registry };
|
|
453
|
+
const json = JSON.stringify(cache);
|
|
454
|
+
const encrypted = encryptWithKey(json, sessionKey);
|
|
455
|
+
writeFileSync2(getCacheFile(), encrypted, { mode: 384 });
|
|
456
|
+
writeFileSync2(getSessionFile(), sessionKey.toString("hex"), { mode: 384 });
|
|
457
|
+
}
|
|
458
|
+
function readSessionCache() {
|
|
459
|
+
if (!existsSync2(getSessionFile()) || !existsSync2(getCacheFile()))
|
|
460
|
+
return null;
|
|
461
|
+
try {
|
|
462
|
+
const sessionStat = statSync(getSessionFile());
|
|
463
|
+
const age = Date.now() - sessionStat.mtimeMs;
|
|
464
|
+
const config = readConfig();
|
|
465
|
+
if (age > config.sessionTtlHours * 3600000) {
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
const sessionKeyHex = readFileSync3(getSessionFile(), "utf8").trim();
|
|
469
|
+
const sessionKey = Buffer.from(sessionKeyHex, "hex");
|
|
470
|
+
const payload = readFileSync3(getCacheFile());
|
|
471
|
+
const json = decryptWithKey(payload, sessionKey);
|
|
472
|
+
return JSON.parse(json);
|
|
473
|
+
} catch {
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
function checkRateLimit() {
|
|
478
|
+
const config = readConfig();
|
|
479
|
+
const now = Date.now();
|
|
480
|
+
let timestamps = [];
|
|
481
|
+
if (existsSync2(getRateFile())) {
|
|
482
|
+
try {
|
|
483
|
+
timestamps = JSON.parse(readFileSync3(getRateFile(), "utf8"));
|
|
484
|
+
} catch {
|
|
485
|
+
timestamps = [];
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
timestamps = timestamps.filter((t) => now - t < DEFAULT_RATE_LIMIT_WINDOW_MS);
|
|
489
|
+
if (timestamps.length >= config.maxGetsPerMinute) {
|
|
490
|
+
logAccess("RATE_LIMIT_BLOCKED", "blocked");
|
|
491
|
+
throw new Error(`Rate limit exceeded. Max ${config.maxGetsPerMinute} get calls per minute. Try again in ${Math.ceil((DEFAULT_RATE_LIMIT_WINDOW_MS - (now - timestamps[0])) / 1000)}s.`);
|
|
492
|
+
}
|
|
493
|
+
const threshold = Math.floor(config.maxGetsPerMinute * 0.8);
|
|
494
|
+
if (timestamps.length >= threshold) {
|
|
495
|
+
const remaining = config.maxGetsPerMinute - timestamps.length;
|
|
496
|
+
console.error(`⚠ Rate limit warning: ${remaining} get(s) remaining this minute.`);
|
|
497
|
+
}
|
|
498
|
+
timestamps.push(now);
|
|
499
|
+
writeFileSync2(getRateFile(), JSON.stringify(timestamps), { mode: 384 });
|
|
500
|
+
}
|
|
501
|
+
function logAccess(opaqueKey, source) {
|
|
502
|
+
ensureDir2();
|
|
503
|
+
const entry = `${new Date().toISOString()} ${source} ${opaqueKey.slice(0, 10)}...
|
|
504
|
+
`;
|
|
505
|
+
appendFileSync(getAccessLog(), entry, { mode: 384 });
|
|
506
|
+
}
|
|
507
|
+
function loadSshKey(vault) {
|
|
508
|
+
if (!vault.sshKeyPath)
|
|
509
|
+
return;
|
|
510
|
+
if (!existsSync2(vault.sshKeyPath)) {
|
|
511
|
+
throw new Error(`SSH key not found: ${vault.sshKeyPath}. This vault requires the SSH key to operate.`);
|
|
512
|
+
}
|
|
513
|
+
return readFileSync3(vault.sshKeyPath, "utf8");
|
|
514
|
+
}
|
|
515
|
+
function initVault(password, options) {
|
|
516
|
+
if (vaultExists()) {
|
|
517
|
+
throw new Error("Vault already exists. Delete ~/.envsave/vault.enc to reinitialize.");
|
|
518
|
+
}
|
|
519
|
+
if (options?.sshKeyPath && !existsSync2(options.sshKeyPath)) {
|
|
520
|
+
throw new Error(`SSH key not found: ${options.sshKeyPath}`);
|
|
521
|
+
}
|
|
522
|
+
const hmacSeed = randomBytes2(32).toString("hex");
|
|
523
|
+
const sshKeyContent = options?.sshKeyPath ? readFileSync3(options.sshKeyPath, "utf8") : undefined;
|
|
524
|
+
const vault = {
|
|
525
|
+
version: 4,
|
|
526
|
+
hmacSeed,
|
|
527
|
+
sshKeyPath: options?.sshKeyPath,
|
|
528
|
+
secrets: {},
|
|
529
|
+
registry: []
|
|
530
|
+
};
|
|
531
|
+
let count = 0;
|
|
532
|
+
if (options?.envFile) {
|
|
533
|
+
const envSecrets = parseEnvFile(options.envFile);
|
|
534
|
+
if (!hasValidLicense() && Object.keys(envSecrets).length > FREE_TIER_MAX_SECRETS) {
|
|
535
|
+
throw new Error(`Free tier limit: max ${FREE_TIER_MAX_SECRETS} secrets. .env file has ${Object.keys(envSecrets).length} keys.
|
|
536
|
+
Run 'envsave activate <token>' to unlock unlimited secrets.
|
|
537
|
+
Get your license at https://api.envsave.com`);
|
|
538
|
+
}
|
|
539
|
+
for (const [key, value] of Object.entries(envSecrets)) {
|
|
540
|
+
const opaque = computeOpaqueName(hmacSeed, key, sshKeyContent);
|
|
541
|
+
vault.secrets[opaque] = value;
|
|
542
|
+
vault.registry.push(key);
|
|
543
|
+
count++;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
writeMainVault(vault, password);
|
|
547
|
+
writeSessionCache(vault.secrets, vault.registry);
|
|
548
|
+
chmodSync(getEnvsaveDir(), 448);
|
|
549
|
+
return count;
|
|
550
|
+
}
|
|
551
|
+
function setSecret(key, value, password) {
|
|
552
|
+
const vault = readMainVault(password);
|
|
553
|
+
const sshKeyContent = loadSshKey(vault);
|
|
554
|
+
const opaque = computeOpaqueName(vault.hmacSeed, key, sshKeyContent);
|
|
555
|
+
if (!(opaque in vault.secrets) && !hasValidLicense()) {
|
|
556
|
+
if (vault.registry.length >= FREE_TIER_MAX_SECRETS) {
|
|
557
|
+
throw new Error(`Free tier limit: max ${FREE_TIER_MAX_SECRETS} secrets. Run 'envsave activate <token>' to unlock unlimited secrets.
|
|
558
|
+
Get your license at https://api.envsave.com`);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
vault.secrets[opaque] = value;
|
|
562
|
+
if (!vault.registry.includes(key)) {
|
|
563
|
+
vault.registry.push(key);
|
|
564
|
+
}
|
|
565
|
+
writeMainVault(vault, password);
|
|
566
|
+
writeSessionCache(vault.secrets, vault.registry);
|
|
567
|
+
return opaque;
|
|
568
|
+
}
|
|
569
|
+
function showSecret(cleanName, password) {
|
|
570
|
+
const vault = readMainVault(password);
|
|
571
|
+
const sshKeyContent = loadSshKey(vault);
|
|
572
|
+
const opaque = computeOpaqueName(vault.hmacSeed, cleanName, sshKeyContent);
|
|
573
|
+
writeSessionCache(vault.secrets, vault.registry);
|
|
574
|
+
return vault.secrets[opaque];
|
|
575
|
+
}
|
|
576
|
+
function revealKey(cleanName, password) {
|
|
577
|
+
const vault = readMainVault(password);
|
|
578
|
+
const sshKeyContent = loadSshKey(vault);
|
|
579
|
+
writeSessionCache(vault.secrets, vault.registry);
|
|
580
|
+
const opaque = computeOpaqueName(vault.hmacSeed, cleanName, sshKeyContent);
|
|
581
|
+
return opaque in vault.secrets ? opaque : undefined;
|
|
582
|
+
}
|
|
583
|
+
function getSecretFromCache(opaqueKey) {
|
|
584
|
+
checkRateLimit();
|
|
585
|
+
const cache = readSessionCache();
|
|
586
|
+
if (!cache) {
|
|
587
|
+
throw new Error("No session found. Run any envsave command with your password first.");
|
|
588
|
+
}
|
|
589
|
+
logAccess(opaqueKey, "cache");
|
|
590
|
+
return cache.secrets[opaqueKey];
|
|
591
|
+
}
|
|
592
|
+
function getSecret(opaqueKey, password) {
|
|
593
|
+
checkRateLimit();
|
|
594
|
+
const vault = readMainVault(password);
|
|
595
|
+
writeSessionCache(vault.secrets, vault.registry);
|
|
596
|
+
logAccess(opaqueKey, "vault");
|
|
597
|
+
return vault.secrets[opaqueKey];
|
|
598
|
+
}
|
|
599
|
+
function deleteSecret(key, password) {
|
|
600
|
+
const vault = readMainVault(password);
|
|
601
|
+
const sshKeyContent = loadSshKey(vault);
|
|
602
|
+
const opaque = computeOpaqueName(vault.hmacSeed, key, sshKeyContent);
|
|
603
|
+
if (!(opaque in vault.secrets))
|
|
604
|
+
return false;
|
|
605
|
+
delete vault.secrets[opaque];
|
|
606
|
+
vault.registry = vault.registry.filter((k) => k !== key);
|
|
607
|
+
writeMainVault(vault, password);
|
|
608
|
+
writeSessionCache(vault.secrets, vault.registry);
|
|
609
|
+
return true;
|
|
610
|
+
}
|
|
611
|
+
function listKeys(password) {
|
|
612
|
+
const vault = readMainVault(password);
|
|
613
|
+
writeSessionCache(vault.secrets, vault.registry);
|
|
614
|
+
return vault.registry;
|
|
615
|
+
}
|
|
616
|
+
function importEnv(filePath, password) {
|
|
617
|
+
const envSecrets = parseEnvFile(filePath);
|
|
618
|
+
const vault = readMainVault(password);
|
|
619
|
+
const sshKeyContent = loadSshKey(vault);
|
|
620
|
+
if (!hasValidLicense()) {
|
|
621
|
+
const newKeys = Object.keys(envSecrets).filter((key) => {
|
|
622
|
+
const opaque = computeOpaqueName(vault.hmacSeed, key, sshKeyContent);
|
|
623
|
+
return !(opaque in vault.secrets);
|
|
624
|
+
});
|
|
625
|
+
if (vault.registry.length + newKeys.length > FREE_TIER_MAX_SECRETS) {
|
|
626
|
+
throw new Error(`Free tier limit: max ${FREE_TIER_MAX_SECRETS} secrets. Import would add ${newKeys.length} new keys (${vault.registry.length} existing).
|
|
627
|
+
Run 'envsave activate <token>' to unlock unlimited secrets.
|
|
628
|
+
Get your license at https://api.envsave.com`);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
let count = 0;
|
|
632
|
+
for (const [key, value] of Object.entries(envSecrets)) {
|
|
633
|
+
const opaque = computeOpaqueName(vault.hmacSeed, key, sshKeyContent);
|
|
634
|
+
vault.secrets[opaque] = value;
|
|
635
|
+
if (!vault.registry.includes(key)) {
|
|
636
|
+
vault.registry.push(key);
|
|
637
|
+
}
|
|
638
|
+
count++;
|
|
639
|
+
}
|
|
640
|
+
writeMainVault(vault, password);
|
|
641
|
+
writeSessionCache(vault.secrets, vault.registry);
|
|
642
|
+
return count;
|
|
643
|
+
}
|
|
644
|
+
function exportEnv(password) {
|
|
645
|
+
const vault = readMainVault(password);
|
|
646
|
+
const sshKeyContent = loadSshKey(vault);
|
|
647
|
+
return vault.registry.map((cleanName) => {
|
|
648
|
+
const opaque = computeOpaqueName(vault.hmacSeed, cleanName, sshKeyContent);
|
|
649
|
+
return `${cleanName}=${vault.secrets[opaque] ?? ""}`;
|
|
650
|
+
}).join(`
|
|
651
|
+
`);
|
|
652
|
+
}
|
|
653
|
+
function changePassword(oldPassword, newPassword) {
|
|
654
|
+
const vault = readMainVault(oldPassword);
|
|
655
|
+
writeMainVault(vault, newPassword);
|
|
656
|
+
writeSessionCache(vault.secrets, vault.registry);
|
|
657
|
+
}
|
|
658
|
+
function searchKeys(pattern) {
|
|
659
|
+
const cache = readSessionCache();
|
|
660
|
+
if (!cache || !cache.registry) {
|
|
661
|
+
throw new Error("No session found. Run any envsave command with your password first.");
|
|
662
|
+
}
|
|
663
|
+
const lower = pattern.toLowerCase();
|
|
664
|
+
return cache.registry.filter((k) => k.toLowerCase().includes(lower));
|
|
665
|
+
}
|
|
666
|
+
function getDeploySecrets(opaqueKeys, password) {
|
|
667
|
+
const vault = readMainVault(password);
|
|
668
|
+
const sshKeyContent = loadSshKey(vault);
|
|
669
|
+
writeSessionCache(vault.secrets, vault.registry);
|
|
670
|
+
const results = [];
|
|
671
|
+
for (const opaque of opaqueKeys) {
|
|
672
|
+
if (opaque in vault.secrets) {
|
|
673
|
+
const cleanName = vault.registry?.find((r) => computeOpaqueName(vault.hmacSeed, r, sshKeyContent) === opaque) || opaque;
|
|
674
|
+
results.push({ name: cleanName, opaque, value: vault.secrets[opaque] });
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
return results;
|
|
678
|
+
}
|
|
679
|
+
function getSecretWithSession(opaqueKey) {
|
|
680
|
+
return getSecretFromCache(opaqueKey);
|
|
681
|
+
}
|
|
682
|
+
function hasSecretWithSession(opaqueKey) {
|
|
683
|
+
try {
|
|
684
|
+
const cache = readSessionCache();
|
|
685
|
+
if (!cache)
|
|
686
|
+
return false;
|
|
687
|
+
return opaqueKey in cache.secrets;
|
|
688
|
+
} catch {
|
|
689
|
+
return false;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
var FREE_TIER_MAX_SECRETS = 5, DEFAULT_SESSION_TTL_MS, DEFAULT_RATE_LIMIT_WINDOW_MS, DEFAULT_RATE_LIMIT_MAX = 30, BASE_CHARS_V3 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@$&*=-!", BASE_CHARS_V4 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_", BASE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_";
|
|
693
|
+
var init_vault = __esm(() => {
|
|
694
|
+
init_crypto();
|
|
695
|
+
init_env_parser();
|
|
696
|
+
init_license();
|
|
697
|
+
DEFAULT_SESSION_TTL_MS = 4 * 60 * 60 * 1000;
|
|
698
|
+
DEFAULT_RATE_LIMIT_WINDOW_MS = 60 * 1000;
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
// src/scanner.ts
|
|
702
|
+
var exports_scanner = {};
|
|
703
|
+
__export(exports_scanner, {
|
|
704
|
+
scanProject: () => scanProject
|
|
705
|
+
});
|
|
706
|
+
import { readdirSync, readFileSync as readFileSync4, statSync as statSync2 } from "node:fs";
|
|
707
|
+
import { join as join3, relative } from "node:path";
|
|
708
|
+
function walkDir(dir, base, results) {
|
|
709
|
+
let entries;
|
|
710
|
+
try {
|
|
711
|
+
entries = readdirSync(dir);
|
|
712
|
+
} catch {
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
for (const entry of entries) {
|
|
716
|
+
if (SKIP_DIRS.has(entry))
|
|
717
|
+
continue;
|
|
718
|
+
const fullPath = join3(dir, entry);
|
|
719
|
+
let stat;
|
|
720
|
+
try {
|
|
721
|
+
stat = statSync2(fullPath);
|
|
722
|
+
} catch {
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
if (stat.isDirectory()) {
|
|
726
|
+
walkDir(fullPath, base, results);
|
|
727
|
+
} else if (stat.isFile()) {
|
|
728
|
+
const ext = entry.slice(entry.lastIndexOf("."));
|
|
729
|
+
if (!SCAN_EXTENSIONS.has(ext))
|
|
730
|
+
continue;
|
|
731
|
+
scanFile(fullPath, base, results);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
function scanFile(filePath, base, results) {
|
|
736
|
+
let content;
|
|
737
|
+
try {
|
|
738
|
+
content = readFileSync4(filePath, "utf8");
|
|
739
|
+
} catch {
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
const relPath = relative(base, filePath);
|
|
743
|
+
const lines = content.split(`
|
|
744
|
+
`);
|
|
745
|
+
for (let i = 0;i < lines.length; i++) {
|
|
746
|
+
const line = lines[i];
|
|
747
|
+
for (const pattern of PATTERNS) {
|
|
748
|
+
pattern.regex.lastIndex = 0;
|
|
749
|
+
let m;
|
|
750
|
+
while ((m = pattern.regex.exec(line)) !== null) {
|
|
751
|
+
results.push({
|
|
752
|
+
file: relPath,
|
|
753
|
+
line: i + 1,
|
|
754
|
+
match: pattern.extract(m),
|
|
755
|
+
type: pattern.type
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
function scanProject(dir) {
|
|
762
|
+
const results = [];
|
|
763
|
+
walkDir(dir, dir, results);
|
|
764
|
+
return results;
|
|
765
|
+
}
|
|
766
|
+
var SKIP_DIRS, SCAN_EXTENSIONS, PATTERNS;
|
|
767
|
+
var init_scanner = __esm(() => {
|
|
768
|
+
SKIP_DIRS = new Set([
|
|
769
|
+
"node_modules",
|
|
770
|
+
".git",
|
|
771
|
+
".next",
|
|
772
|
+
".vercel",
|
|
773
|
+
"dist",
|
|
774
|
+
"build",
|
|
775
|
+
".output",
|
|
776
|
+
".nuxt",
|
|
777
|
+
"__pycache__",
|
|
778
|
+
".venv",
|
|
779
|
+
"venv",
|
|
780
|
+
".tox"
|
|
781
|
+
]);
|
|
782
|
+
SCAN_EXTENSIONS = new Set([
|
|
783
|
+
".ts",
|
|
784
|
+
".tsx",
|
|
785
|
+
".js",
|
|
786
|
+
".jsx",
|
|
787
|
+
".mjs",
|
|
788
|
+
".cjs",
|
|
789
|
+
".py",
|
|
790
|
+
".rb",
|
|
791
|
+
".go",
|
|
792
|
+
".rs",
|
|
793
|
+
".java",
|
|
794
|
+
".kt",
|
|
795
|
+
".yml",
|
|
796
|
+
".yaml",
|
|
797
|
+
".toml",
|
|
798
|
+
".json",
|
|
799
|
+
".sh",
|
|
800
|
+
".bash",
|
|
801
|
+
".vue",
|
|
802
|
+
".svelte",
|
|
803
|
+
".astro"
|
|
804
|
+
]);
|
|
805
|
+
PATTERNS = [
|
|
806
|
+
{
|
|
807
|
+
regex: /envsave\.get\(["'`]([^"'`]+)["'`]\)/g,
|
|
808
|
+
type: "envsave.get",
|
|
809
|
+
extract: (m) => m[1]
|
|
810
|
+
},
|
|
811
|
+
{
|
|
812
|
+
regex: /envsave_get\(["'`]([^"'`]+)["'`]\)/g,
|
|
813
|
+
type: "envsave.get (Python alias)",
|
|
814
|
+
extract: (m) => m[1]
|
|
815
|
+
},
|
|
816
|
+
{
|
|
817
|
+
regex: /envsave\.has\(["'`]([^"'`]+)["'`]\)/g,
|
|
818
|
+
type: "envsave.has",
|
|
819
|
+
extract: (m) => m[1]
|
|
820
|
+
},
|
|
821
|
+
{
|
|
822
|
+
regex: /process\.env\.([A-Z_][A-Z0-9_]*)/g,
|
|
823
|
+
type: "process.env",
|
|
824
|
+
extract: (m) => m[1]
|
|
825
|
+
},
|
|
826
|
+
{
|
|
827
|
+
regex: /process\.env\[["'`]([A-Z_][A-Z0-9_]*)["'`]\]/g,
|
|
828
|
+
type: "process.env",
|
|
829
|
+
extract: (m) => m[1]
|
|
830
|
+
},
|
|
831
|
+
{
|
|
832
|
+
regex: /os\.environ\[["']([A-Z_][A-Z0-9_]*)["']\]/g,
|
|
833
|
+
type: "os.environ (Python)",
|
|
834
|
+
extract: (m) => m[1]
|
|
835
|
+
},
|
|
836
|
+
{
|
|
837
|
+
regex: /os\.getenv\(["']([A-Z_][A-Z0-9_]*)["']\)/g,
|
|
838
|
+
type: "os.getenv (Python)",
|
|
839
|
+
extract: (m) => m[1]
|
|
840
|
+
},
|
|
841
|
+
{
|
|
842
|
+
regex: /os\.Getenv\(["']([A-Z_][A-Z0-9_]*)["']\)/g,
|
|
843
|
+
type: "os.Getenv (Go)",
|
|
844
|
+
extract: (m) => m[1]
|
|
845
|
+
},
|
|
846
|
+
{
|
|
847
|
+
regex: /Bun\.env\.([A-Z_][A-Z0-9_]*)/g,
|
|
848
|
+
type: "Bun.env",
|
|
849
|
+
extract: (m) => m[1]
|
|
850
|
+
},
|
|
851
|
+
{
|
|
852
|
+
regex: /import\.meta\.env\.([A-Z_][A-Z0-9_]*)/g,
|
|
853
|
+
type: "import.meta.env",
|
|
854
|
+
extract: (m) => m[1]
|
|
855
|
+
}
|
|
856
|
+
];
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
// src/cli.ts
|
|
860
|
+
init_vault();
|
|
861
|
+
init_license();
|
|
862
|
+
import { createInterface } from "node:readline";
|
|
863
|
+
import { existsSync as existsSync3, readFileSync as readFileSync5, writeFileSync as writeFileSync3, mkdirSync as mkdirSync3, readdirSync as readdirSync2, statSync as statSync3 } from "node:fs";
|
|
864
|
+
import { spawnSync } from "node:child_process";
|
|
865
|
+
import { createHash } from "node:crypto";
|
|
866
|
+
import { join as join4 } from "node:path";
|
|
867
|
+
import { homedir as homedir3 } from "node:os";
|
|
868
|
+
var args = process.argv.slice(2);
|
|
869
|
+
var command = args[0];
|
|
870
|
+
function checkEnvFiles() {
|
|
871
|
+
const cwd = process.cwd();
|
|
872
|
+
const envPatterns = [".env", ".env.local", ".env.development", ".env.production", ".env.staging", ".env.test"];
|
|
873
|
+
const found = [];
|
|
874
|
+
try {
|
|
875
|
+
const files = readdirSync2(cwd);
|
|
876
|
+
for (const f of files) {
|
|
877
|
+
if (f === ".env" || f.startsWith(".env.")) {
|
|
878
|
+
found.push(f);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
} catch {}
|
|
882
|
+
if (found.length > 0) {
|
|
883
|
+
const RED = "\x1B[31m";
|
|
884
|
+
const BOLD = "\x1B[1m";
|
|
885
|
+
const RESET = "\x1B[0m";
|
|
886
|
+
console.error(`
|
|
887
|
+
${RED}${BOLD}⚠ Warning: Found .env files in current directory:${RESET}`);
|
|
888
|
+
for (const f of found) {
|
|
889
|
+
console.error(`${RED} • ${f}${RESET}`);
|
|
890
|
+
}
|
|
891
|
+
console.error(`${RED} Consider importing them into EnvSave: envsave import <file>${RESET}`);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
function usage() {
|
|
895
|
+
console.log(`
|
|
896
|
+
EnvSave — Local Secret Vault for LLM-Safe Environments
|
|
897
|
+
|
|
898
|
+
Usage:
|
|
899
|
+
envsave activate <token> [--offline] Activate license (--offline skips server)
|
|
900
|
+
envsave init [--env <f>] [--ssh-key <f>] Initialize vault
|
|
901
|
+
envsave set <key> <value> Add/update a secret (outputs opaque key)
|
|
902
|
+
envsave get <opaque_key> Retrieve a value (no password needed)
|
|
903
|
+
envsave show <key> Show real value by key name (password required)
|
|
904
|
+
envsave reveal <key> [key2] [key3]... Show opaque key name(s) (password required)
|
|
905
|
+
envsave delete <key> Remove a secret (password required)
|
|
906
|
+
envsave search <keyword> Search keys matching keyword (no password)
|
|
907
|
+
envsave scan [dir] Scan project for env variable usage
|
|
908
|
+
envsave passwd Change master password
|
|
909
|
+
envsave config Show rate limit config
|
|
910
|
+
envsave config --max-gets <n> Set max gets per minute (password required)
|
|
911
|
+
envsave config --session-ttl <hours> Set session TTL in hours (password required)
|
|
912
|
+
envsave list List clean key names (password required)
|
|
913
|
+
envsave import <file> Import from .env file (password required)
|
|
914
|
+
envsave export Export as .env format (password required)
|
|
915
|
+
envsave backup Upload encrypted vault to server
|
|
916
|
+
envsave restore Download vault backup from server
|
|
917
|
+
envsave sync Sync vault with server (pull if cloud newer)
|
|
918
|
+
envsave vercel <key> [key2]... Push secrets to Vercel as opaque env vars
|
|
919
|
+
envsave help Show this help
|
|
920
|
+
|
|
921
|
+
Security model:
|
|
922
|
+
1. envsave set OPENAI_API_KEY sk-... → outputs: es_7a3f2b1e9c8d4f6b2a1c3d5f
|
|
923
|
+
2. envsave reveal OPENAI_API_KEY → password required to see opaque key
|
|
924
|
+
3. envsave.get("es_7a3f2b1e9c8d4f6b2a1c3d5f") → returns value (no password)
|
|
925
|
+
|
|
926
|
+
Key names are HMAC-SHA256 hashes — fully opaque, no trace of original name.
|
|
927
|
+
Session cache stores secrets only — no aliases, no seed, no enumeration.
|
|
928
|
+
Optional --ssh-key adds hardware-level "something you have" factor.
|
|
929
|
+
`);
|
|
930
|
+
}
|
|
931
|
+
async function promptPassword(prompt = "Master password: ") {
|
|
932
|
+
return new Promise((resolve) => {
|
|
933
|
+
const rl = createInterface({
|
|
934
|
+
input: process.stdin,
|
|
935
|
+
output: process.stderr,
|
|
936
|
+
terminal: false
|
|
937
|
+
});
|
|
938
|
+
process.stderr.write(prompt);
|
|
939
|
+
const stdin = process.stdin;
|
|
940
|
+
const wasRaw = stdin.isRaw;
|
|
941
|
+
if (stdin.isTTY && stdin.setRawMode) {
|
|
942
|
+
stdin.setRawMode(true);
|
|
943
|
+
}
|
|
944
|
+
let password = "";
|
|
945
|
+
const onData = (ch) => {
|
|
946
|
+
const c = ch.toString();
|
|
947
|
+
if (c === `
|
|
948
|
+
` || c === "\r" || c === "\x04") {
|
|
949
|
+
if (stdin.isTTY && stdin.setRawMode) {
|
|
950
|
+
stdin.setRawMode(wasRaw ?? false);
|
|
951
|
+
}
|
|
952
|
+
stdin.removeListener("data", onData);
|
|
953
|
+
process.stderr.write(`
|
|
954
|
+
`);
|
|
955
|
+
rl.close();
|
|
956
|
+
resolve(password);
|
|
957
|
+
} else if (c === "\x03") {
|
|
958
|
+
process.exit(1);
|
|
959
|
+
} else if (c === "" || c === "\b") {
|
|
960
|
+
password = password.slice(0, -1);
|
|
961
|
+
} else {
|
|
962
|
+
password += c;
|
|
963
|
+
}
|
|
964
|
+
};
|
|
965
|
+
stdin.on("data", onData);
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
async function requirePassword() {
|
|
969
|
+
const password = await promptPassword();
|
|
970
|
+
if (!password) {
|
|
971
|
+
console.error("Password cannot be empty.");
|
|
972
|
+
process.exit(1);
|
|
973
|
+
}
|
|
974
|
+
return password;
|
|
975
|
+
}
|
|
976
|
+
async function requireLicense() {
|
|
977
|
+
if (!await checkLicense()) {
|
|
978
|
+
console.error("No valid license found. Run 'envsave activate <token>' first.");
|
|
979
|
+
console.error("Get your license at https://api.envsave.com");
|
|
980
|
+
process.exit(1);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
function getFlag(name) {
|
|
984
|
+
const idx = args.indexOf(name);
|
|
985
|
+
return idx !== -1 ? args[idx + 1] : undefined;
|
|
986
|
+
}
|
|
987
|
+
async function main() {
|
|
988
|
+
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
989
|
+
usage();
|
|
990
|
+
checkEnvFiles();
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
switch (command) {
|
|
994
|
+
case "activate": {
|
|
995
|
+
const token = args[1];
|
|
996
|
+
if (!token) {
|
|
997
|
+
console.error("Usage: envsave activate <token> [--offline]");
|
|
998
|
+
process.exit(1);
|
|
999
|
+
}
|
|
1000
|
+
const offline = args.includes("--offline");
|
|
1001
|
+
const ok = await activateLicense(token, offline);
|
|
1002
|
+
if (ok) {
|
|
1003
|
+
console.log("License activated successfully!");
|
|
1004
|
+
} else {
|
|
1005
|
+
process.exit(1);
|
|
1006
|
+
}
|
|
1007
|
+
break;
|
|
1008
|
+
}
|
|
1009
|
+
case "init": {
|
|
1010
|
+
if (vaultExists()) {
|
|
1011
|
+
console.error("Vault already exists. Delete ~/.envsave/vault.enc to reinitialize.");
|
|
1012
|
+
process.exit(1);
|
|
1013
|
+
}
|
|
1014
|
+
const password = await promptPassword("Set master password: ");
|
|
1015
|
+
if (!password) {
|
|
1016
|
+
console.error("Password cannot be empty.");
|
|
1017
|
+
process.exit(1);
|
|
1018
|
+
}
|
|
1019
|
+
const confirm = await promptPassword("Confirm password: ");
|
|
1020
|
+
if (password !== confirm) {
|
|
1021
|
+
console.error("Passwords do not match.");
|
|
1022
|
+
process.exit(1);
|
|
1023
|
+
}
|
|
1024
|
+
const envFile = getFlag("--env");
|
|
1025
|
+
const sshKeyPath = getFlag("--ssh-key");
|
|
1026
|
+
if (envFile && !existsSync3(envFile)) {
|
|
1027
|
+
console.error(`File not found: ${envFile}`);
|
|
1028
|
+
process.exit(1);
|
|
1029
|
+
}
|
|
1030
|
+
if (sshKeyPath && !existsSync3(sshKeyPath)) {
|
|
1031
|
+
console.error(`SSH key not found: ${sshKeyPath}`);
|
|
1032
|
+
process.exit(1);
|
|
1033
|
+
}
|
|
1034
|
+
const count = initVault(password, { envFile, sshKeyPath });
|
|
1035
|
+
console.log("Vault initialized successfully!");
|
|
1036
|
+
if (sshKeyPath) {
|
|
1037
|
+
console.log(`SSH key factor enabled: ${sshKeyPath}`);
|
|
1038
|
+
}
|
|
1039
|
+
if (count > 0) {
|
|
1040
|
+
console.log(`Imported ${count} secrets.`);
|
|
1041
|
+
console.log(`
|
|
1042
|
+
Use 'envsave reveal <KEY>' to get the opaque key for each secret.`);
|
|
1043
|
+
}
|
|
1044
|
+
break;
|
|
1045
|
+
}
|
|
1046
|
+
case "set": {
|
|
1047
|
+
const key = args[1];
|
|
1048
|
+
const value = args[2];
|
|
1049
|
+
if (!key || value === undefined) {
|
|
1050
|
+
console.error("Usage: envsave set <key> <value>");
|
|
1051
|
+
process.exit(1);
|
|
1052
|
+
}
|
|
1053
|
+
const password = await requirePassword();
|
|
1054
|
+
const opaque = setSecret(key, value, password);
|
|
1055
|
+
console.log(`
|
|
1056
|
+
${key} → ${opaque}
|
|
1057
|
+
`);
|
|
1058
|
+
console.log(` Use this in your code to access ${key}:`);
|
|
1059
|
+
console.log(` envsave.get("${opaque}")
|
|
1060
|
+
`);
|
|
1061
|
+
break;
|
|
1062
|
+
}
|
|
1063
|
+
case "reveal": {
|
|
1064
|
+
const keys = args.slice(1);
|
|
1065
|
+
if (keys.length === 0) {
|
|
1066
|
+
console.error("Usage: envsave reveal <key> [key2] [key3]...");
|
|
1067
|
+
process.exit(1);
|
|
1068
|
+
}
|
|
1069
|
+
const password = await requirePassword();
|
|
1070
|
+
let notFound = 0;
|
|
1071
|
+
for (const key of keys) {
|
|
1072
|
+
const opaque = revealKey(key, password);
|
|
1073
|
+
if (opaque) {
|
|
1074
|
+
console.log(`
|
|
1075
|
+
${key} → ${opaque}`);
|
|
1076
|
+
console.log(` envsave.get("${opaque}")`);
|
|
1077
|
+
} else {
|
|
1078
|
+
console.error(`Secret '${key}' not found.`);
|
|
1079
|
+
notFound++;
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
if (keys.length > 1 && notFound === 0)
|
|
1083
|
+
console.log();
|
|
1084
|
+
if (notFound > 0 && notFound === keys.length)
|
|
1085
|
+
process.exit(1);
|
|
1086
|
+
break;
|
|
1087
|
+
}
|
|
1088
|
+
case "show": {
|
|
1089
|
+
const key = args[1];
|
|
1090
|
+
if (!key) {
|
|
1091
|
+
console.error("Usage: envsave show <key>");
|
|
1092
|
+
process.exit(1);
|
|
1093
|
+
}
|
|
1094
|
+
const password = await requirePassword();
|
|
1095
|
+
const val = showSecret(key, password);
|
|
1096
|
+
if (val !== undefined) {
|
|
1097
|
+
console.log(`
|
|
1098
|
+
${key} = ${val}
|
|
1099
|
+
`);
|
|
1100
|
+
} else {
|
|
1101
|
+
console.error(`Secret '${key}' not found.`);
|
|
1102
|
+
process.exit(1);
|
|
1103
|
+
}
|
|
1104
|
+
break;
|
|
1105
|
+
}
|
|
1106
|
+
case "get": {
|
|
1107
|
+
const key = args[1];
|
|
1108
|
+
if (!key) {
|
|
1109
|
+
console.error("Usage: envsave get <opaque_key>");
|
|
1110
|
+
process.exit(1);
|
|
1111
|
+
}
|
|
1112
|
+
try {
|
|
1113
|
+
const value = getSecretFromCache(key);
|
|
1114
|
+
if (value !== undefined) {
|
|
1115
|
+
process.stdout.write(value);
|
|
1116
|
+
break;
|
|
1117
|
+
}
|
|
1118
|
+
} catch {}
|
|
1119
|
+
const password = await requirePassword();
|
|
1120
|
+
const val = getSecret(key, password);
|
|
1121
|
+
if (val !== undefined) {
|
|
1122
|
+
process.stdout.write(val);
|
|
1123
|
+
} else {
|
|
1124
|
+
console.error(`Secret not found. Use 'envsave reveal <name>' to get the opaque key.`);
|
|
1125
|
+
process.exit(1);
|
|
1126
|
+
}
|
|
1127
|
+
break;
|
|
1128
|
+
}
|
|
1129
|
+
case "delete": {
|
|
1130
|
+
const key = args[1];
|
|
1131
|
+
if (!key) {
|
|
1132
|
+
console.error("Usage: envsave delete <key>");
|
|
1133
|
+
process.exit(1);
|
|
1134
|
+
}
|
|
1135
|
+
const password = await requirePassword();
|
|
1136
|
+
const deleted = deleteSecret(key, password);
|
|
1137
|
+
if (deleted) {
|
|
1138
|
+
console.log(`Secret '${key}' deleted.`);
|
|
1139
|
+
} else {
|
|
1140
|
+
console.error(`Secret '${key}' not found.`);
|
|
1141
|
+
process.exit(1);
|
|
1142
|
+
}
|
|
1143
|
+
break;
|
|
1144
|
+
}
|
|
1145
|
+
case "config": {
|
|
1146
|
+
const maxGets = getFlag("--max-gets");
|
|
1147
|
+
const sessionTtl = getFlag("--session-ttl");
|
|
1148
|
+
if (!maxGets && !sessionTtl) {
|
|
1149
|
+
const config2 = getRateLimitConfig();
|
|
1150
|
+
console.log(`Rate limit config:`);
|
|
1151
|
+
console.log(` Max gets per minute: ${config2.maxGetsPerMinute}`);
|
|
1152
|
+
console.log(` Session TTL: ${config2.sessionTtlHours} hours`);
|
|
1153
|
+
break;
|
|
1154
|
+
}
|
|
1155
|
+
const password = await requirePassword();
|
|
1156
|
+
const options = {};
|
|
1157
|
+
if (maxGets)
|
|
1158
|
+
options.maxGetsPerMinute = parseInt(maxGets, 10);
|
|
1159
|
+
if (sessionTtl)
|
|
1160
|
+
options.sessionTtlHours = parseFloat(sessionTtl);
|
|
1161
|
+
if (options.maxGetsPerMinute !== undefined && (isNaN(options.maxGetsPerMinute) || options.maxGetsPerMinute < 1)) {
|
|
1162
|
+
console.error("--max-gets must be a positive number.");
|
|
1163
|
+
process.exit(1);
|
|
1164
|
+
}
|
|
1165
|
+
if (options.sessionTtlHours !== undefined && (isNaN(options.sessionTtlHours) || options.sessionTtlHours <= 0)) {
|
|
1166
|
+
console.error("--session-ttl must be a positive number.");
|
|
1167
|
+
process.exit(1);
|
|
1168
|
+
}
|
|
1169
|
+
const config = configureRateLimit(password, options);
|
|
1170
|
+
console.log(`Config updated:`);
|
|
1171
|
+
console.log(` Max gets per minute: ${config.maxGetsPerMinute}`);
|
|
1172
|
+
console.log(` Session TTL: ${config.sessionTtlHours} hours`);
|
|
1173
|
+
break;
|
|
1174
|
+
}
|
|
1175
|
+
case "passwd": {
|
|
1176
|
+
const oldPassword = await promptPassword("Current password: ");
|
|
1177
|
+
if (!oldPassword) {
|
|
1178
|
+
console.error("Password cannot be empty.");
|
|
1179
|
+
process.exit(1);
|
|
1180
|
+
}
|
|
1181
|
+
try {
|
|
1182
|
+
listKeys(oldPassword);
|
|
1183
|
+
} catch {
|
|
1184
|
+
console.error("Incorrect current password.");
|
|
1185
|
+
process.exit(1);
|
|
1186
|
+
}
|
|
1187
|
+
const newPassword = await promptPassword("New password: ");
|
|
1188
|
+
if (!newPassword) {
|
|
1189
|
+
console.error("Password cannot be empty.");
|
|
1190
|
+
process.exit(1);
|
|
1191
|
+
}
|
|
1192
|
+
const confirmNew = await promptPassword("Confirm new password: ");
|
|
1193
|
+
if (newPassword !== confirmNew) {
|
|
1194
|
+
console.error("Passwords do not match.");
|
|
1195
|
+
process.exit(1);
|
|
1196
|
+
}
|
|
1197
|
+
const { changePassword: changePassword2 } = await Promise.resolve().then(() => (init_vault(), exports_vault));
|
|
1198
|
+
changePassword2(oldPassword, newPassword);
|
|
1199
|
+
console.log("Password changed successfully!");
|
|
1200
|
+
break;
|
|
1201
|
+
}
|
|
1202
|
+
case "search": {
|
|
1203
|
+
const keyword = args[1];
|
|
1204
|
+
if (!keyword) {
|
|
1205
|
+
console.error("Usage: envsave search <keyword>");
|
|
1206
|
+
process.exit(1);
|
|
1207
|
+
}
|
|
1208
|
+
let matches;
|
|
1209
|
+
try {
|
|
1210
|
+
matches = searchKeys(keyword);
|
|
1211
|
+
} catch {
|
|
1212
|
+
const password = await requirePassword();
|
|
1213
|
+
listKeys(password);
|
|
1214
|
+
matches = searchKeys(keyword);
|
|
1215
|
+
}
|
|
1216
|
+
if (matches.length === 0) {
|
|
1217
|
+
console.log(`No keys matching "${keyword}".`);
|
|
1218
|
+
} else {
|
|
1219
|
+
for (const k of matches) {
|
|
1220
|
+
console.log(k);
|
|
1221
|
+
}
|
|
1222
|
+
console.log(`
|
|
1223
|
+
${matches.length} key(s) matching "${keyword}".`);
|
|
1224
|
+
}
|
|
1225
|
+
break;
|
|
1226
|
+
}
|
|
1227
|
+
case "scan": {
|
|
1228
|
+
const scanDir = args.slice(1).find((a) => !a.startsWith("--")) || process.cwd();
|
|
1229
|
+
const { scanProject: scanProject2 } = await Promise.resolve().then(() => (init_scanner(), exports_scanner));
|
|
1230
|
+
const results = scanProject2(scanDir);
|
|
1231
|
+
if (results.length === 0) {
|
|
1232
|
+
console.log("No env variable usage found.");
|
|
1233
|
+
} else {
|
|
1234
|
+
let opaqueToName = {};
|
|
1235
|
+
const hasOpaqueKeys = results.some((r) => r.match.startsWith("es_"));
|
|
1236
|
+
if (hasOpaqueKeys && vaultExists()) {
|
|
1237
|
+
try {
|
|
1238
|
+
const password = await requirePassword();
|
|
1239
|
+
const keys = listKeys(password);
|
|
1240
|
+
for (const k of keys) {
|
|
1241
|
+
const opaque = revealKey(k, password);
|
|
1242
|
+
if (opaque)
|
|
1243
|
+
opaqueToName[opaque] = k;
|
|
1244
|
+
}
|
|
1245
|
+
} catch (err) {
|
|
1246
|
+
if (err.message?.includes("unable to authenticate data") || err.message?.includes("Unsupported state")) {
|
|
1247
|
+
console.error("Incorrect password — skipping key resolution.");
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
const grouped = {};
|
|
1252
|
+
for (const r of results) {
|
|
1253
|
+
if (!grouped[r.type])
|
|
1254
|
+
grouped[r.type] = [];
|
|
1255
|
+
grouped[r.type].push(r);
|
|
1256
|
+
}
|
|
1257
|
+
for (const [type, entries] of Object.entries(grouped)) {
|
|
1258
|
+
console.log(`
|
|
1259
|
+
\x1B[1m${type}\x1B[0m`);
|
|
1260
|
+
for (const e of entries) {
|
|
1261
|
+
const name = opaqueToName[e.match];
|
|
1262
|
+
const display = name ? `${e.match} → ${name}` : e.match;
|
|
1263
|
+
console.log(` ${e.file}:${e.line} ${display}`);
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
const uniqueVars = new Set(results.map((r) => r.match));
|
|
1267
|
+
console.log(`
|
|
1268
|
+
${results.length} reference(s), ${uniqueVars.size} unique variable(s) found.`);
|
|
1269
|
+
}
|
|
1270
|
+
break;
|
|
1271
|
+
}
|
|
1272
|
+
case "list": {
|
|
1273
|
+
const password = await requirePassword();
|
|
1274
|
+
const keys = listKeys(password);
|
|
1275
|
+
if (keys.length === 0) {
|
|
1276
|
+
console.log("No secrets stored.");
|
|
1277
|
+
} else {
|
|
1278
|
+
for (const k of keys) {
|
|
1279
|
+
console.log(k);
|
|
1280
|
+
}
|
|
1281
|
+
console.log(`
|
|
1282
|
+
${keys.length} secret(s). Use 'envsave reveal <KEY>' to get the opaque key.`);
|
|
1283
|
+
if (!hasValidLicense()) {
|
|
1284
|
+
console.log(`\x1B[33mFree tier: ${keys.length}/5 secrets used. Activate a license for unlimited.\x1B[0m`);
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
break;
|
|
1288
|
+
}
|
|
1289
|
+
case "import": {
|
|
1290
|
+
const file = args[1];
|
|
1291
|
+
if (!file) {
|
|
1292
|
+
console.error("Usage: envsave import <file>");
|
|
1293
|
+
process.exit(1);
|
|
1294
|
+
}
|
|
1295
|
+
if (!existsSync3(file)) {
|
|
1296
|
+
console.error(`File not found: ${file}`);
|
|
1297
|
+
process.exit(1);
|
|
1298
|
+
}
|
|
1299
|
+
const password = await requirePassword();
|
|
1300
|
+
const count = importEnv(file, password);
|
|
1301
|
+
console.log(`Imported ${count} secrets.`);
|
|
1302
|
+
console.log(`
|
|
1303
|
+
Use 'envsave reveal <KEY>' to get the opaque key for each secret.`);
|
|
1304
|
+
break;
|
|
1305
|
+
}
|
|
1306
|
+
case "export": {
|
|
1307
|
+
const password = await requirePassword();
|
|
1308
|
+
const output = exportEnv(password);
|
|
1309
|
+
console.log(output);
|
|
1310
|
+
break;
|
|
1311
|
+
}
|
|
1312
|
+
case "backup": {
|
|
1313
|
+
await requireLicense();
|
|
1314
|
+
const token = getLicenseToken();
|
|
1315
|
+
if (!token) {
|
|
1316
|
+
console.error("No license token found. Run 'envsave activate <token>' first.");
|
|
1317
|
+
process.exit(1);
|
|
1318
|
+
}
|
|
1319
|
+
const envsaveDir = join4(homedir3(), ".envsave");
|
|
1320
|
+
const vaultFile = join4(envsaveDir, "vault.enc");
|
|
1321
|
+
if (!existsSync3(vaultFile)) {
|
|
1322
|
+
console.error("No vault found. Run 'envsave init' first.");
|
|
1323
|
+
process.exit(1);
|
|
1324
|
+
}
|
|
1325
|
+
const vaultData = readFileSync5(vaultFile);
|
|
1326
|
+
const checksum = createHash("sha256").update(vaultData).digest("hex");
|
|
1327
|
+
const apiBase = (process.env.ENVSAVE_LICENSE_API || "https://api.envsave.com/api/license/validate").replace("/api/license/validate", "");
|
|
1328
|
+
console.log(`Uploading encrypted vault (${vaultData.byteLength} bytes)...`);
|
|
1329
|
+
const resp = await fetch(`${apiBase}/api/vault/backup`, {
|
|
1330
|
+
method: "POST",
|
|
1331
|
+
headers: {
|
|
1332
|
+
Authorization: `Bearer ${token}`,
|
|
1333
|
+
"Content-Type": "application/octet-stream"
|
|
1334
|
+
},
|
|
1335
|
+
body: vaultData
|
|
1336
|
+
});
|
|
1337
|
+
if (!resp.ok) {
|
|
1338
|
+
const err = await resp.json().catch(() => ({}));
|
|
1339
|
+
console.error(`Backup failed: ${err.error || resp.statusText}`);
|
|
1340
|
+
process.exit(1);
|
|
1341
|
+
}
|
|
1342
|
+
const result = await resp.json();
|
|
1343
|
+
if (result.checksum !== checksum) {
|
|
1344
|
+
console.error("Checksum mismatch! Backup may be corrupted.");
|
|
1345
|
+
process.exit(1);
|
|
1346
|
+
}
|
|
1347
|
+
console.log(`Vault backed up successfully!`);
|
|
1348
|
+
console.log(` Size: ${result.size} bytes`);
|
|
1349
|
+
console.log(` Checksum: ${checksum.slice(0, 16)}...`);
|
|
1350
|
+
break;
|
|
1351
|
+
}
|
|
1352
|
+
case "restore": {
|
|
1353
|
+
await requireLicense();
|
|
1354
|
+
const token = getLicenseToken();
|
|
1355
|
+
if (!token) {
|
|
1356
|
+
console.error("No license token found. Run 'envsave activate <token>' first.");
|
|
1357
|
+
process.exit(1);
|
|
1358
|
+
}
|
|
1359
|
+
const envsaveDir = join4(homedir3(), ".envsave");
|
|
1360
|
+
const vaultFile = join4(envsaveDir, "vault.enc");
|
|
1361
|
+
const apiBase = (process.env.ENVSAVE_LICENSE_API || "https://api.envsave.com/api/license/validate").replace("/api/license/validate", "");
|
|
1362
|
+
if (existsSync3(vaultFile)) {
|
|
1363
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
1364
|
+
const answer = await new Promise((resolve) => {
|
|
1365
|
+
rl.question("Local vault exists. Overwrite with backup? (y/N) ", resolve);
|
|
1366
|
+
});
|
|
1367
|
+
rl.close();
|
|
1368
|
+
if (answer.toLowerCase() !== "y") {
|
|
1369
|
+
console.log("Restore cancelled.");
|
|
1370
|
+
break;
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
console.log("Downloading vault backup...");
|
|
1374
|
+
const resp = await fetch(`${apiBase}/api/vault/download`, {
|
|
1375
|
+
method: "POST",
|
|
1376
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
1377
|
+
});
|
|
1378
|
+
if (!resp.ok) {
|
|
1379
|
+
const err = await resp.json().catch(() => ({}));
|
|
1380
|
+
console.error(`Restore failed: ${err.error || resp.statusText}`);
|
|
1381
|
+
process.exit(1);
|
|
1382
|
+
}
|
|
1383
|
+
const vaultData = Buffer.from(await resp.arrayBuffer());
|
|
1384
|
+
const serverChecksum = resp.headers.get("x-checksum") || "";
|
|
1385
|
+
const localChecksum = createHash("sha256").update(vaultData).digest("hex");
|
|
1386
|
+
if (serverChecksum && localChecksum !== serverChecksum) {
|
|
1387
|
+
console.error("Checksum mismatch! Download may be corrupted.");
|
|
1388
|
+
process.exit(1);
|
|
1389
|
+
}
|
|
1390
|
+
if (!existsSync3(envsaveDir)) {
|
|
1391
|
+
mkdirSync3(envsaveDir, { recursive: true, mode: 448 });
|
|
1392
|
+
}
|
|
1393
|
+
writeFileSync3(vaultFile, vaultData, { mode: 384 });
|
|
1394
|
+
const backupDate = resp.headers.get("x-backup-date") || "unknown";
|
|
1395
|
+
console.log(`Vault restored successfully!`);
|
|
1396
|
+
console.log(` Size: ${vaultData.byteLength} bytes`);
|
|
1397
|
+
console.log(` Backup date: ${backupDate}`);
|
|
1398
|
+
console.log(`
|
|
1399
|
+
Use 'envsave list' to verify your secrets.`);
|
|
1400
|
+
break;
|
|
1401
|
+
}
|
|
1402
|
+
case "sync": {
|
|
1403
|
+
await requireLicense();
|
|
1404
|
+
const token = getLicenseToken();
|
|
1405
|
+
if (!token) {
|
|
1406
|
+
console.error("No license token found. Run 'envsave activate <token>' first.");
|
|
1407
|
+
process.exit(1);
|
|
1408
|
+
}
|
|
1409
|
+
const envsaveDir = join4(homedir3(), ".envsave");
|
|
1410
|
+
const vaultFile = join4(envsaveDir, "vault.enc");
|
|
1411
|
+
const apiBase = (process.env.ENVSAVE_LICENSE_API || "https://api.envsave.com/api/license/validate").replace("/api/license/validate", "");
|
|
1412
|
+
const statusResp = await fetch(`${apiBase}/api/vault/status`, {
|
|
1413
|
+
method: "POST",
|
|
1414
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
1415
|
+
});
|
|
1416
|
+
if (!statusResp.ok) {
|
|
1417
|
+
console.error("Failed to check cloud status.");
|
|
1418
|
+
process.exit(1);
|
|
1419
|
+
}
|
|
1420
|
+
const cloudStatus = await statusResp.json();
|
|
1421
|
+
const hasLocal = existsSync3(vaultFile);
|
|
1422
|
+
let localChecksum = "";
|
|
1423
|
+
let localSize = 0;
|
|
1424
|
+
if (hasLocal) {
|
|
1425
|
+
const localData = readFileSync5(vaultFile);
|
|
1426
|
+
localChecksum = createHash("sha256").update(localData).digest("hex");
|
|
1427
|
+
localSize = localData.byteLength;
|
|
1428
|
+
}
|
|
1429
|
+
if (cloudStatus.hasBackup && hasLocal && cloudStatus.checksum === localChecksum) {
|
|
1430
|
+
console.log("Already in sync.");
|
|
1431
|
+
console.log(` Checksum: ${localChecksum.slice(0, 16)}...`);
|
|
1432
|
+
break;
|
|
1433
|
+
}
|
|
1434
|
+
if (!cloudStatus.hasBackup && hasLocal) {
|
|
1435
|
+
console.log("No cloud backup found. Uploading local vault...");
|
|
1436
|
+
const localData = readFileSync5(vaultFile);
|
|
1437
|
+
const resp = await fetch(`${apiBase}/api/vault/backup`, {
|
|
1438
|
+
method: "POST",
|
|
1439
|
+
headers: {
|
|
1440
|
+
Authorization: `Bearer ${token}`,
|
|
1441
|
+
"Content-Type": "application/octet-stream"
|
|
1442
|
+
},
|
|
1443
|
+
body: localData
|
|
1444
|
+
});
|
|
1445
|
+
if (!resp.ok) {
|
|
1446
|
+
console.error("Upload failed.");
|
|
1447
|
+
process.exit(1);
|
|
1448
|
+
}
|
|
1449
|
+
console.log(`Pushed local vault to cloud (${localSize} bytes).`);
|
|
1450
|
+
break;
|
|
1451
|
+
}
|
|
1452
|
+
if (cloudStatus.hasBackup && !hasLocal) {
|
|
1453
|
+
console.log("No local vault found. Downloading from cloud...");
|
|
1454
|
+
const resp = await fetch(`${apiBase}/api/vault/download`, {
|
|
1455
|
+
method: "POST",
|
|
1456
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
1457
|
+
});
|
|
1458
|
+
if (!resp.ok) {
|
|
1459
|
+
console.error("Download failed.");
|
|
1460
|
+
process.exit(1);
|
|
1461
|
+
}
|
|
1462
|
+
const vaultData = Buffer.from(await resp.arrayBuffer());
|
|
1463
|
+
if (!existsSync3(envsaveDir)) {
|
|
1464
|
+
mkdirSync3(envsaveDir, { recursive: true, mode: 448 });
|
|
1465
|
+
}
|
|
1466
|
+
writeFileSync3(vaultFile, vaultData, { mode: 384 });
|
|
1467
|
+
console.log(`Pulled vault from cloud (${vaultData.byteLength} bytes).`);
|
|
1468
|
+
break;
|
|
1469
|
+
}
|
|
1470
|
+
if (cloudStatus.hasBackup && hasLocal && cloudStatus.checksum !== localChecksum) {
|
|
1471
|
+
const localMtime = statSync3(vaultFile).mtimeMs;
|
|
1472
|
+
const cloudDate = cloudStatus.updatedAt ? new Date(cloudStatus.updatedAt).getTime() : 0;
|
|
1473
|
+
console.log("Local and cloud vaults differ:");
|
|
1474
|
+
console.log(` Local: ${localSize} bytes, modified ${new Date(localMtime).toISOString()}`);
|
|
1475
|
+
console.log(` Cloud: ${cloudStatus.size} bytes, modified ${cloudStatus.updatedAt || "unknown"}`);
|
|
1476
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
1477
|
+
const answer = await new Promise((resolve) => {
|
|
1478
|
+
rl.question("Pull from cloud (p), push local to cloud (l), or cancel (c)? [p/l/c] ", resolve);
|
|
1479
|
+
});
|
|
1480
|
+
rl.close();
|
|
1481
|
+
if (answer.toLowerCase() === "p") {
|
|
1482
|
+
const resp = await fetch(`${apiBase}/api/vault/download`, {
|
|
1483
|
+
method: "POST",
|
|
1484
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
1485
|
+
});
|
|
1486
|
+
if (!resp.ok) {
|
|
1487
|
+
console.error("Download failed.");
|
|
1488
|
+
process.exit(1);
|
|
1489
|
+
}
|
|
1490
|
+
const vaultData = Buffer.from(await resp.arrayBuffer());
|
|
1491
|
+
writeFileSync3(vaultFile, vaultData, { mode: 384 });
|
|
1492
|
+
console.log(`Pulled vault from cloud (${vaultData.byteLength} bytes).`);
|
|
1493
|
+
} else if (answer.toLowerCase() === "l") {
|
|
1494
|
+
const localData = readFileSync5(vaultFile);
|
|
1495
|
+
const resp = await fetch(`${apiBase}/api/vault/backup`, {
|
|
1496
|
+
method: "POST",
|
|
1497
|
+
headers: {
|
|
1498
|
+
Authorization: `Bearer ${token}`,
|
|
1499
|
+
"Content-Type": "application/octet-stream"
|
|
1500
|
+
},
|
|
1501
|
+
body: localData
|
|
1502
|
+
});
|
|
1503
|
+
if (!resp.ok) {
|
|
1504
|
+
console.error("Upload failed.");
|
|
1505
|
+
process.exit(1);
|
|
1506
|
+
}
|
|
1507
|
+
console.log(`Pushed local vault to cloud (${localData.byteLength} bytes).`);
|
|
1508
|
+
} else {
|
|
1509
|
+
console.log("Sync cancelled.");
|
|
1510
|
+
}
|
|
1511
|
+
break;
|
|
1512
|
+
}
|
|
1513
|
+
if (!cloudStatus.hasBackup && !hasLocal) {
|
|
1514
|
+
console.log("No vault found locally or in cloud. Run 'envsave init' first.");
|
|
1515
|
+
}
|
|
1516
|
+
break;
|
|
1517
|
+
}
|
|
1518
|
+
case "vercel": {
|
|
1519
|
+
const vercelDir = join4(process.cwd(), ".vercel");
|
|
1520
|
+
const vercelProjectJson = join4(vercelDir, "project.json");
|
|
1521
|
+
if (!existsSync3(vercelProjectJson)) {
|
|
1522
|
+
console.error("This project is not linked to Vercel.");
|
|
1523
|
+
console.error("Run 'vercel link' first to connect this directory to a Vercel project.");
|
|
1524
|
+
process.exit(1);
|
|
1525
|
+
}
|
|
1526
|
+
let projectName = "unknown";
|
|
1527
|
+
try {
|
|
1528
|
+
const proj = JSON.parse(readFileSync5(vercelProjectJson, "utf8"));
|
|
1529
|
+
projectName = proj.projectId || "unknown";
|
|
1530
|
+
} catch {}
|
|
1531
|
+
const envArg = getFlag("--env");
|
|
1532
|
+
const environments = envArg ? envArg.split(",") : ["production", "preview", "development"];
|
|
1533
|
+
const keyArgs = args.slice(1).filter((a) => !a.startsWith("--"));
|
|
1534
|
+
if (envArg) {
|
|
1535
|
+
const envIdx = keyArgs.indexOf(envArg);
|
|
1536
|
+
if (envIdx !== -1)
|
|
1537
|
+
keyArgs.splice(envIdx, 1);
|
|
1538
|
+
}
|
|
1539
|
+
if (keyArgs.length === 0) {
|
|
1540
|
+
console.error("Usage: envsave vercel <es_key> [es_key2]... [--env production,preview]");
|
|
1541
|
+
process.exit(1);
|
|
1542
|
+
}
|
|
1543
|
+
const invalidKeys = keyArgs.filter((k) => !k.startsWith("es_"));
|
|
1544
|
+
if (invalidKeys.length > 0) {
|
|
1545
|
+
console.error(`Only opaque keys (es_...) are accepted. Use 'envsave reveal <NAME>' to get the opaque key.`);
|
|
1546
|
+
console.error(`Invalid: ${invalidKeys.join(", ")}`);
|
|
1547
|
+
process.exit(1);
|
|
1548
|
+
}
|
|
1549
|
+
const password = await requirePassword();
|
|
1550
|
+
const keysToPush = keyArgs;
|
|
1551
|
+
const secrets = getDeploySecrets(keysToPush, password);
|
|
1552
|
+
const notFound = keysToPush.filter((k) => !secrets.find((s) => s.opaque === k));
|
|
1553
|
+
if (notFound.length > 0) {
|
|
1554
|
+
console.error(`Secrets not found: ${notFound.join(", ")}`);
|
|
1555
|
+
if (secrets.length === 0)
|
|
1556
|
+
process.exit(1);
|
|
1557
|
+
}
|
|
1558
|
+
console.log(`
|
|
1559
|
+
Pushing ${secrets.length} secret(s) to Vercel (${environments.join(", ")})...
|
|
1560
|
+
`);
|
|
1561
|
+
let success = 0;
|
|
1562
|
+
let failed = 0;
|
|
1563
|
+
for (const secret of secrets) {
|
|
1564
|
+
for (const env of environments) {
|
|
1565
|
+
spawnSync("vercel", ["env", "rm", secret.opaque, env, "-y"], {
|
|
1566
|
+
stdio: "ignore"
|
|
1567
|
+
});
|
|
1568
|
+
const proc = spawnSync("vercel", ["env", "add", secret.opaque, env], {
|
|
1569
|
+
input: Buffer.from(secret.value),
|
|
1570
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
1571
|
+
});
|
|
1572
|
+
if (proc.status === 0) {
|
|
1573
|
+
console.log(` \x1B[32m✓\x1B[0m ${secret.name} → ${secret.opaque} [${env}]`);
|
|
1574
|
+
success++;
|
|
1575
|
+
} else {
|
|
1576
|
+
const errMsg = proc.stderr.toString().trim();
|
|
1577
|
+
console.error(` \x1B[31m✗\x1B[0m ${secret.name} → ${secret.opaque} [${env}]: ${errMsg}`);
|
|
1578
|
+
failed++;
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
console.log(`
|
|
1583
|
+
Done: ${success} pushed, ${failed} failed.`);
|
|
1584
|
+
if (failed > 0)
|
|
1585
|
+
process.exit(1);
|
|
1586
|
+
break;
|
|
1587
|
+
}
|
|
1588
|
+
default:
|
|
1589
|
+
console.error(`Unknown command: ${command}`);
|
|
1590
|
+
usage();
|
|
1591
|
+
process.exit(1);
|
|
1592
|
+
}
|
|
1593
|
+
if (command !== "get") {
|
|
1594
|
+
checkEnvFiles();
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
main().catch((err) => {
|
|
1598
|
+
if (err.message?.includes("unable to authenticate data") || err.message?.includes("Unsupported state")) {
|
|
1599
|
+
console.error("Incorrect password.");
|
|
1600
|
+
} else {
|
|
1601
|
+
console.error("Error:", err.message);
|
|
1602
|
+
}
|
|
1603
|
+
process.exit(1);
|
|
1604
|
+
});
|