@griv/env2 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
package/dist/index.js ADDED
@@ -0,0 +1,521 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/commands/config.ts
7
+ import * as p from "@clack/prompts";
8
+
9
+ // src/lib/config.ts
10
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
11
+ import { homedir } from "os";
12
+ import { join } from "path";
13
+ var CONFIG_DIR = join(homedir(), ".env2");
14
+ var CONFIG_FILE = join(CONFIG_DIR, "config.json");
15
+ var DEFAULT_HOST = "https://env2-worker.charoing-lucas.workers.dev";
16
+ function getConfig() {
17
+ if (!existsSync(CONFIG_FILE)) {
18
+ return { host: DEFAULT_HOST };
19
+ }
20
+ const raw = readFileSync(CONFIG_FILE, "utf-8");
21
+ return JSON.parse(raw);
22
+ }
23
+ function resetConfig() {
24
+ ensureDir();
25
+ writeFileSync(CONFIG_FILE, JSON.stringify({ host: DEFAULT_HOST }, null, 2) + "\n");
26
+ }
27
+ function setConfig(key, value) {
28
+ ensureDir();
29
+ const config2 = getConfig();
30
+ config2[key] = value;
31
+ writeFileSync(CONFIG_FILE, JSON.stringify(config2, null, 2) + "\n");
32
+ }
33
+ function ensureDir() {
34
+ if (!existsSync(CONFIG_DIR)) {
35
+ mkdirSync(CONFIG_DIR, { recursive: true });
36
+ }
37
+ }
38
+
39
+ // src/commands/config.ts
40
+ function configGet(key) {
41
+ const config2 = getConfig();
42
+ if (key in config2) {
43
+ console.log(config2[key]);
44
+ } else {
45
+ p.log.error(`Unknown config key: ${key}`);
46
+ process.exit(1);
47
+ }
48
+ }
49
+ function configReset() {
50
+ resetConfig();
51
+ p.log.success("Config reset to defaults.");
52
+ }
53
+ function configSet(key, value) {
54
+ if (key !== "host") {
55
+ p.log.error(`Unknown config key: ${key}`);
56
+ process.exit(1);
57
+ }
58
+ setConfig(key, value);
59
+ p.log.success(`Set ${key} = ${value}`);
60
+ }
61
+
62
+ // src/commands/receive.ts
63
+ import * as p2 from "@clack/prompts";
64
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
65
+ import { dirname, join as join2, resolve } from "path";
66
+
67
+ // src/lib/crypto.ts
68
+ import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
69
+ var ALGORITHM = "aes-256-gcm";
70
+ var IV_LENGTH = 12;
71
+ var TAG_LENGTH = 16;
72
+ function decryptManifest(ciphertext, keyBase64url) {
73
+ const key = Buffer.from(keyBase64url, "base64url");
74
+ const data = Buffer.from(ciphertext, "base64");
75
+ const iv = data.subarray(0, IV_LENGTH);
76
+ const tag = data.subarray(data.length - TAG_LENGTH);
77
+ const encrypted = data.subarray(IV_LENGTH, data.length - TAG_LENGTH);
78
+ const decipher = createDecipheriv(ALGORITHM, key, iv);
79
+ decipher.setAuthTag(tag);
80
+ const decrypted = Buffer.concat([
81
+ decipher.update(encrypted),
82
+ decipher.final()
83
+ ]);
84
+ return decrypted.toString("utf8");
85
+ }
86
+ function encryptManifest(plaintext) {
87
+ const key = randomBytes(32);
88
+ const iv = randomBytes(IV_LENGTH);
89
+ const cipher = createCipheriv(ALGORITHM, key, iv);
90
+ const encrypted = Buffer.concat([
91
+ cipher.update(plaintext, "utf8"),
92
+ cipher.final()
93
+ ]);
94
+ const tag = cipher.getAuthTag();
95
+ const blob = Buffer.concat([iv, encrypted, tag]);
96
+ return {
97
+ ciphertext: blob.toString("base64"),
98
+ key: key.toString("base64url")
99
+ };
100
+ }
101
+
102
+ // src/lib/env-parser.ts
103
+ function mergeEnvContent(existingContent, selectedGroups) {
104
+ const existingLines = existingContent.split("\n");
105
+ const selectedVars = /* @__PURE__ */ new Map();
106
+ for (const group of selectedGroups) {
107
+ for (const v of group.vars) {
108
+ selectedVars.set(v.key, { group, value: v.value });
109
+ }
110
+ }
111
+ const found = /* @__PURE__ */ new Set();
112
+ const updatedLines = [];
113
+ for (const line of existingLines) {
114
+ const trimmed = line.trim();
115
+ if (trimmed && !trimmed.startsWith("#")) {
116
+ const eqIndex = trimmed.indexOf("=");
117
+ if (eqIndex > 0) {
118
+ const key = trimmed.slice(0, eqIndex);
119
+ const selected = selectedVars.get(key);
120
+ if (selected) {
121
+ updatedLines.push(`${key}=${selected.value}`);
122
+ found.add(key);
123
+ continue;
124
+ }
125
+ }
126
+ }
127
+ updatedLines.push(line);
128
+ }
129
+ const newGroups = [];
130
+ for (const group of selectedGroups) {
131
+ const newVars = group.vars.filter((v) => !found.has(v.key));
132
+ if (newVars.length > 0) {
133
+ newGroups.push({ comment: group.comment, vars: newVars });
134
+ }
135
+ }
136
+ if (newGroups.length > 0) {
137
+ const lastLine = updatedLines[updatedLines.length - 1]?.trim();
138
+ if (lastLine !== "") {
139
+ updatedLines.push("");
140
+ }
141
+ updatedLines.push(serializeEnvGroups(newGroups).trimEnd());
142
+ }
143
+ return updatedLines.join("\n").trimEnd() + "\n";
144
+ }
145
+ function parseEnvContent(content) {
146
+ const lines = content.split("\n");
147
+ const groups = [];
148
+ let currentComment = [];
149
+ let currentVars = [];
150
+ for (const line of lines) {
151
+ const trimmed = line.trim();
152
+ if (!trimmed) {
153
+ if (currentVars.length > 0) {
154
+ groups.push({
155
+ comment: currentComment.length > 0 ? currentComment.join("\n") : void 0,
156
+ vars: currentVars
157
+ });
158
+ currentComment = [];
159
+ currentVars = [];
160
+ }
161
+ continue;
162
+ }
163
+ if (trimmed.startsWith("#")) {
164
+ if (currentVars.length > 0) {
165
+ groups.push({
166
+ comment: currentComment.length > 0 ? currentComment.join("\n") : void 0,
167
+ vars: currentVars
168
+ });
169
+ currentComment = [];
170
+ currentVars = [];
171
+ }
172
+ currentComment.push(line);
173
+ continue;
174
+ }
175
+ const eqIndex = trimmed.indexOf("=");
176
+ if (eqIndex > 0) {
177
+ const key = trimmed.slice(0, eqIndex);
178
+ const value = trimmed.slice(eqIndex + 1);
179
+ currentVars.push({ key, value });
180
+ }
181
+ }
182
+ if (currentVars.length > 0 || currentComment.length > 0) {
183
+ groups.push({
184
+ comment: currentComment.length > 0 ? currentComment.join("\n") : void 0,
185
+ vars: currentVars
186
+ });
187
+ }
188
+ return groups;
189
+ }
190
+ function serializeEnvGroups(groups) {
191
+ const parts = [];
192
+ for (const group of groups) {
193
+ if (group.vars.length === 0) continue;
194
+ if (group.comment) {
195
+ parts.push(group.comment);
196
+ }
197
+ for (const v of group.vars) {
198
+ parts.push(`${v.key}=${v.value}`);
199
+ }
200
+ parts.push("");
201
+ }
202
+ return parts.join("\n").trimEnd() + "\n";
203
+ }
204
+
205
+ // src/commands/receive.ts
206
+ async function receive(url, options) {
207
+ const root = resolve(options.root || ".");
208
+ p2.intro("env2 receive");
209
+ const parsed = new URL(url);
210
+ const key = parsed.hash.slice(1);
211
+ parsed.hash = "";
212
+ const fetchUrl = parsed.toString();
213
+ if (!key) {
214
+ p2.log.error("Invalid URL \u2014 missing encryption key (fragment).");
215
+ process.exit(1);
216
+ }
217
+ const s = p2.spinner();
218
+ s.start("Fetching...");
219
+ const res = await fetch(fetchUrl);
220
+ if (!res.ok) {
221
+ s.stop("Failed.");
222
+ if (res.status === 404) {
223
+ p2.log.error("Share not found or expired.");
224
+ } else {
225
+ p2.log.error(`Server error: ${res.status}`);
226
+ }
227
+ process.exit(1);
228
+ }
229
+ const { ciphertext } = await res.json();
230
+ let manifest;
231
+ try {
232
+ const plaintext = decryptManifest(ciphertext, key);
233
+ manifest = JSON.parse(plaintext);
234
+ } catch {
235
+ s.stop("Failed.");
236
+ p2.log.error("Decryption failed \u2014 invalid key or corrupted data.");
237
+ process.exit(1);
238
+ }
239
+ s.stop(`Decrypted ${manifest.files.length} file(s).`);
240
+ const fileSelections = [];
241
+ for (const file of manifest.files) {
242
+ const groups = parseEnvContent(file.content);
243
+ const allKeys = groups.flatMap((g) => g.vars.map((v) => v.key));
244
+ let selectedKeys;
245
+ if (options.noInteractive || options.stdout) {
246
+ selectedKeys = new Set(allKeys);
247
+ } else {
248
+ const selectOptions = [];
249
+ for (const group of groups) {
250
+ for (const v of group.vars) {
251
+ selectOptions.push({
252
+ hint: group.comment?.replace(/^#\s*/, "") ?? void 0,
253
+ label: v.key,
254
+ value: v.key
255
+ });
256
+ }
257
+ }
258
+ const selected = await p2.multiselect({
259
+ initialValues: allKeys,
260
+ message: `${file.path} (${allKeys.length} vars)`,
261
+ options: selectOptions
262
+ });
263
+ if (p2.isCancel(selected)) {
264
+ p2.cancel("Cancelled.");
265
+ process.exit(0);
266
+ }
267
+ selectedKeys = new Set(selected);
268
+ }
269
+ fileSelections.push({ content: file.content, groups, path: file.path, selectedKeys });
270
+ }
271
+ const filesToWrite = [];
272
+ for (const { groups, path, selectedKeys } of fileSelections) {
273
+ const selectedGroups = groups.map((g) => ({
274
+ comment: g.comment,
275
+ vars: g.vars.filter((v) => selectedKeys.has(v.key))
276
+ })).filter((g) => g.vars.length > 0);
277
+ if (selectedGroups.length === 0) continue;
278
+ const fullPath = join2(root, path);
279
+ let content;
280
+ if (!options.overwrite && existsSync2(fullPath)) {
281
+ const existing = readFileSync2(fullPath, "utf-8");
282
+ content = mergeEnvContent(existing, selectedGroups);
283
+ } else {
284
+ content = serializeEnvGroups(selectedGroups);
285
+ }
286
+ filesToWrite.push({ content, path });
287
+ }
288
+ if (filesToWrite.length === 0) {
289
+ p2.outro("No vars selected \u2014 nothing to write.");
290
+ return;
291
+ }
292
+ if (options.stdout) {
293
+ for (const f of filesToWrite) {
294
+ console.log(`# ${f.path}`);
295
+ console.log(f.content);
296
+ }
297
+ p2.outro("Done.");
298
+ return;
299
+ }
300
+ if (options.dryRun) {
301
+ for (const f of filesToWrite) {
302
+ const fullPath = join2(root, f.path);
303
+ const exists = existsSync2(fullPath);
304
+ const icon = exists ? options.overwrite ? "\u26A0 overwrite" : "\u26A0 merge" : "\u2190 new";
305
+ p2.log.info(`${f.path} ${icon}`);
306
+ }
307
+ p2.outro("Dry run \u2014 no files written.");
308
+ return;
309
+ }
310
+ for (const f of filesToWrite) {
311
+ const fullPath = join2(root, f.path);
312
+ const exists = existsSync2(fullPath);
313
+ const icon = exists ? options.overwrite ? "\u26A0 overwrite" : "\u26A0 merge" : "\u2190 new";
314
+ p2.log.info(`${f.path} ${icon}`);
315
+ }
316
+ if (!options.force) {
317
+ const confirm3 = await p2.confirm({ message: "Proceed?" });
318
+ if (p2.isCancel(confirm3) || !confirm3) {
319
+ p2.cancel("Cancelled.");
320
+ process.exit(0);
321
+ }
322
+ }
323
+ for (const f of filesToWrite) {
324
+ const fullPath = join2(root, f.path);
325
+ const dir = dirname(fullPath);
326
+ if (!existsSync2(dir)) {
327
+ mkdirSync2(dir, { recursive: true });
328
+ }
329
+ writeFileSync2(fullPath, f.content);
330
+ }
331
+ p2.outro(`Wrote ${filesToWrite.length} file(s).`);
332
+ }
333
+
334
+ // src/commands/share.ts
335
+ import * as p3 from "@clack/prompts";
336
+ import { DEFAULT_MAX_DOWNLOADS, DEFAULT_TTL } from "@env2/types";
337
+ import { readFileSync as readFileSync4 } from "fs";
338
+ import { join as join4, resolve as resolve2 } from "path";
339
+
340
+ // src/lib/scanner.ts
341
+ import { existsSync as existsSync3, readdirSync, readFileSync as readFileSync3 } from "fs";
342
+ import { join as join3, relative } from "path";
343
+ function scanEnvFiles(root) {
344
+ const gitignorePatterns = loadGitignore(root);
345
+ const results = [];
346
+ function walk(dir) {
347
+ const entries = readdirSync(dir, { withFileTypes: true });
348
+ for (const entry of entries) {
349
+ const fullPath = join3(dir, entry.name);
350
+ const relPath = relative(root, fullPath);
351
+ if (entry.name === "node_modules" || entry.name === ".git" || entry.name === ".turbo") {
352
+ continue;
353
+ }
354
+ if (isGitignored(relPath, gitignorePatterns)) {
355
+ continue;
356
+ }
357
+ if (entry.isDirectory()) {
358
+ walk(fullPath);
359
+ } else if (isEnvFile(entry.name)) {
360
+ const content = readFileSync3(fullPath, "utf-8");
361
+ const varCount = countVars(content);
362
+ results.push({ path: relPath, varCount });
363
+ }
364
+ }
365
+ }
366
+ walk(root);
367
+ return results.sort((a, b) => a.path.localeCompare(b.path));
368
+ }
369
+ function countVars(content) {
370
+ return content.split("\n").filter(
371
+ (line) => line.trim() && !line.trim().startsWith("#")
372
+ ).length;
373
+ }
374
+ function isEnvFile(name) {
375
+ return name === ".env" || name.startsWith(".env.") && name !== ".env.example";
376
+ }
377
+ function isGitignored(relPath, patterns) {
378
+ for (const pattern of patterns) {
379
+ const clean = pattern.replace(/\/$/, "");
380
+ if (relPath === clean || relPath.startsWith(clean + "/")) {
381
+ return true;
382
+ }
383
+ }
384
+ return false;
385
+ }
386
+ function loadGitignore(root) {
387
+ const gitignorePath = join3(root, ".gitignore");
388
+ if (!existsSync3(gitignorePath)) return [];
389
+ return readFileSync3(gitignorePath, "utf-8").split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
390
+ }
391
+
392
+ // src/commands/share.ts
393
+ async function share(options) {
394
+ const root = resolve2(options.root || ".");
395
+ const host = options.host || getConfig().host;
396
+ p3.intro("env2 share");
397
+ const files = scanEnvFiles(root);
398
+ if (files.length === 0) {
399
+ p3.log.warn("No .env files found.");
400
+ p3.outro("Nothing to share.");
401
+ return;
402
+ }
403
+ let selectedPaths;
404
+ if (options.noInteractive) {
405
+ selectedPaths = files.map((f) => f.path);
406
+ } else {
407
+ const selected = await p3.multiselect({
408
+ initialValues: files.map((f) => f.path),
409
+ message: "Select files to share",
410
+ options: files.map((f) => ({
411
+ hint: `${f.varCount} vars`,
412
+ label: f.path,
413
+ value: f.path
414
+ }))
415
+ });
416
+ if (p3.isCancel(selected)) {
417
+ p3.cancel("Cancelled.");
418
+ process.exit(0);
419
+ }
420
+ selectedPaths = selected;
421
+ }
422
+ if (selectedPaths.length === 0) {
423
+ p3.log.warn("No files selected.");
424
+ p3.outro("Nothing to share.");
425
+ return;
426
+ }
427
+ const manifestFiles = [];
428
+ for (const path of selectedPaths) {
429
+ const raw = readFileSync4(join4(root, path), "utf-8");
430
+ const groups = parseEnvContent(raw);
431
+ const allKeys = groups.flatMap((g) => g.vars.map((v) => v.key));
432
+ if (options.noInteractive || allKeys.length === 0) {
433
+ manifestFiles.push({ content: raw, path });
434
+ continue;
435
+ }
436
+ const selectOptions = groups.flatMap(
437
+ (g) => g.vars.map((v) => ({
438
+ hint: g.comment?.replace(/^#\s*/, "") ?? void 0,
439
+ label: v.key,
440
+ value: v.key
441
+ }))
442
+ );
443
+ const selected = await p3.multiselect({
444
+ initialValues: allKeys,
445
+ message: `${path} (${allKeys.length} vars)`,
446
+ options: selectOptions
447
+ });
448
+ if (p3.isCancel(selected)) {
449
+ p3.cancel("Cancelled.");
450
+ process.exit(0);
451
+ }
452
+ const selectedSet = new Set(selected);
453
+ const filteredGroups = groups.map((g) => ({
454
+ comment: g.comment,
455
+ vars: g.vars.filter((v) => selectedSet.has(v.key))
456
+ })).filter((g) => g.vars.length > 0);
457
+ if (filteredGroups.length > 0) {
458
+ manifestFiles.push({ content: serializeEnvGroups(filteredGroups), path });
459
+ }
460
+ }
461
+ if (manifestFiles.length === 0) {
462
+ p3.log.warn("No vars selected.");
463
+ p3.outro("Nothing to share.");
464
+ return;
465
+ }
466
+ const manifest = {
467
+ files: manifestFiles,
468
+ version: 1
469
+ };
470
+ const s = p3.spinner();
471
+ s.start("Encrypting and uploading...");
472
+ const { ciphertext, key } = encryptManifest(JSON.stringify(manifest));
473
+ const ttl = parseTtl(options.ttl);
474
+ const maxDownloads = Number(options.downloads) || DEFAULT_MAX_DOWNLOADS;
475
+ const res = await fetch(`${host}/s`, {
476
+ body: JSON.stringify({ ciphertext, maxDownloads, ttl }),
477
+ headers: { "Content-Type": "application/json" },
478
+ method: "POST"
479
+ });
480
+ if (!res.ok) {
481
+ s.stop("Upload failed.");
482
+ p3.log.error(`Server error: ${res.status} ${await res.text()}`);
483
+ process.exit(1);
484
+ }
485
+ const { id } = await res.json();
486
+ s.stop("Done!");
487
+ const ttlMin = Math.round(ttl / 60);
488
+ const shareUrl = `${host}/s/${id}#${key}`;
489
+ p3.log.success(`Encrypted ${selectedPaths.length} file(s) \u2014 expires ${ttlMin}min, ${maxDownloads} download(s)`);
490
+ p3.log.message(shareUrl);
491
+ const copy = await p3.confirm({ message: "Copy URL to clipboard?" });
492
+ if (!p3.isCancel(copy) && copy) {
493
+ try {
494
+ const { default: clipboardy } = await import("clipboardy");
495
+ await clipboardy.write(shareUrl);
496
+ p3.outro("Copied! Share it with your teammate.");
497
+ } catch {
498
+ p3.outro("Could not copy \u2014 share the URL above.");
499
+ }
500
+ } else {
501
+ p3.outro("Share this URL with your teammate.");
502
+ }
503
+ }
504
+ function parseTtl(ttl) {
505
+ const match = ttl.match(/^(\d+)(s|m|h)$/);
506
+ if (!match) return DEFAULT_TTL;
507
+ const [, value, unit] = match;
508
+ const multiplier = { h: 3600, m: 60, s: 1 }[unit];
509
+ return Number(value) * multiplier;
510
+ }
511
+
512
+ // src/index.ts
513
+ var program = new Command();
514
+ program.name("env2").description("Ephemeral encrypted .env sharing").version("0.0.0");
515
+ program.command("share").description("Encrypt and share .env files").option("--ttl <duration>", "Expiry duration (e.g. 5m, 15m, 1h)", "15m").option("--downloads <count>", "Max downloads", "1").option("--root <path>", "Working directory").option("--host <url>", "Custom server URL").option("--no-interactive", "Skip interactive picker").action(share);
516
+ program.command("receive <url>").description("Fetch and decrypt shared .env files").option("--root <path>", "Write files relative to this directory").option("--dry-run", "Preview without writing files").option("--force", "Skip overwrite confirmation").option("--overwrite", "Replace existing files instead of merging").option("--no-interactive", "Accept all vars without picker").option("--stdout", "Print to stdout instead of writing files").action(receive);
517
+ var config = program.command("config").description("Manage CLI configuration");
518
+ config.command("get <key>").description("Get a config value").action(configGet);
519
+ config.command("set <key> <value>").description("Set a config value").action(configSet);
520
+ config.command("reset").description("Reset config to defaults").action(configReset);
521
+ program.parse();
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@griv/env2",
3
+ "version": "0.0.1",
4
+ "description": "Ephemeral encrypted .env sharing",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/lucaschrng/env2"
10
+ },
11
+ "bin": {
12
+ "env2": "./dist/index.js"
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "dependencies": {
18
+ "@clack/prompts": "^0.10",
19
+ "clipboardy": "^5.3.1",
20
+ "commander": "^13",
21
+ "@env2/types": "0.0.0",
22
+ "@env2/crypto": "0.0.0"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^22",
26
+ "tsup": "^8.5.1",
27
+ "typescript": "^5"
28
+ },
29
+ "scripts": {
30
+ "build": "tsup src/index.ts --format esm --dts --clean",
31
+ "lint": "eslint .",
32
+ "lint:fix": "eslint . --fix",
33
+ "typecheck": "tsc --noEmit"
34
+ }
35
+ }