envlope 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/CHANGELOG.md +32 -0
- package/LICENSE +21 -0
- package/README.md +735 -0
- package/dist/cli.cjs +702 -0
- package/dist/cli.js +675 -0
- package/package.json +62 -0
package/dist/cli.cjs
ADDED
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
11
|
+
for (let key of __getOwnPropNames(from))
|
|
12
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
13
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
14
|
+
}
|
|
15
|
+
return to;
|
|
16
|
+
};
|
|
17
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
18
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
19
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
20
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
21
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
22
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
23
|
+
mod
|
|
24
|
+
));
|
|
25
|
+
|
|
26
|
+
// node_modules/tsup/assets/cjs_shims.js
|
|
27
|
+
var getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${__filename}`).href : document.currentScript && document.currentScript.tagName.toUpperCase() === "SCRIPT" ? document.currentScript.src : new URL("main.js", document.baseURI).href;
|
|
28
|
+
var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
|
|
29
|
+
|
|
30
|
+
// src/cli.ts
|
|
31
|
+
var import_commander = require("commander");
|
|
32
|
+
var import_node_fs2 = require("fs");
|
|
33
|
+
var import_node_path2 = require("path");
|
|
34
|
+
var import_node_url = require("url");
|
|
35
|
+
var import_update_notifier = __toESM(require("update-notifier"), 1);
|
|
36
|
+
|
|
37
|
+
// src/crypto.ts
|
|
38
|
+
var import_node_crypto = require("crypto");
|
|
39
|
+
var ALGORITHM = "aes-256-gcm";
|
|
40
|
+
var IV_LENGTH = 12;
|
|
41
|
+
var TAG_LENGTH = 16;
|
|
42
|
+
var FORMAT_VERSION = 1;
|
|
43
|
+
var DecryptionError = class extends Error {
|
|
44
|
+
constructor(message) {
|
|
45
|
+
super(message);
|
|
46
|
+
this.name = "DecryptionError";
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
function encrypt(plaintext, key) {
|
|
50
|
+
if (key.length !== 32) {
|
|
51
|
+
throw new Error(`Invalid key length: expected 32 bytes, got ${key.length}`);
|
|
52
|
+
}
|
|
53
|
+
const iv = (0, import_node_crypto.randomBytes)(IV_LENGTH);
|
|
54
|
+
const cipher = (0, import_node_crypto.createCipheriv)(ALGORITHM, key, iv);
|
|
55
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
56
|
+
const tag = cipher.getAuthTag();
|
|
57
|
+
const payload = Buffer.concat([iv, ciphertext, tag]);
|
|
58
|
+
return `envlope:${FORMAT_VERSION}:${payload.toString("base64")}`;
|
|
59
|
+
}
|
|
60
|
+
function decrypt(blob, key) {
|
|
61
|
+
if (key.length !== 32) {
|
|
62
|
+
throw new Error(`Invalid key length: expected 32 bytes, got ${key.length}`);
|
|
63
|
+
}
|
|
64
|
+
const trimmed = blob.trim();
|
|
65
|
+
const parts = trimmed.split(":");
|
|
66
|
+
if (parts.length !== 3 || parts[0] !== "envlope") {
|
|
67
|
+
throw new DecryptionError("Not a valid envlope-encrypted file.");
|
|
68
|
+
}
|
|
69
|
+
const version = Number(parts[1]);
|
|
70
|
+
if (version !== FORMAT_VERSION) {
|
|
71
|
+
throw new DecryptionError(
|
|
72
|
+
`Unsupported envlope format version ${version}. This tool supports version ${FORMAT_VERSION}.`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
const payload = Buffer.from(parts[2], "base64");
|
|
76
|
+
if (payload.length < IV_LENGTH + TAG_LENGTH) {
|
|
77
|
+
throw new DecryptionError("Encrypted payload is truncated or malformed.");
|
|
78
|
+
}
|
|
79
|
+
const iv = payload.subarray(0, IV_LENGTH);
|
|
80
|
+
const tag = payload.subarray(payload.length - TAG_LENGTH);
|
|
81
|
+
const ciphertext = payload.subarray(IV_LENGTH, payload.length - TAG_LENGTH);
|
|
82
|
+
const decipher = (0, import_node_crypto.createDecipheriv)(ALGORITHM, key, iv);
|
|
83
|
+
decipher.setAuthTag(tag);
|
|
84
|
+
try {
|
|
85
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
86
|
+
} catch {
|
|
87
|
+
throw new DecryptionError("Invalid key \u2014 decryption failed.");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// src/files.ts
|
|
92
|
+
var import_node_fs = require("fs");
|
|
93
|
+
var import_node_path = require("path");
|
|
94
|
+
var DEFAULT_ENV_FILE = ".env";
|
|
95
|
+
var GITIGNORE_FILE = ".gitignore";
|
|
96
|
+
var BASELINE_GITIGNORE_ENTRIES = [".env", ".env.local"];
|
|
97
|
+
function envPath(file = DEFAULT_ENV_FILE, cwd = process.cwd()) {
|
|
98
|
+
return (0, import_node_path.resolve)(cwd, file);
|
|
99
|
+
}
|
|
100
|
+
function encryptedPath(file = DEFAULT_ENV_FILE, cwd = process.cwd()) {
|
|
101
|
+
return (0, import_node_path.resolve)(cwd, `${file}.encrypted`);
|
|
102
|
+
}
|
|
103
|
+
function backupPath(file = DEFAULT_ENV_FILE, cwd = process.cwd()) {
|
|
104
|
+
return (0, import_node_path.resolve)(cwd, `${file}.encrypted.bak`);
|
|
105
|
+
}
|
|
106
|
+
function gitignorePath(cwd = process.cwd()) {
|
|
107
|
+
return (0, import_node_path.resolve)(cwd, GITIGNORE_FILE);
|
|
108
|
+
}
|
|
109
|
+
function readEnv(file, cwd) {
|
|
110
|
+
return (0, import_node_fs.readFileSync)(envPath(file, cwd));
|
|
111
|
+
}
|
|
112
|
+
function writeEnv(content, file, cwd) {
|
|
113
|
+
(0, import_node_fs.writeFileSync)(envPath(file, cwd), content);
|
|
114
|
+
}
|
|
115
|
+
function readEncrypted(file, cwd) {
|
|
116
|
+
return (0, import_node_fs.readFileSync)(encryptedPath(file, cwd), "utf8");
|
|
117
|
+
}
|
|
118
|
+
function writeEncrypted(blob, file, cwd) {
|
|
119
|
+
(0, import_node_fs.writeFileSync)(encryptedPath(file, cwd), blob + "\n");
|
|
120
|
+
}
|
|
121
|
+
function envExists(file, cwd) {
|
|
122
|
+
return (0, import_node_fs.existsSync)(envPath(file, cwd));
|
|
123
|
+
}
|
|
124
|
+
function encryptedExists(file, cwd) {
|
|
125
|
+
return (0, import_node_fs.existsSync)(encryptedPath(file, cwd));
|
|
126
|
+
}
|
|
127
|
+
function envMtime(file, cwd) {
|
|
128
|
+
const p = envPath(file, cwd);
|
|
129
|
+
return (0, import_node_fs.existsSync)(p) ? (0, import_node_fs.statSync)(p).mtime : null;
|
|
130
|
+
}
|
|
131
|
+
function encryptedMtime(file, cwd) {
|
|
132
|
+
const p = encryptedPath(file, cwd);
|
|
133
|
+
return (0, import_node_fs.existsSync)(p) ? (0, import_node_fs.statSync)(p).mtime : null;
|
|
134
|
+
}
|
|
135
|
+
function backupEncrypted(file, cwd) {
|
|
136
|
+
(0, import_node_fs.copyFileSync)(encryptedPath(file, cwd), backupPath(file, cwd));
|
|
137
|
+
}
|
|
138
|
+
function gitignoreEntriesFor(file) {
|
|
139
|
+
const entries = [];
|
|
140
|
+
if (file === DEFAULT_ENV_FILE) {
|
|
141
|
+
entries.push(...BASELINE_GITIGNORE_ENTRIES);
|
|
142
|
+
} else {
|
|
143
|
+
entries.push(file);
|
|
144
|
+
}
|
|
145
|
+
entries.push(`${file}.encrypted.bak`);
|
|
146
|
+
return entries;
|
|
147
|
+
}
|
|
148
|
+
function ensureGitignore(file = DEFAULT_ENV_FILE, cwd) {
|
|
149
|
+
const path = gitignorePath(cwd);
|
|
150
|
+
const existed = (0, import_node_fs.existsSync)(path);
|
|
151
|
+
const current = existed ? (0, import_node_fs.readFileSync)(path, "utf8") : "";
|
|
152
|
+
const lines = current.split(/\r?\n/).map((l) => l.trim());
|
|
153
|
+
const present = new Set(lines);
|
|
154
|
+
const desired = gitignoreEntriesFor(file);
|
|
155
|
+
const toAdd = desired.filter((entry) => !present.has(entry));
|
|
156
|
+
if (toAdd.length === 0) {
|
|
157
|
+
return { added: [], existed };
|
|
158
|
+
}
|
|
159
|
+
const prefix = current.length > 0 && !current.endsWith("\n") ? "\n" : "";
|
|
160
|
+
const addition = prefix + toAdd.join("\n") + "\n";
|
|
161
|
+
(0, import_node_fs.writeFileSync)(path, current + addition);
|
|
162
|
+
return { added: toAdd, existed };
|
|
163
|
+
}
|
|
164
|
+
function isGitignored(file = DEFAULT_ENV_FILE, cwd) {
|
|
165
|
+
const path = gitignorePath(cwd);
|
|
166
|
+
if (!(0, import_node_fs.existsSync)(path)) return false;
|
|
167
|
+
const lines = (0, import_node_fs.readFileSync)(path, "utf8").split(/\r?\n/).map((l) => l.trim());
|
|
168
|
+
return lines.includes(file);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// src/key.ts
|
|
172
|
+
var import_node_crypto2 = require("crypto");
|
|
173
|
+
|
|
174
|
+
// src/ui.ts
|
|
175
|
+
var prompts = __toESM(require("@clack/prompts"), 1);
|
|
176
|
+
var import_picocolors = __toESM(require("picocolors"), 1);
|
|
177
|
+
async function confirm2(message, defaultValue = false) {
|
|
178
|
+
const result = await prompts.confirm({
|
|
179
|
+
message,
|
|
180
|
+
initialValue: defaultValue
|
|
181
|
+
});
|
|
182
|
+
if (prompts.isCancel(result)) {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
return result;
|
|
186
|
+
}
|
|
187
|
+
async function promptKey(message = "Enter your envlope key:") {
|
|
188
|
+
const result = await prompts.password({
|
|
189
|
+
message,
|
|
190
|
+
validate: (value) => {
|
|
191
|
+
if (!value) return "Key is required.";
|
|
192
|
+
if (!value.startsWith("envlope_key_")) return "Key must start with envlope_key_";
|
|
193
|
+
return void 0;
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
if (prompts.isCancel(result)) {
|
|
197
|
+
throw new UserCancelled();
|
|
198
|
+
}
|
|
199
|
+
return result;
|
|
200
|
+
}
|
|
201
|
+
var UserCancelled = class extends Error {
|
|
202
|
+
constructor() {
|
|
203
|
+
super("Cancelled.");
|
|
204
|
+
this.name = "UserCancelled";
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
// src/key.ts
|
|
209
|
+
var KEY_PREFIX = "envlope_key_";
|
|
210
|
+
var KEY_BYTES = 32;
|
|
211
|
+
var ENV_VAR_NAME = "ENVLOPE_KEY";
|
|
212
|
+
var InvalidKeyError = class extends Error {
|
|
213
|
+
constructor(message) {
|
|
214
|
+
super(message);
|
|
215
|
+
this.name = "InvalidKeyError";
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
function generateKey() {
|
|
219
|
+
return (0, import_node_crypto2.randomBytes)(KEY_BYTES);
|
|
220
|
+
}
|
|
221
|
+
function formatKey(key) {
|
|
222
|
+
if (key.length !== KEY_BYTES) {
|
|
223
|
+
throw new Error(`Invalid key length: expected ${KEY_BYTES} bytes, got ${key.length}`);
|
|
224
|
+
}
|
|
225
|
+
return `${KEY_PREFIX}${key.toString("base64")}`;
|
|
226
|
+
}
|
|
227
|
+
function parseKey(formatted) {
|
|
228
|
+
const trimmed = formatted.trim();
|
|
229
|
+
if (!trimmed.startsWith(KEY_PREFIX)) {
|
|
230
|
+
throw new InvalidKeyError(
|
|
231
|
+
`Key must start with "${KEY_PREFIX}". Did you paste the whole key?`
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
const base64 = trimmed.slice(KEY_PREFIX.length);
|
|
235
|
+
let key;
|
|
236
|
+
try {
|
|
237
|
+
key = Buffer.from(base64, "base64");
|
|
238
|
+
} catch {
|
|
239
|
+
throw new InvalidKeyError("Key contents are not valid base64.");
|
|
240
|
+
}
|
|
241
|
+
if (key.length !== KEY_BYTES) {
|
|
242
|
+
throw new InvalidKeyError(
|
|
243
|
+
`Decoded key is ${key.length} bytes; expected ${KEY_BYTES}. Key may be corrupted.`
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
return key;
|
|
247
|
+
}
|
|
248
|
+
async function resolveKey(options, promptMessage = "Enter your envlope key:") {
|
|
249
|
+
if (options.key) {
|
|
250
|
+
return parseKey(options.key);
|
|
251
|
+
}
|
|
252
|
+
const envValue = process.env[ENV_VAR_NAME];
|
|
253
|
+
if (envValue && envValue.length > 0) {
|
|
254
|
+
return parseKey(envValue);
|
|
255
|
+
}
|
|
256
|
+
if (options.json) {
|
|
257
|
+
throw new InvalidKeyError(
|
|
258
|
+
`No key provided. In --json mode, pass --key <key> or set ${ENV_VAR_NAME} env var.`
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
if (!process.stdin.isTTY) {
|
|
262
|
+
throw new InvalidKeyError(
|
|
263
|
+
`No key provided. Pass --key <key>, set ${ENV_VAR_NAME} env var, or run interactively.`
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
const formatted = await promptKey(promptMessage);
|
|
267
|
+
return parseKey(formatted);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// src/output.ts
|
|
271
|
+
var import_picocolors2 = __toESM(require("picocolors"), 1);
|
|
272
|
+
var Output = class {
|
|
273
|
+
constructor(json = false) {
|
|
274
|
+
this.json = json;
|
|
275
|
+
}
|
|
276
|
+
json;
|
|
277
|
+
get isHuman() {
|
|
278
|
+
return !this.json;
|
|
279
|
+
}
|
|
280
|
+
success(message) {
|
|
281
|
+
if (this.json) return;
|
|
282
|
+
console.log(`${import_picocolors2.default.green("\u2713")} ${message}`);
|
|
283
|
+
}
|
|
284
|
+
info(message) {
|
|
285
|
+
if (this.json) return;
|
|
286
|
+
console.log(`${import_picocolors2.default.cyan("\u203A")} ${message}`);
|
|
287
|
+
}
|
|
288
|
+
warn(message) {
|
|
289
|
+
if (this.json) return;
|
|
290
|
+
console.log(`${import_picocolors2.default.yellow("\u26A0")} ${message}`);
|
|
291
|
+
}
|
|
292
|
+
/** Render the prominent "SAVE THIS KEY" block. Human mode only. */
|
|
293
|
+
keyBlock(formattedKey) {
|
|
294
|
+
if (this.json) return;
|
|
295
|
+
const bar = import_picocolors2.default.dim("\u2500".repeat(64));
|
|
296
|
+
console.log("");
|
|
297
|
+
console.log(import_picocolors2.default.bold(import_picocolors2.default.yellow("SAVE THIS KEY \u2014 it cannot be recovered:")));
|
|
298
|
+
console.log(bar);
|
|
299
|
+
console.log(` ${import_picocolors2.default.bold(formattedKey)}`);
|
|
300
|
+
console.log(bar);
|
|
301
|
+
console.log(
|
|
302
|
+
import_picocolors2.default.dim(
|
|
303
|
+
"Save it in your password manager and share with teammates over a secure channel."
|
|
304
|
+
)
|
|
305
|
+
);
|
|
306
|
+
console.log("");
|
|
307
|
+
}
|
|
308
|
+
/** Render a "Next steps" section. Human mode only. */
|
|
309
|
+
nextSteps(steps) {
|
|
310
|
+
if (this.json) return;
|
|
311
|
+
console.log("");
|
|
312
|
+
console.log(import_picocolors2.default.bold("Next:"));
|
|
313
|
+
for (const step of steps) {
|
|
314
|
+
console.log(` ${import_picocolors2.default.dim("$")} ${step}`);
|
|
315
|
+
}
|
|
316
|
+
console.log("");
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Emit the final structured result. In JSON mode, prints one JSON object to
|
|
320
|
+
* stdout. In human mode, this is a no-op (success/info etc. handled output).
|
|
321
|
+
*/
|
|
322
|
+
emit(data) {
|
|
323
|
+
if (!this.json) return;
|
|
324
|
+
console.log(JSON.stringify(data));
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Emit a failure. In JSON mode, writes a structured `{error, code}` JSON
|
|
328
|
+
* object to stdout. In human mode, writes a red ✗ line to stderr.
|
|
329
|
+
*/
|
|
330
|
+
fail(message, code = 1) {
|
|
331
|
+
if (this.json) {
|
|
332
|
+
console.log(JSON.stringify({ error: message, code }));
|
|
333
|
+
} else {
|
|
334
|
+
console.error(`${import_picocolors2.default.red("\u2717")} ${message}`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
/** Print a raw line (used by `view` to print the variable value). */
|
|
338
|
+
raw(line) {
|
|
339
|
+
console.log(line);
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
// src/commands/decrypt.ts
|
|
344
|
+
async function decryptCommand(options = {}) {
|
|
345
|
+
const file = options.file ?? DEFAULT_ENV_FILE;
|
|
346
|
+
const out = new Output(options.json);
|
|
347
|
+
if (!encryptedExists(file)) {
|
|
348
|
+
out.fail(`No ${file}.encrypted file found in this directory.`);
|
|
349
|
+
out.info("Nothing to decrypt. Did you run `git pull` in the right directory?");
|
|
350
|
+
return 1;
|
|
351
|
+
}
|
|
352
|
+
let key;
|
|
353
|
+
try {
|
|
354
|
+
key = await resolveKey(options);
|
|
355
|
+
} catch (err) {
|
|
356
|
+
if (err instanceof InvalidKeyError) {
|
|
357
|
+
out.fail(err.message);
|
|
358
|
+
return 1;
|
|
359
|
+
}
|
|
360
|
+
if (err instanceof UserCancelled) return 130;
|
|
361
|
+
throw err;
|
|
362
|
+
}
|
|
363
|
+
const blob = readEncrypted(file);
|
|
364
|
+
let plaintext;
|
|
365
|
+
try {
|
|
366
|
+
plaintext = decrypt(blob, key);
|
|
367
|
+
} catch (err) {
|
|
368
|
+
if (err instanceof DecryptionError) {
|
|
369
|
+
out.fail(err.message);
|
|
370
|
+
return 1;
|
|
371
|
+
}
|
|
372
|
+
throw err;
|
|
373
|
+
}
|
|
374
|
+
if (envExists(file) && !options.yes) {
|
|
375
|
+
if (options.json) {
|
|
376
|
+
out.fail(`${file} already exists. Pass --yes to confirm overwrite in --json mode.`);
|
|
377
|
+
return 1;
|
|
378
|
+
}
|
|
379
|
+
const overwrite = await confirm2(
|
|
380
|
+
`${file} already exists. Overwrite it with the decrypted contents?`,
|
|
381
|
+
false
|
|
382
|
+
);
|
|
383
|
+
if (!overwrite) {
|
|
384
|
+
out.info(`Aborted. ${file} left untouched.`);
|
|
385
|
+
return 0;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
writeEnv(plaintext, file);
|
|
389
|
+
ensureGitignore(file);
|
|
390
|
+
out.success(`Decrypted to ${file}`);
|
|
391
|
+
out.emit({ success: true, output_file: file });
|
|
392
|
+
return 0;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// src/commands/encrypt.ts
|
|
396
|
+
async function encryptCommand(options = {}) {
|
|
397
|
+
const file = options.file ?? DEFAULT_ENV_FILE;
|
|
398
|
+
const out = new Output(options.json);
|
|
399
|
+
if (!envExists(file)) {
|
|
400
|
+
out.fail(`No ${file} file found in this directory.`);
|
|
401
|
+
out.info(`Nothing to encrypt. Create a ${file} file first.`);
|
|
402
|
+
return 1;
|
|
403
|
+
}
|
|
404
|
+
if (!encryptedExists(file)) {
|
|
405
|
+
out.fail(`No ${file}.encrypted file found in this directory.`);
|
|
406
|
+
out.info("Run `envlope init` first to create one.");
|
|
407
|
+
return 1;
|
|
408
|
+
}
|
|
409
|
+
let key;
|
|
410
|
+
try {
|
|
411
|
+
key = await resolveKey(options, `Enter your envlope key to re-encrypt ${file}:`);
|
|
412
|
+
} catch (err) {
|
|
413
|
+
if (err instanceof InvalidKeyError) {
|
|
414
|
+
out.fail(err.message);
|
|
415
|
+
return 1;
|
|
416
|
+
}
|
|
417
|
+
if (err instanceof UserCancelled) return 130;
|
|
418
|
+
throw err;
|
|
419
|
+
}
|
|
420
|
+
const existingBlob = readEncrypted(file);
|
|
421
|
+
try {
|
|
422
|
+
decrypt(existingBlob, key);
|
|
423
|
+
} catch (err) {
|
|
424
|
+
if (err instanceof DecryptionError) {
|
|
425
|
+
out.fail(`This key does not match the current ${file}.encrypted.`);
|
|
426
|
+
out.info(
|
|
427
|
+
"Either the key is wrong, or someone rotated the key with `envlope init`. Ask a teammate for the current key."
|
|
428
|
+
);
|
|
429
|
+
return 1;
|
|
430
|
+
}
|
|
431
|
+
throw err;
|
|
432
|
+
}
|
|
433
|
+
const plaintext = readEnv(file);
|
|
434
|
+
const blob = encrypt(plaintext, key);
|
|
435
|
+
writeEncrypted(blob, file);
|
|
436
|
+
out.success(`Updated ${file}.encrypted \u2014 ready to commit.`);
|
|
437
|
+
out.emit({ success: true, encrypted_file: `${file}.encrypted` });
|
|
438
|
+
return 0;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// src/commands/init.ts
|
|
442
|
+
async function initCommand(options = {}) {
|
|
443
|
+
const file = options.file ?? DEFAULT_ENV_FILE;
|
|
444
|
+
const out = new Output(options.json);
|
|
445
|
+
if (!envExists(file)) {
|
|
446
|
+
out.fail(`No ${file} file found in this directory.`);
|
|
447
|
+
out.info(`Create a plaintext ${file} first, then run \`envlope init\` again.`);
|
|
448
|
+
return 1;
|
|
449
|
+
}
|
|
450
|
+
if (encryptedExists(file)) {
|
|
451
|
+
out.warn(`${file}.encrypted already exists.`);
|
|
452
|
+
out.info(
|
|
453
|
+
"Generating a new key will replace it \u2014 any teammates still using the old key will lose access until you share the new key with them."
|
|
454
|
+
);
|
|
455
|
+
if (options.yes) {
|
|
456
|
+
out.info("--yes provided; proceeding with re-encryption.");
|
|
457
|
+
} else if (options.json) {
|
|
458
|
+
out.fail(
|
|
459
|
+
`${file}.encrypted already exists. Pass --yes to confirm re-encryption in --json mode.`
|
|
460
|
+
);
|
|
461
|
+
return 1;
|
|
462
|
+
} else {
|
|
463
|
+
const proceed = await confirm2("Generate a new key and re-encrypt?", false);
|
|
464
|
+
if (!proceed) {
|
|
465
|
+
out.info("Aborted. No changes made.");
|
|
466
|
+
return 0;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
backupEncrypted(file);
|
|
470
|
+
out.success(`Backed up old ${file}.encrypted \u2192 ${file}.encrypted.bak`);
|
|
471
|
+
}
|
|
472
|
+
let key;
|
|
473
|
+
if (options.key) {
|
|
474
|
+
try {
|
|
475
|
+
key = parseKey(options.key);
|
|
476
|
+
} catch (err) {
|
|
477
|
+
if (err instanceof InvalidKeyError) {
|
|
478
|
+
out.fail(err.message);
|
|
479
|
+
return 1;
|
|
480
|
+
}
|
|
481
|
+
throw err;
|
|
482
|
+
}
|
|
483
|
+
} else {
|
|
484
|
+
key = generateKey();
|
|
485
|
+
}
|
|
486
|
+
const plaintext = readEnv(file);
|
|
487
|
+
const blob = encrypt(plaintext, key);
|
|
488
|
+
writeEncrypted(blob, file);
|
|
489
|
+
const gitignoreResult = ensureGitignore(file);
|
|
490
|
+
out.success(`Encrypted ${file} \u2192 ${file}.encrypted`);
|
|
491
|
+
if (gitignoreResult.added.length > 0) {
|
|
492
|
+
const verb = gitignoreResult.existed ? "Updated" : "Created";
|
|
493
|
+
out.success(`${verb} .gitignore (added ${gitignoreResult.added.join(", ")})`);
|
|
494
|
+
} else {
|
|
495
|
+
out.info(`.gitignore already protects ${file} \u2014 no changes.`);
|
|
496
|
+
}
|
|
497
|
+
const formattedKey = formatKey(key);
|
|
498
|
+
if (options.key) {
|
|
499
|
+
out.success(
|
|
500
|
+
"Used the provided key \u2014 encrypted file is now unlockable with the same key as your other repos."
|
|
501
|
+
);
|
|
502
|
+
} else {
|
|
503
|
+
out.keyBlock(formattedKey);
|
|
504
|
+
}
|
|
505
|
+
out.nextSteps([`git add ${file}.encrypted .gitignore`, 'git commit -m "Add encrypted env"']);
|
|
506
|
+
out.emit({
|
|
507
|
+
success: true,
|
|
508
|
+
file,
|
|
509
|
+
encrypted_file: `${file}.encrypted`,
|
|
510
|
+
...options.key ? {} : { key: formattedKey }
|
|
511
|
+
});
|
|
512
|
+
return 0;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// src/commands/status.ts
|
|
516
|
+
function humanizeAge(date) {
|
|
517
|
+
const seconds = Math.max(0, Math.floor((Date.now() - date.getTime()) / 1e3));
|
|
518
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
519
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
|
520
|
+
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
|
521
|
+
return `${Math.floor(seconds / 86400)}d ago`;
|
|
522
|
+
}
|
|
523
|
+
async function statusCommand(options = {}) {
|
|
524
|
+
const file = options.file ?? DEFAULT_ENV_FILE;
|
|
525
|
+
const out = new Output(options.json);
|
|
526
|
+
const envEx = envExists(file);
|
|
527
|
+
const encEx = encryptedExists(file);
|
|
528
|
+
const envT = envMtime(file);
|
|
529
|
+
const encT = encryptedMtime(file);
|
|
530
|
+
const gitignored = isGitignored(file);
|
|
531
|
+
let inSync = null;
|
|
532
|
+
if (envEx && encEx && envT && encT) {
|
|
533
|
+
inSync = envT.getTime() <= encT.getTime() + 1e3;
|
|
534
|
+
}
|
|
535
|
+
if (out.isHuman) {
|
|
536
|
+
console.log(`File: ${file}`);
|
|
537
|
+
if (envEx) {
|
|
538
|
+
out.success(`${file} exists`);
|
|
539
|
+
} else {
|
|
540
|
+
out.warn(`${file} does not exist`);
|
|
541
|
+
}
|
|
542
|
+
if (encEx && encT) {
|
|
543
|
+
out.success(`${file}.encrypted exists (last encrypted ${humanizeAge(encT)})`);
|
|
544
|
+
} else {
|
|
545
|
+
out.warn(`${file}.encrypted does not exist`);
|
|
546
|
+
}
|
|
547
|
+
if (envEx && encEx) {
|
|
548
|
+
if (inSync === false) {
|
|
549
|
+
out.warn(
|
|
550
|
+
`${file} was modified after ${file}.encrypted \u2014 run \`envlope encrypt\` before committing.`
|
|
551
|
+
);
|
|
552
|
+
} else if (inSync === true) {
|
|
553
|
+
out.success(`${file} and ${file}.encrypted are in sync`);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
if (gitignored) {
|
|
557
|
+
out.success(`.gitignore protects ${file}`);
|
|
558
|
+
} else {
|
|
559
|
+
out.warn(
|
|
560
|
+
`${file} is not in .gitignore \u2014 run \`envlope init\` or add it manually before committing.`
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
out.emit({
|
|
565
|
+
file,
|
|
566
|
+
env_exists: envEx,
|
|
567
|
+
encrypted_exists: encEx,
|
|
568
|
+
in_sync: inSync,
|
|
569
|
+
last_encrypted: encT ? encT.toISOString() : null,
|
|
570
|
+
gitignored
|
|
571
|
+
});
|
|
572
|
+
if (options.strict && inSync === false) return 1;
|
|
573
|
+
return 0;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// src/commands/view.ts
|
|
577
|
+
async function viewCommand(variable, options = {}) {
|
|
578
|
+
const file = options.file ?? DEFAULT_ENV_FILE;
|
|
579
|
+
const out = new Output(options.json);
|
|
580
|
+
if (!encryptedExists(file)) {
|
|
581
|
+
out.fail(`No ${file}.encrypted file found in this directory.`);
|
|
582
|
+
return 1;
|
|
583
|
+
}
|
|
584
|
+
let key;
|
|
585
|
+
try {
|
|
586
|
+
key = await resolveKey(options);
|
|
587
|
+
} catch (err) {
|
|
588
|
+
if (err instanceof InvalidKeyError) {
|
|
589
|
+
out.fail(err.message);
|
|
590
|
+
return 1;
|
|
591
|
+
}
|
|
592
|
+
if (err instanceof UserCancelled) return 130;
|
|
593
|
+
throw err;
|
|
594
|
+
}
|
|
595
|
+
const blob = readEncrypted(file);
|
|
596
|
+
let plaintext;
|
|
597
|
+
try {
|
|
598
|
+
plaintext = decrypt(blob, key);
|
|
599
|
+
} catch (err) {
|
|
600
|
+
if (err instanceof DecryptionError) {
|
|
601
|
+
out.fail(err.message);
|
|
602
|
+
return 1;
|
|
603
|
+
}
|
|
604
|
+
throw err;
|
|
605
|
+
}
|
|
606
|
+
const lines = plaintext.toString("utf8").split(/\r?\n/);
|
|
607
|
+
for (const line of lines) {
|
|
608
|
+
if (!line || line.trimStart().startsWith("#")) continue;
|
|
609
|
+
const eqIdx = line.indexOf("=");
|
|
610
|
+
if (eqIdx === -1) continue;
|
|
611
|
+
const name = line.slice(0, eqIdx).trim();
|
|
612
|
+
if (name !== variable) continue;
|
|
613
|
+
const value = line.slice(eqIdx + 1);
|
|
614
|
+
if (options.json) {
|
|
615
|
+
out.emit({ variable, value });
|
|
616
|
+
} else {
|
|
617
|
+
out.raw(value);
|
|
618
|
+
}
|
|
619
|
+
return 0;
|
|
620
|
+
}
|
|
621
|
+
out.fail(`Variable '${variable}' not found in ${file}.encrypted.`);
|
|
622
|
+
return 1;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// src/cli.ts
|
|
626
|
+
var __dirname = (0, import_node_path2.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl));
|
|
627
|
+
var pkg = JSON.parse((0, import_node_fs2.readFileSync)((0, import_node_path2.resolve)(__dirname, "../package.json"), "utf8"));
|
|
628
|
+
var isJsonMode = process.argv.includes("--json");
|
|
629
|
+
if (!isJsonMode) {
|
|
630
|
+
try {
|
|
631
|
+
const notifier = (0, import_update_notifier.default)({
|
|
632
|
+
pkg,
|
|
633
|
+
updateCheckInterval: 1e3 * 60 * 60 * 24
|
|
634
|
+
// 24h
|
|
635
|
+
});
|
|
636
|
+
notifier.notify({
|
|
637
|
+
defer: false,
|
|
638
|
+
message: "Update available: {currentVersion} \u2192 {latestVersion}\nRun `npm i envlope@latest` (add `-g` if installed globally) to update."
|
|
639
|
+
});
|
|
640
|
+
} catch {
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
var program = new import_commander.Command();
|
|
644
|
+
program.name("envlope").description(
|
|
645
|
+
"Encrypt your .env file with a key, push it to git safely, unlock with the same key."
|
|
646
|
+
).version(pkg.version);
|
|
647
|
+
program.command("init [file]").description("Generate a key (or use a provided one) and encrypt the env file in this directory.").option("-y, --yes", "Skip the re-init confirmation prompt (use in scripts/CI)").option(
|
|
648
|
+
"-k, --key <key>",
|
|
649
|
+
"Use this key instead of generating a new random one (for reusing one key across multiple repos)"
|
|
650
|
+
).option("--json", "Emit JSON output instead of human-readable text").action(async (file, options) => {
|
|
651
|
+
process.exit(
|
|
652
|
+
await runWithErrorHandling(() => initCommand({ ...options, file }), options)
|
|
653
|
+
);
|
|
654
|
+
});
|
|
655
|
+
program.command("encrypt [file]").description("Re-encrypt the env file using your existing key.").option(
|
|
656
|
+
"-k, --key <key>",
|
|
657
|
+
"The envlope key (otherwise read from ENVLOPE_KEY env var or prompted)"
|
|
658
|
+
).option("--json", "Emit JSON output instead of human-readable text").action(async (file, options) => {
|
|
659
|
+
process.exit(
|
|
660
|
+
await runWithErrorHandling(() => encryptCommand({ ...options, file }), options)
|
|
661
|
+
);
|
|
662
|
+
});
|
|
663
|
+
program.command("decrypt [file]").description("Decrypt the encrypted env file using your key.").option(
|
|
664
|
+
"-k, --key <key>",
|
|
665
|
+
"The envlope key (otherwise read from ENVLOPE_KEY env var or prompted)"
|
|
666
|
+
).option("-y, --yes", "Overwrite an existing decrypted file without prompting").option("--json", "Emit JSON output instead of human-readable text").action(async (file, options) => {
|
|
667
|
+
process.exit(
|
|
668
|
+
await runWithErrorHandling(() => decryptCommand({ ...options, file }), options)
|
|
669
|
+
);
|
|
670
|
+
});
|
|
671
|
+
program.command("status [file]").description("Show the health of the env file: sync state, gitignore, last encrypted.").option("--json", "Emit JSON output instead of human-readable text").option("--strict", "Exit code 1 if the env file is out of sync with the encrypted file").action(async (file, options) => {
|
|
672
|
+
process.exit(
|
|
673
|
+
await runWithErrorHandling(() => statusCommand({ ...options, file }), options)
|
|
674
|
+
);
|
|
675
|
+
});
|
|
676
|
+
program.command("view <variable> [file]").description(
|
|
677
|
+
"Print the decrypted value of a single variable to stdout without writing the env file to disk."
|
|
678
|
+
).option(
|
|
679
|
+
"-k, --key <key>",
|
|
680
|
+
"The envlope key (otherwise read from ENVLOPE_KEY env var or prompted)"
|
|
681
|
+
).option("--json", "Emit JSON output instead of human-readable text").action(async (variable, file, options) => {
|
|
682
|
+
process.exit(
|
|
683
|
+
await runWithErrorHandling(
|
|
684
|
+
() => viewCommand(variable, { ...options, file }),
|
|
685
|
+
options
|
|
686
|
+
)
|
|
687
|
+
);
|
|
688
|
+
});
|
|
689
|
+
program.parseAsync().catch((err) => {
|
|
690
|
+
const out = new Output(isJsonMode);
|
|
691
|
+
out.fail(err instanceof Error ? err.message : String(err));
|
|
692
|
+
process.exit(1);
|
|
693
|
+
});
|
|
694
|
+
async function runWithErrorHandling(fn, options = {}) {
|
|
695
|
+
try {
|
|
696
|
+
return await fn();
|
|
697
|
+
} catch (err) {
|
|
698
|
+
const out = new Output(options.json);
|
|
699
|
+
out.fail(err instanceof Error ? err.message : String(err));
|
|
700
|
+
return 1;
|
|
701
|
+
}
|
|
702
|
+
}
|