@modootoday/envs 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.
@@ -0,0 +1,4591 @@
1
+ import {
2
+ SCHEMA_VERSION,
3
+ addWrap,
4
+ config,
5
+ createKeyring,
6
+ createSchema,
7
+ globalDir,
8
+ hashKeyName,
9
+ itemContext,
10
+ locateCatalogs,
11
+ migrate,
12
+ open,
13
+ openDatabaseSync,
14
+ parseEnv,
15
+ readEntries,
16
+ readMeta,
17
+ readWraps,
18
+ seal,
19
+ sha256Hex,
20
+ toRecord,
21
+ unlockDek
22
+ } from "./chunk-STHFIZRP.js";
23
+
24
+ // src/catalog/export.ts
25
+ import { existsSync } from "fs";
26
+ function csvField(value) {
27
+ return `"${value.replace(/"/g, '""')}"`;
28
+ }
29
+ var FORMULA_START = /* @__PURE__ */ new Set(["=", "+", "-", "@"]);
30
+ function toCsv(entries) {
31
+ const rows = [
32
+ ["key", "value", "source", "path", "layer"].map(csvField).join(",")
33
+ ];
34
+ for (const entry of entries) {
35
+ rows.push(
36
+ [entry.key, entry.value, entry.alias, entry.path, entry.layer].map(csvField).join(",")
37
+ );
38
+ }
39
+ return `${rows.join("\r\n")}\r
40
+ `;
41
+ }
42
+ function toShell(entries) {
43
+ return entries.map((entry) => `export ${entry.key}=${JSON.stringify(entry.value)}`).join("\n").concat("\n");
44
+ }
45
+ function toEnvFile(entries) {
46
+ return entries.map((entry) => `${entry.key}=${JSON.stringify(entry.value)}`).join("\n").concat("\n");
47
+ }
48
+ function readCatalog(path, options, layer) {
49
+ if (!existsSync(path)) return [];
50
+ const db = openDatabaseSync(path, { readOnly: true });
51
+ try {
52
+ return readEntries(db, {
53
+ unlock: options.unlock,
54
+ ...options.aliases ? { aliases: options.aliases } : {},
55
+ ...options.revisionId ? { revisionId: options.revisionId } : {}
56
+ }).map((entry) => ({ ...entry, layer }));
57
+ } finally {
58
+ db.close();
59
+ }
60
+ }
61
+ function exportValues(options) {
62
+ const located = locateCatalogs(options);
63
+ const catalogs = [];
64
+ const entries = [];
65
+ catalogs.push(located.project);
66
+ entries.push(...readCatalog(located.project, options, "project"));
67
+ if (options.includeGlobal === true && located.global !== void 0) {
68
+ catalogs.push(located.global);
69
+ entries.push(...readCatalog(located.global, options, "global"));
70
+ }
71
+ const format = options.format ?? "csv";
72
+ const text = format === "csv" ? toCsv(entries) : format === "env" ? toEnvFile(entries) : format === "shell" ? toShell(entries) : `${JSON.stringify(
73
+ Object.fromEntries(entries.map((e) => [e.key, e.value])),
74
+ null,
75
+ 2
76
+ )}
77
+ `;
78
+ return {
79
+ text,
80
+ count: entries.length,
81
+ // Not rewritten: altering a value to make a spreadsheet safe would hand back
82
+ // something that is not what is stored. The hazard is reported instead.
83
+ formulaKeys: entries.filter((e) => e.value.length > 0 && FORMULA_START.has(e.value[0])).map((e) => e.key),
84
+ catalogs
85
+ };
86
+ }
87
+
88
+ // src/cli/ui.ts
89
+ var ESC = String.fromCharCode(27);
90
+ var CODES = {
91
+ reset: `${ESC}[0m`,
92
+ bold: `${ESC}[1m`,
93
+ dim: `${ESC}[2m`,
94
+ red: `${ESC}[31m`,
95
+ green: `${ESC}[32m`,
96
+ yellow: `${ESC}[33m`,
97
+ cyan: `${ESC}[36m`
98
+ };
99
+ var SYMBOL = {
100
+ success: "+",
101
+ error: "x",
102
+ warn: "!",
103
+ info: "-",
104
+ prompt: ">"
105
+ };
106
+ var KIND_COLOUR = {
107
+ success: "green",
108
+ error: "red",
109
+ warn: "yellow",
110
+ info: "cyan",
111
+ prompt: "cyan"
112
+ };
113
+ var Ui = class {
114
+ out;
115
+ err;
116
+ colour;
117
+ constructor(options = {}) {
118
+ this.out = options.stdout ?? process.stdout;
119
+ this.err = options.stderr ?? process.stderr;
120
+ const env = options.env ?? process.env;
121
+ this.colour = options.color ?? (this.err.isTTY === true && (env["NO_COLOR"] ?? "") === "" && env["TERM"] !== "dumb");
122
+ }
123
+ paint(text, ...colours) {
124
+ if (!this.colour || colours.length === 0) return text;
125
+ return `${colours.map((c) => CODES[c]).join("")}${text}${CODES.reset}`;
126
+ }
127
+ /** Data goes to stdout so it can be piped; everything else to stderr. */
128
+ data(text) {
129
+ this.out.write(text);
130
+ }
131
+ line(text = "") {
132
+ this.err.write(`${text}
133
+ `);
134
+ }
135
+ message(kind, text, detail) {
136
+ const mark = this.paint(SYMBOL[kind], KIND_COLOUR[kind], "bold");
137
+ const tail = detail === void 0 ? "" : ` ${this.paint(detail, "dim")}`;
138
+ this.line(`${mark} ${text}${tail}`);
139
+ }
140
+ success(text, detail) {
141
+ this.message("success", text, detail);
142
+ }
143
+ error(text, detail) {
144
+ this.message("error", text, detail);
145
+ }
146
+ warn(text, detail) {
147
+ this.message("warn", text, detail);
148
+ }
149
+ info(text, detail) {
150
+ this.message("info", text, detail);
151
+ }
152
+ /** Two aligned columns, as a help screen or a summary. */
153
+ table(rows, indent = " ") {
154
+ const width = rows.reduce((n, [left]) => Math.max(n, left.length), 0);
155
+ for (const [left, right] of rows) {
156
+ this.line(
157
+ `${indent}${this.paint(left.padEnd(width), "cyan")} ${this.paint(right, "dim")}`
158
+ );
159
+ }
160
+ }
161
+ heading(text) {
162
+ this.line(this.paint(text, "bold"));
163
+ }
164
+ };
165
+
166
+ // src/cli/command.ts
167
+ var GROUPS = [
168
+ "start here",
169
+ "values",
170
+ "check",
171
+ "history",
172
+ "backup",
173
+ "hosted account",
174
+ "publish"
175
+ ];
176
+ var ArgumentError = class extends Error {
177
+ constructor(message) {
178
+ super(message);
179
+ this.name = "ArgumentError";
180
+ }
181
+ };
182
+ function parseArgs(argv, specs = []) {
183
+ const byName = new Map(specs.map((spec) => [spec.name, spec]));
184
+ const positional = [];
185
+ const options = /* @__PURE__ */ new Map();
186
+ const flags = /* @__PURE__ */ new Set();
187
+ for (let i = 0; i < argv.length; i += 1) {
188
+ const arg = argv[i];
189
+ if (arg === "--") {
190
+ positional.push(...argv.slice(i + 1));
191
+ break;
192
+ }
193
+ if (!arg.startsWith("--")) {
194
+ positional.push(arg);
195
+ continue;
196
+ }
197
+ const eq = arg.indexOf("=");
198
+ const name = eq === -1 ? arg.slice(2) : arg.slice(2, eq);
199
+ const spec = byName.get(name);
200
+ if (spec === void 0) {
201
+ throw new ArgumentError(`unknown option --${name}`);
202
+ }
203
+ if (spec.boolean === true) {
204
+ if (eq !== -1) {
205
+ throw new ArgumentError(`--${name} does not take a value`);
206
+ }
207
+ flags.add(name);
208
+ continue;
209
+ }
210
+ const value = eq === -1 ? argv[++i] : arg.slice(eq + 1);
211
+ if (value === void 0) {
212
+ throw new ArgumentError(`--${name} needs a value`);
213
+ }
214
+ const existing = options.get(name);
215
+ if (existing !== void 0 && spec.repeat !== true) {
216
+ throw new ArgumentError(`--${name} was given more than once`);
217
+ }
218
+ options.set(name, [...existing ?? [], value]);
219
+ }
220
+ return { positional, options, flags };
221
+ }
222
+ function one(args, name) {
223
+ return args.options.get(name)?.[0];
224
+ }
225
+ function many(args, name) {
226
+ return args.options.get(name) ?? [];
227
+ }
228
+ function printCommandHelp(ui, command) {
229
+ ui.heading(`envs ${command.name}`);
230
+ ui.line(` ${command.describe}`);
231
+ ui.line();
232
+ ui.line(` ${ui.paint(command.usage, "dim")}`);
233
+ if (command.options && command.options.length > 0) {
234
+ ui.line();
235
+ ui.table(
236
+ command.options.map((option) => [
237
+ `--${option.name}${option.placeholder ? ` ${option.placeholder}` : ""}`,
238
+ option.describe
239
+ ])
240
+ );
241
+ }
242
+ ui.line();
243
+ }
244
+ function printHelp(ui, commands, notImplemented) {
245
+ ui.heading("envs \u2014 environment values in a catalog");
246
+ ui.line();
247
+ const ungrouped = commands.filter(
248
+ (command) => command.group === void 0 || !GROUPS.includes(command.group)
249
+ );
250
+ for (const group of GROUPS) {
251
+ const members = commands.filter((command) => command.group === group);
252
+ const rows = group === GROUPS[GROUPS.length - 1] ? [...members, ...ungrouped] : members;
253
+ if (rows.length === 0) continue;
254
+ ui.line(` ${ui.paint(group, "dim")}`);
255
+ ui.table(rows.map((command) => [command.name, command.describe]));
256
+ ui.line();
257
+ }
258
+ if (notImplemented.length > 0) {
259
+ ui.line(
260
+ ` ${ui.paint(`not implemented yet: ${notImplemented.join(", ")}`, "dim")}`
261
+ );
262
+ ui.line();
263
+ }
264
+ }
265
+
266
+ // src/commands/add.ts
267
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
268
+
269
+ // src/template/schema.ts
270
+ var TemplateError = class extends Error {
271
+ constructor(message, at) {
272
+ super(at === void 0 ? message : `${at}: ${message}`);
273
+ this.at = at;
274
+ this.name = "TemplateError";
275
+ }
276
+ at;
277
+ };
278
+ var TEMPLATE_FIELDS = /* @__PURE__ */ new Set(["name", "version", "title", "keys"]);
279
+ var KEY_FIELDS = /* @__PURE__ */ new Set([
280
+ "required",
281
+ "sensitivity",
282
+ "pattern",
283
+ "obtain",
284
+ "rotateDays",
285
+ "description"
286
+ ]);
287
+ var NAME = /^[a-z0-9][a-z0-9-]{0,38}[a-z0-9]\/[a-z0-9][a-z0-9-]{0,38}$/;
288
+ var KEY_NAME = /^[A-Z][A-Z0-9_]{0,63}$/;
289
+ var PATTERN_MAX = 200;
290
+ var NESTED_QUANTIFIER = /\([^()]*[+*}][^()]*\)(?:[+*]|\{\d+,\})/;
291
+ function ownFields(value, at) {
292
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
293
+ throw new TemplateError("must be an object", at);
294
+ }
295
+ return value;
296
+ }
297
+ function rejectUnknown(fields, allowed, at) {
298
+ for (const name of Object.keys(fields)) {
299
+ if (!allowed.has(name)) {
300
+ throw new TemplateError(`unknown field "${name}"`, at);
301
+ }
302
+ }
303
+ }
304
+ function compilePattern(pattern, at) {
305
+ if (pattern.length > PATTERN_MAX) {
306
+ throw new TemplateError(
307
+ `pattern is longer than ${String(PATTERN_MAX)} characters`,
308
+ at
309
+ );
310
+ }
311
+ if (NESTED_QUANTIFIER.test(pattern)) {
312
+ throw new TemplateError("pattern nests quantifiers and may not halt", at);
313
+ }
314
+ if (!pattern.startsWith("^") || !pattern.endsWith("$")) {
315
+ throw new TemplateError("pattern must be anchored with ^ and $", at);
316
+ }
317
+ try {
318
+ return new RegExp(pattern, "u");
319
+ } catch {
320
+ throw new TemplateError("pattern is not a valid regular expression", at);
321
+ }
322
+ }
323
+ function parseKey(raw, at, allowObtain) {
324
+ const fields = ownFields(raw, at);
325
+ rejectUnknown(fields, KEY_FIELDS, at);
326
+ const required = fields["required"];
327
+ if (typeof required !== "boolean") {
328
+ throw new TemplateError("required must be true or false", at);
329
+ }
330
+ const sensitivity = fields["sensitivity"];
331
+ if (sensitivity !== "secret" && sensitivity !== "config") {
332
+ throw new TemplateError('sensitivity must be "secret" or "config"', at);
333
+ }
334
+ const key = { required, sensitivity };
335
+ const pattern = fields["pattern"];
336
+ if (pattern !== void 0) {
337
+ if (typeof pattern !== "string") {
338
+ throw new TemplateError("pattern must be a string", at);
339
+ }
340
+ compilePattern(pattern, at);
341
+ key.pattern = pattern;
342
+ }
343
+ const obtain = fields["obtain"];
344
+ if (obtain !== void 0) {
345
+ if (typeof obtain !== "string") {
346
+ throw new TemplateError("obtain must be a string", at);
347
+ }
348
+ allowObtain(obtain, at);
349
+ key.obtain = obtain;
350
+ }
351
+ const rotateDays = fields["rotateDays"];
352
+ if (rotateDays !== void 0) {
353
+ if (typeof rotateDays !== "number" || !Number.isInteger(rotateDays) || rotateDays <= 0 || rotateDays > 3650) {
354
+ throw new TemplateError("rotateDays must be a whole number of days", at);
355
+ }
356
+ key.rotateDays = rotateDays;
357
+ }
358
+ const description = fields["description"];
359
+ if (description !== void 0) {
360
+ if (typeof description !== "string" || description.length > 300) {
361
+ throw new TemplateError("description must be a short string", at);
362
+ }
363
+ key.description = description;
364
+ }
365
+ return key;
366
+ }
367
+ function parseTemplate(text, options = {}) {
368
+ let raw;
369
+ try {
370
+ raw = JSON.parse(text);
371
+ } catch {
372
+ throw new TemplateError("is not valid JSON");
373
+ }
374
+ assertNoDuplicateKeys(text);
375
+ const fields = ownFields(raw, "template");
376
+ rejectUnknown(fields, TEMPLATE_FIELDS, "template");
377
+ const name = fields["name"];
378
+ if (typeof name !== "string" || !NAME.test(name)) {
379
+ throw new TemplateError(
380
+ 'name must be "publisher/template" in lowercase',
381
+ "template"
382
+ );
383
+ }
384
+ const version = fields["version"];
385
+ if (typeof version !== "number" || !Number.isInteger(version) || version < 1) {
386
+ throw new TemplateError("version must be a whole number from 1", "template");
387
+ }
388
+ const title = fields["title"];
389
+ if (typeof title !== "string" || title === "" || title.length > 120) {
390
+ throw new TemplateError("title must be a short non-empty string", "template");
391
+ }
392
+ const keys = ownFields(fields["keys"], "keys");
393
+ const names = Object.keys(keys);
394
+ if (names.length === 0) {
395
+ throw new TemplateError("must declare at least one key", "keys");
396
+ }
397
+ if (names.length > 100) {
398
+ throw new TemplateError("declares more than 100 keys", "keys");
399
+ }
400
+ const allowObtain = options.allowObtain ?? (() => void 0);
401
+ const parsed = {};
402
+ for (const keyName of names) {
403
+ if (!KEY_NAME.test(keyName)) {
404
+ throw new TemplateError(
405
+ "key names are upper snake case",
406
+ `keys.${keyName}`
407
+ );
408
+ }
409
+ parsed[keyName] = parseKey(keys[keyName], `keys.${keyName}`, allowObtain);
410
+ }
411
+ return { name, version, title, keys: parsed };
412
+ }
413
+ function assertNoDuplicateKeys(text) {
414
+ const seen = [];
415
+ let depth = -1;
416
+ let index = 0;
417
+ let inString = false;
418
+ let escaped = false;
419
+ let current = "";
420
+ let captured = null;
421
+ while (index < text.length) {
422
+ const ch = text[index];
423
+ if (inString) {
424
+ if (escaped) escaped = false;
425
+ else if (ch === "\\") escaped = true;
426
+ else if (ch === '"') {
427
+ inString = false;
428
+ captured = current;
429
+ } else current += ch;
430
+ index += 1;
431
+ continue;
432
+ }
433
+ if (ch === '"') {
434
+ inString = true;
435
+ current = "";
436
+ captured = null;
437
+ } else if (ch === "{") {
438
+ depth += 1;
439
+ seen[depth] = /* @__PURE__ */ new Set();
440
+ } else if (ch === "}") {
441
+ if (depth >= 0) depth -= 1;
442
+ } else if (ch === ":" && captured !== null && depth >= 0) {
443
+ const scope = seen[depth];
444
+ if (scope.has(captured)) {
445
+ throw new TemplateError(`duplicate field "${captured}"`);
446
+ }
447
+ scope.add(captured);
448
+ captured = null;
449
+ } else if (ch === "," || ch === "[") {
450
+ captured = null;
451
+ }
452
+ index += 1;
453
+ }
454
+ }
455
+ async function templateDigest(text) {
456
+ return sha256Hex(new TextEncoder().encode(text));
457
+ }
458
+
459
+ // src/template/fetch.ts
460
+ var DEFAULT_REGISTRY = "https://envs.build/v1/templates";
461
+ var NAME2 = /^[a-z0-9][a-z0-9-]{0,38}[a-z0-9]\/[a-z0-9][a-z0-9-]{0,38}$/;
462
+ var MAX_BYTES = 64 * 1024;
463
+ var looksLikeName = (value) => NAME2.test(value);
464
+ function templateUrl(name, registry2) {
465
+ if (!NAME2.test(name)) {
466
+ throw new TemplateError(
467
+ `"${name}" is not a template name; use publisher/template`
468
+ );
469
+ }
470
+ const base = new URL(`${registry2.replace(/\/+$/, "")}/`);
471
+ if (base.protocol !== "https:" && base.hostname !== "localhost") {
472
+ throw new TemplateError("the registry must be served over https");
473
+ }
474
+ return new URL(`${name}.json`, base).toString();
475
+ }
476
+ async function fetchTemplate(name, registry2 = DEFAULT_REGISTRY, fetcher2 = fetch) {
477
+ const url = templateUrl(name, registry2);
478
+ let response;
479
+ try {
480
+ response = await fetcher2(url, {
481
+ redirect: "error",
482
+ headers: { accept: "application/json" },
483
+ signal: AbortSignal.timeout(15e3)
484
+ });
485
+ } catch (error) {
486
+ throw new TemplateError(
487
+ `could not reach the registry: ${error instanceof Error ? error.message : "failed"}`
488
+ );
489
+ }
490
+ if (response.status === 404) {
491
+ throw new TemplateError(`no template named "${name}" in the registry`);
492
+ }
493
+ if (!response.ok) {
494
+ throw new TemplateError(
495
+ `the registry refused: ${String(response.status)}`
496
+ );
497
+ }
498
+ const declared = Number(response.headers.get("content-length") ?? "0");
499
+ if (declared > MAX_BYTES) {
500
+ throw new TemplateError("that template is larger than a template can be");
501
+ }
502
+ const text = await response.text();
503
+ if (text.length > MAX_BYTES) {
504
+ throw new TemplateError("that template is larger than a template can be");
505
+ }
506
+ return text;
507
+ }
508
+
509
+ // src/template/registry.ts
510
+ var RESERVED_NAMESPACES = [
511
+ "anthropic",
512
+ "aws",
513
+ "azure",
514
+ "cloudflare",
515
+ "datadog",
516
+ "discord",
517
+ "envs",
518
+ "gcp",
519
+ "github",
520
+ "gitlab",
521
+ "google",
522
+ "openai",
523
+ "postgres",
524
+ "redis",
525
+ "sentry",
526
+ "slack",
527
+ "stripe",
528
+ "supabase",
529
+ "twilio",
530
+ "vercel"
531
+ ];
532
+ var OBTAIN_DOMAINS = {
533
+ anthropic: ["console.anthropic.com"],
534
+ aws: ["console.aws.amazon.com", "docs.aws.amazon.com"],
535
+ azure: ["portal.azure.com", "learn.microsoft.com"],
536
+ cloudflare: ["dash.cloudflare.com", "developers.cloudflare.com"],
537
+ datadog: ["app.datadoghq.com", "docs.datadoghq.com"],
538
+ discord: ["discord.com"],
539
+ envs: ["envs.build"],
540
+ gcp: ["console.cloud.google.com"],
541
+ github: ["github.com", "docs.github.com"],
542
+ gitlab: ["gitlab.com", "docs.gitlab.com"],
543
+ google: ["console.cloud.google.com", "developers.google.com"],
544
+ openai: ["platform.openai.com"],
545
+ postgres: ["www.postgresql.org"],
546
+ redis: ["redis.io"],
547
+ sentry: ["sentry.io", "docs.sentry.io"],
548
+ slack: ["api.slack.com"],
549
+ stripe: ["dashboard.stripe.com", "docs.stripe.com"],
550
+ supabase: ["supabase.com"],
551
+ twilio: ["console.twilio.com"],
552
+ vercel: ["vercel.com"]
553
+ };
554
+ var namespaceOf = (name) => name.slice(0, name.indexOf("/"));
555
+ function checkObtain(name, url, at) {
556
+ let parsed;
557
+ try {
558
+ parsed = new URL(url);
559
+ } catch {
560
+ throw new TemplateError("obtain must be an absolute URL", at);
561
+ }
562
+ if (parsed.protocol !== "https:") {
563
+ throw new TemplateError("obtain must be https", at);
564
+ }
565
+ if (parsed.username !== "" || parsed.password !== "") {
566
+ throw new TemplateError("obtain must not carry credentials", at);
567
+ }
568
+ const allowed = OBTAIN_DOMAINS[namespaceOf(name)];
569
+ if (allowed === void 0) {
570
+ throw new TemplateError(
571
+ `namespace "${namespaceOf(name)}" has no obtain allowlist; templates under it may not link out`,
572
+ at
573
+ );
574
+ }
575
+ if (!allowed.includes(parsed.hostname)) {
576
+ throw new TemplateError(
577
+ `obtain host "${parsed.hostname}" is not one of ${allowed.join(", ")}`,
578
+ at
579
+ );
580
+ }
581
+ }
582
+ function checkNamespace(name, publisher) {
583
+ const namespace = namespaceOf(name);
584
+ if (!RESERVED_NAMESPACES.includes(namespace)) return;
585
+ if (publisher !== "modootoday") {
586
+ throw new TemplateError(
587
+ `"${namespace}" is a reserved namespace`,
588
+ "template"
589
+ );
590
+ }
591
+ }
592
+
593
+ // src/template/store.ts
594
+ function recordTemplate(db, input) {
595
+ db.prepare(
596
+ `INSERT INTO template_ref (name, version, digest, payload, applied_at)
597
+ VALUES ($name, $version, $digest, $payload, $appliedAt)
598
+ ON CONFLICT (name) DO UPDATE SET
599
+ version = $version, digest = $digest,
600
+ payload = $payload, applied_at = $appliedAt`
601
+ ).run(input);
602
+ }
603
+ function appliedTemplates(db) {
604
+ let rows;
605
+ try {
606
+ rows = db.prepare(
607
+ "SELECT name, version, digest, payload, applied_at FROM template_ref ORDER BY name"
608
+ ).all();
609
+ } catch {
610
+ return [];
611
+ }
612
+ const out = [];
613
+ for (const row of rows) {
614
+ try {
615
+ out.push({
616
+ name: row.name,
617
+ version: Number(row.version),
618
+ digest: row.digest,
619
+ appliedAt: row.applied_at,
620
+ template: parseTemplate(row.payload)
621
+ });
622
+ } catch {
623
+ continue;
624
+ }
625
+ }
626
+ return out;
627
+ }
628
+
629
+ // src/commands/unlock.ts
630
+ import { readFileSync } from "fs";
631
+ function readStdin() {
632
+ try {
633
+ return readFileSync(0, "utf8").trim();
634
+ } catch {
635
+ return "";
636
+ }
637
+ }
638
+ function resolveUnlock(code, env) {
639
+ const fromArg = code === "-" ? readStdin() : code;
640
+ const recovery = fromArg ?? env["ENVS_RECOVERY_CODE"] ?? "";
641
+ if (recovery !== "") return { recoveryCode: recovery };
642
+ const kek = env["ENVS_KEK"] ?? "";
643
+ if (kek === "") {
644
+ return "no key given: pass --recovery-code -, or set ENVS_RECOVERY_CODE or ENVS_KEK";
645
+ }
646
+ const bytes = new Uint8Array(Buffer.from(kek, "base64"));
647
+ if (bytes.length !== 32) return "ENVS_KEK must be 32 bytes, base64 encoded";
648
+ return { kek: bytes };
649
+ }
650
+
651
+ // src/commands/add.ts
652
+ var CATALOG_LEVEL = {
653
+ secret: "high",
654
+ config: "low"
655
+ };
656
+ async function loadTemplate(source, env = {}) {
657
+ let payload;
658
+ let from;
659
+ if (existsSync2(source)) {
660
+ payload = readFileSync2(source, "utf8");
661
+ from = source;
662
+ } else if (looksLikeName(source)) {
663
+ const registry2 = env["ENVS_REGISTRY"] ?? DEFAULT_REGISTRY;
664
+ payload = await fetchTemplate(source, registry2);
665
+ from = templateUrl(source, registry2);
666
+ } else {
667
+ throw new Error(
668
+ `no template at ${source}; pass a file or a publisher/template name`
669
+ );
670
+ }
671
+ const template = parseTemplate(payload, {
672
+ allowObtain: (url, at) => {
673
+ const name = JSON.parse(payload).name;
674
+ checkObtain(typeof name === "string" ? name : "", url, at);
675
+ }
676
+ });
677
+ return { template, payload, from };
678
+ }
679
+ var addCommand = {
680
+ name: "add",
681
+ describe: "declare the keys a template names, without setting any value",
682
+ usage: "envs add <publisher/template | template.json>",
683
+ group: "start here",
684
+ options: [
685
+ {
686
+ name: "recovery-code",
687
+ placeholder: "<code|->",
688
+ describe: "unlock with a recovery code; - reads it from stdin"
689
+ }
690
+ ],
691
+ async run({ ui, args, env, cwd }) {
692
+ const source = args.positional[0];
693
+ if (source === void 0) {
694
+ ui.error(
695
+ "say which template",
696
+ "envs add <publisher/template | template.json>"
697
+ );
698
+ return 2;
699
+ }
700
+ const located = locateCatalogs({ cwd, env });
701
+ if (!existsSync2(located.project)) {
702
+ ui.error("no catalog here", located.project);
703
+ ui.info("run envs init first");
704
+ return 1;
705
+ }
706
+ const unlock = resolveUnlock(one(args, "recovery-code"), env);
707
+ if (typeof unlock === "string") {
708
+ ui.error(unlock);
709
+ return 2;
710
+ }
711
+ let loaded;
712
+ try {
713
+ loaded = await loadTemplate(source, env);
714
+ } catch (error) {
715
+ ui.error("template refused", error.message);
716
+ return 1;
717
+ }
718
+ const { template, payload } = loaded;
719
+ const digest = await templateDigest(payload);
720
+ const db = openDatabaseSync(located.project);
721
+ try {
722
+ const dek = unlockDek(readWraps(db), unlock);
723
+ const now = (/* @__PURE__ */ new Date()).toISOString();
724
+ const declared = [];
725
+ const already = [];
726
+ db.transaction(() => {
727
+ for (const [name, key] of Object.entries(template.keys)) {
728
+ const hash = hashKeyName(dek, name);
729
+ const existing = db.prepare(
730
+ "SELECT key_hash FROM keys WHERE key_hash = $hash"
731
+ ).get({ hash });
732
+ if (existing) {
733
+ already.push(name);
734
+ continue;
735
+ }
736
+ db.prepare(
737
+ `INSERT INTO keys (key_hash, first_seen_at, sensitivity)
738
+ VALUES ($hash, $at, $level)`
739
+ ).run({
740
+ hash,
741
+ at: now,
742
+ level: CATALOG_LEVEL[key.sensitivity]
743
+ });
744
+ declared.push(name);
745
+ }
746
+ recordTemplate(db, {
747
+ name: template.name,
748
+ version: template.version,
749
+ digest,
750
+ payload,
751
+ appliedAt: now
752
+ });
753
+ });
754
+ ui.success(
755
+ `${template.name} v${String(template.version)}`,
756
+ template.title
757
+ );
758
+ if (declared.length > 0) {
759
+ ui.info(`declared ${String(declared.length)}`, declared.join(" "));
760
+ }
761
+ if (already.length > 0) {
762
+ ui.info(`already present ${String(already.length)}`, already.join(" "));
763
+ }
764
+ ui.info("no values were set", "envs doctor says which are still missing");
765
+ return 0;
766
+ } catch (error) {
767
+ ui.error(error.message);
768
+ return 1;
769
+ } finally {
770
+ db.close();
771
+ }
772
+ }
773
+ };
774
+
775
+ // src/commands/export.ts
776
+ import { existsSync as existsSync3 } from "fs";
777
+ var FORMATS = /* @__PURE__ */ new Set(["csv", "env", "json", "shell"]);
778
+ var exportCommand = {
779
+ name: "export",
780
+ describe: "decrypt the catalog and print its values",
781
+ usage: "envs export --yes [--format csv|env|json|shell] [--recovery-code -]",
782
+ group: "values",
783
+ options: [
784
+ { name: "yes", boolean: true, describe: "required: this prints secrets" },
785
+ {
786
+ name: "format",
787
+ placeholder: "<fmt>",
788
+ describe: "csv (default), env, json or shell"
789
+ },
790
+ {
791
+ name: "recovery-code",
792
+ placeholder: "<code|->",
793
+ describe: "unlock with a recovery code; - reads it from stdin"
794
+ },
795
+ {
796
+ name: "alias",
797
+ placeholder: "<name>",
798
+ repeat: true,
799
+ describe: "restrict to one source; repeatable"
800
+ },
801
+ {
802
+ name: "revision",
803
+ placeholder: "<id>",
804
+ describe: "a release other than the current"
805
+ },
806
+ { name: "include-global", boolean: true, describe: "also read ~/.envs" }
807
+ ],
808
+ run({ ui, args, env, cwd }) {
809
+ if (!args.flags.has("yes")) {
810
+ ui.error(
811
+ "export prints decrypted values to stdout",
812
+ "re-run with --yes if that is what you want"
813
+ );
814
+ return 2;
815
+ }
816
+ const format = one(args, "format") ?? "csv";
817
+ if (!FORMATS.has(format)) {
818
+ ui.error(`unknown format "${format}"`, "use csv, env, json or shell");
819
+ return 2;
820
+ }
821
+ const located = locateCatalogs({ cwd, env });
822
+ if (!existsSync3(located.project)) {
823
+ ui.error("no catalog here", located.project);
824
+ ui.info("run envs init first");
825
+ return 1;
826
+ }
827
+ const code = one(args, "recovery-code");
828
+ if (code !== void 0 && code !== "-") {
829
+ ui.warn(
830
+ "a recovery code on the command line is visible to other processes",
831
+ "prefer --recovery-code - and pipe it in"
832
+ );
833
+ }
834
+ const unlock = resolveUnlock(code, env);
835
+ if (typeof unlock === "string") {
836
+ ui.error(unlock);
837
+ return 2;
838
+ }
839
+ const aliases = many(args, "alias");
840
+ const revision = one(args, "revision");
841
+ try {
842
+ const result = exportValues({
843
+ unlock,
844
+ cwd,
845
+ env,
846
+ format,
847
+ ...aliases.length > 0 ? { aliases } : {},
848
+ ...revision ? { revisionId: revision } : {},
849
+ includeGlobal: args.flags.has("include-global")
850
+ });
851
+ ui.data(result.text);
852
+ ui.success(`exported ${result.count} values`, result.catalogs.join(", "));
853
+ if (result.formulaKeys.length > 0) {
854
+ ui.warn(
855
+ "a spreadsheet will evaluate these rather than display them",
856
+ `value starts with = + - or @: ${result.formulaKeys.join(", ")}`
857
+ );
858
+ }
859
+ return 0;
860
+ } catch (error) {
861
+ ui.error(error.message);
862
+ return 1;
863
+ }
864
+ }
865
+ };
866
+
867
+ // src/commands/del.ts
868
+ import { existsSync as existsSync4 } from "fs";
869
+
870
+ // src/catalog/write.ts
871
+ import { randomUUID } from "crypto";
872
+
873
+ // src/catalog/alias.ts
874
+ import { basename, dirname, sep } from "path";
875
+ var AliasError = class extends Error {
876
+ constructor(message) {
877
+ super(message);
878
+ this.name = "AliasError";
879
+ }
880
+ };
881
+ function normaliseAlias(raw) {
882
+ const cleaned = raw.toLowerCase().replace(/^\.+/, "").replace(/\./g, "-").replace(/[^a-z0-9-]+/g, "-").replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "");
883
+ if (cleaned === "") {
884
+ throw new AliasError(`"${raw}" normalises to nothing; give an alias`);
885
+ }
886
+ return cleaned;
887
+ }
888
+ var OFFSET = "_";
889
+ function deriveAlias(path, options) {
890
+ const { taken } = options;
891
+ const maxSegments = options.maxSegments ?? 4;
892
+ const base = normaliseAlias(basename(path));
893
+ if (!taken.has(base)) return base;
894
+ const parents = dirname(path).split(sep).filter((segment) => segment !== "" && segment !== ".");
895
+ for (let depth = 1; depth <= Math.min(maxSegments, parents.length); depth += 1) {
896
+ const prefix = parents.slice(parents.length - depth).map((segment) => normaliseAlias(segment)).join("-");
897
+ const candidate = `${prefix}-${base}`;
898
+ if (!taken.has(candidate)) return candidate;
899
+ }
900
+ for (let n = 1; ; n += 1) {
901
+ const candidate = `${base}${OFFSET}${n}`;
902
+ if (!taken.has(candidate)) return candidate;
903
+ }
904
+ }
905
+ function checkAlias(alias, taken) {
906
+ if (!/^[a-z0-9][a-z0-9-]*(_[0-9]+)?$/.test(alias)) {
907
+ throw new AliasError(
908
+ `"${alias}" is not a usable alias: lower case letters, digits and dashes`
909
+ );
910
+ }
911
+ if (taken.has(alias)) {
912
+ throw new AliasError(`alias "${alias}" is already in use or retired`);
913
+ }
914
+ }
915
+
916
+ // src/catalog/write.ts
917
+ var encoder = new TextEncoder();
918
+ var systemClock = {
919
+ now: () => (/* @__PURE__ */ new Date()).toISOString(),
920
+ newId: () => randomUUID()
921
+ };
922
+ function writeWraps(db, wraps, clock = systemClock) {
923
+ const insert = db.prepare(
924
+ `INSERT INTO dek_wraps (wrap_id, method, salt, scrypt_n, scrypt_r, scrypt_p, envelope, created_at)
925
+ VALUES ($id, $method, $salt, $n, $r, $p, $envelope, $at)`
926
+ );
927
+ const at = clock.now();
928
+ for (const wrap of wraps) {
929
+ insert.run({
930
+ id: wrap.wrapId,
931
+ method: wrap.method,
932
+ salt: wrap.salt,
933
+ n: wrap.n,
934
+ r: wrap.r,
935
+ p: wrap.p,
936
+ envelope: wrap.envelope,
937
+ at
938
+ });
939
+ }
940
+ }
941
+ function audit(db, action, subject, detail, clock = systemClock) {
942
+ db.prepare(
943
+ "INSERT INTO audit (at, action, subject, detail) VALUES ($at, $action, $subject, $detail)"
944
+ ).run({
945
+ at: clock.now(),
946
+ action,
947
+ subject: subject ?? null,
948
+ detail: detail ?? null
949
+ });
950
+ }
951
+ function takenAliases(db) {
952
+ const live = db.prepare("SELECT alias FROM sources").all().map((row) => row.alias);
953
+ const retired = db.prepare("SELECT alias FROM alias_retired").all().map((row) => row.alias);
954
+ return /* @__PURE__ */ new Set([...live, ...retired]);
955
+ }
956
+ function ensureSource(db, path, options = {}) {
957
+ const clock = options.clock ?? systemClock;
958
+ const existing = db.prepare(
959
+ "SELECT source_id, alias FROM sources WHERE path = $path"
960
+ ).get({ path });
961
+ if (existing !== void 0) {
962
+ db.prepare(
963
+ "UPDATE sources SET last_seen_at = $at, digest = $digest, retired_at = NULL WHERE path = $path"
964
+ ).run({ at: clock.now(), digest: options.digest ?? null, path });
965
+ return { sourceId: existing.source_id, alias: existing.alias, path };
966
+ }
967
+ const taken = takenAliases(db);
968
+ const alias = options.alias ?? deriveAlias(path, { taken });
969
+ checkAlias(alias, taken);
970
+ const sourceId = clock.newId();
971
+ db.prepare(
972
+ `INSERT INTO sources (source_id, path, alias, kind, digest, added_at, last_seen_at)
973
+ VALUES ($id, $path, $alias, 'file', $digest, $at, $at)`
974
+ ).run({
975
+ id: sourceId,
976
+ path,
977
+ alias,
978
+ digest: options.digest ?? null,
979
+ at: clock.now()
980
+ });
981
+ audit(db, "source.add", alias, path, clock);
982
+ return { sourceId, alias, path };
983
+ }
984
+ function currentRevisionId(db) {
985
+ return db.prepare(
986
+ "SELECT revision_id FROM pointer WHERE id = 1"
987
+ ).get()?.revision_id;
988
+ }
989
+ function writeRelease(db, dek, items, options = {}) {
990
+ const clock = options.clock ?? systemClock;
991
+ const revisionId = clock.newId();
992
+ const at = clock.now();
993
+ db.transaction(() => {
994
+ db.prepare(
995
+ "INSERT INTO releases (revision_id, created_at, note) VALUES ($rev, $at, $note)"
996
+ ).run({ rev: revisionId, at, note: options.note ?? null });
997
+ const insertKey = db.prepare(
998
+ "INSERT OR IGNORE INTO keys (key_hash, first_seen_at) VALUES ($hash, $at)"
999
+ );
1000
+ const insertItem = db.prepare(
1001
+ `INSERT INTO items (source_id, key_hash, revision_id, envelope, kek_version, created_at)
1002
+ VALUES ($source, $hash, $rev, $envelope, 1, $at)`
1003
+ );
1004
+ for (const item of items) {
1005
+ const keyHash = hashKeyName(dek, item.key);
1006
+ insertKey.run({ hash: keyHash, at });
1007
+ insertItem.run({
1008
+ source: item.sourceId,
1009
+ hash: keyHash,
1010
+ rev: revisionId,
1011
+ // The name travels inside the envelope: the column holds only its HMAC.
1012
+ envelope: seal({
1013
+ kek: dek,
1014
+ kekVersion: 1,
1015
+ plaintext: encoder.encode(`${item.key}=${item.value}`),
1016
+ context: itemContext(item.sourceId, keyHash, revisionId)
1017
+ }),
1018
+ at
1019
+ });
1020
+ }
1021
+ if (options.movePointer !== false) {
1022
+ db.prepare(
1023
+ `INSERT INTO pointer (id, revision_id, updated_at) VALUES (1, $rev, $at)
1024
+ ON CONFLICT (id) DO UPDATE SET revision_id = $rev, updated_at = $at`
1025
+ ).run({ rev: revisionId, at });
1026
+ }
1027
+ audit(db, "release.write", revisionId, `${items.length} items`, clock);
1028
+ });
1029
+ return { revisionId, count: items.length };
1030
+ }
1031
+ function movePointer(db, revisionId, clock = systemClock) {
1032
+ const known = db.prepare(
1033
+ "SELECT count(*) AS n FROM releases WHERE revision_id = $rev"
1034
+ ).get({ rev: revisionId });
1035
+ if ((known?.n ?? 0) === 0) {
1036
+ throw new Error(`no release ${revisionId} in this catalog`);
1037
+ }
1038
+ const at = clock.now();
1039
+ db.transaction(() => {
1040
+ db.prepare(
1041
+ `INSERT INTO pointer (id, revision_id, updated_at) VALUES (1, $rev, $at)
1042
+ ON CONFLICT (id) DO UPDATE SET revision_id = $rev, updated_at = $at`
1043
+ ).run({ rev: revisionId, at });
1044
+ audit(db, "pointer.move", revisionId, void 0, clock);
1045
+ });
1046
+ }
1047
+
1048
+ // src/commands/del.ts
1049
+ var delCommand = {
1050
+ name: "del",
1051
+ describe: "remove one key, as a new release",
1052
+ usage: "envs del <KEY> [--source <alias>]",
1053
+ group: "values",
1054
+ options: [
1055
+ {
1056
+ name: "source",
1057
+ placeholder: "<alias>",
1058
+ describe: "remove it from this source only"
1059
+ },
1060
+ {
1061
+ name: "recovery-code",
1062
+ placeholder: "<code|->",
1063
+ describe: "unlock with a recovery code; - reads it from stdin"
1064
+ }
1065
+ ],
1066
+ run({ ui, args, env, cwd }) {
1067
+ if (args.positional.length !== 1) {
1068
+ ui.error("give exactly one key", "envs del <KEY>");
1069
+ return 2;
1070
+ }
1071
+ const key = args.positional[0];
1072
+ const located = locateCatalogs({ cwd, env });
1073
+ if (!existsSync4(located.project)) {
1074
+ ui.error("no catalog here", located.project);
1075
+ ui.info("run envs init first");
1076
+ return 1;
1077
+ }
1078
+ const unlock = resolveUnlock(one(args, "recovery-code"), env);
1079
+ if (typeof unlock === "string") {
1080
+ ui.error(unlock);
1081
+ return 2;
1082
+ }
1083
+ const wantedSource = one(args, "source");
1084
+ const db = openDatabaseSync(located.project);
1085
+ try {
1086
+ const dek = unlockDek(readWraps(db), unlock);
1087
+ const items = [];
1088
+ const removedFrom = [];
1089
+ for (const entry of readEntries(db, { unlock })) {
1090
+ const matches = entry.key === key && (wantedSource === void 0 || entry.alias === wantedSource);
1091
+ if (matches) {
1092
+ removedFrom.push(entry.alias);
1093
+ continue;
1094
+ }
1095
+ items.push({
1096
+ sourceId: entry.sourceId,
1097
+ key: entry.key,
1098
+ value: entry.value
1099
+ });
1100
+ }
1101
+ if (removedFrom.length === 0) {
1102
+ ui.error(
1103
+ `no value for ${key}`,
1104
+ wantedSource === void 0 ? "nothing removed" : `not in ${wantedSource}`
1105
+ );
1106
+ return 1;
1107
+ }
1108
+ const release = writeRelease(db, dek, items, {
1109
+ note: `del ${key} from ${removedFrom.join(", ")}`
1110
+ });
1111
+ ui.success(`removed ${key}`, `from ${removedFrom.join(", ")}`);
1112
+ ui.success(
1113
+ `release ${release.revisionId.slice(0, 8)} is current`,
1114
+ `${release.count} values \u2014 envs rollback brings it back`
1115
+ );
1116
+ return 0;
1117
+ } catch (error) {
1118
+ ui.error(error.message);
1119
+ return 1;
1120
+ } finally {
1121
+ db.close();
1122
+ }
1123
+ }
1124
+ };
1125
+
1126
+ // src/commands/migrate.ts
1127
+ import { existsSync as existsSync5 } from "fs";
1128
+ var migrateCommand = {
1129
+ name: "migrate",
1130
+ describe: "bring an older catalog up to this build's schema",
1131
+ usage: "envs migrate",
1132
+ group: "history",
1133
+ run({ ui, env, cwd }) {
1134
+ const located = locateCatalogs({ cwd, env });
1135
+ if (!existsSync5(located.project)) {
1136
+ ui.error("no catalog here", located.project);
1137
+ ui.info("run envs init first");
1138
+ return 1;
1139
+ }
1140
+ const db = openDatabaseSync(located.project);
1141
+ try {
1142
+ const meta = readMeta(db);
1143
+ if (meta === void 0) {
1144
+ ui.error("this file is not a catalog", located.project);
1145
+ return 1;
1146
+ }
1147
+ if (meta.version === SCHEMA_VERSION) {
1148
+ ui.info(
1149
+ `already at schema ${String(SCHEMA_VERSION)}`,
1150
+ "nothing to upgrade"
1151
+ );
1152
+ return 0;
1153
+ }
1154
+ const from = meta.version;
1155
+ const after = migrate(db);
1156
+ ui.success(
1157
+ `schema ${String(from)} to ${String(after.version)}`,
1158
+ located.project
1159
+ );
1160
+ return 0;
1161
+ } catch (error) {
1162
+ ui.error("could not upgrade", error.message);
1163
+ return 1;
1164
+ } finally {
1165
+ db.close();
1166
+ }
1167
+ }
1168
+ };
1169
+
1170
+ // src/commands/template.ts
1171
+ import { existsSync as existsSync6, readFileSync as readFileSync3 } from "fs";
1172
+ var isReserved = (name) => RESERVED_NAMESPACES.includes(namespaceOf(name));
1173
+ var templateCommand = {
1174
+ name: "template",
1175
+ describe: "check a template before publishing it",
1176
+ usage: "envs template lint <file.json>",
1177
+ group: "publish",
1178
+ options: [
1179
+ {
1180
+ name: "publisher",
1181
+ placeholder: "<name>",
1182
+ describe: "who is publishing, for the reserved namespace check"
1183
+ }
1184
+ ],
1185
+ async run({ ui, args }) {
1186
+ const [verb, file] = args.positional;
1187
+ if (verb !== "lint") {
1188
+ ui.error("say what to do", "envs template lint <file.json>");
1189
+ return 2;
1190
+ }
1191
+ if (file === void 0) {
1192
+ ui.error("say which file", "envs template lint <file.json>");
1193
+ return 2;
1194
+ }
1195
+ if (!existsSync6(file)) {
1196
+ ui.error("no such file", file);
1197
+ return 1;
1198
+ }
1199
+ const payload = readFileSync3(file, "utf8");
1200
+ try {
1201
+ const declared = JSON.parse(payload).name;
1202
+ const name = typeof declared === "string" ? declared : "";
1203
+ const template = parseTemplate(payload, {
1204
+ allowObtain: (url, at) => {
1205
+ checkObtain(name, url, at);
1206
+ }
1207
+ });
1208
+ const publisher = one(args, "publisher");
1209
+ if (publisher !== void 0) {
1210
+ checkNamespace(template.name, publisher);
1211
+ } else if (isReserved(template.name)) {
1212
+ ui.warn(
1213
+ `"${namespaceOf(template.name)}" is a reserved namespace`,
1214
+ "publishing under it is refused unless we are the publisher"
1215
+ );
1216
+ }
1217
+ const keys = Object.entries(template.keys);
1218
+ ui.success(`${template.name} v${String(template.version)}`, template.title);
1219
+ ui.info("digest", await templateDigest(payload));
1220
+ ui.info(
1221
+ `${String(keys.length)} keys`,
1222
+ keys.map(([keyName, key]) => `${keyName}${key.required ? "" : "?"}`).join(" ")
1223
+ );
1224
+ const unpatterned = keys.filter(([, key]) => key.pattern === void 0);
1225
+ if (unpatterned.length > 0) {
1226
+ ui.warn(
1227
+ `${String(unpatterned.length)} without a pattern`,
1228
+ unpatterned.map(([keyName]) => keyName).join(" ")
1229
+ );
1230
+ }
1231
+ return 0;
1232
+ } catch (error) {
1233
+ ui.error("template refused", error.message);
1234
+ return 1;
1235
+ }
1236
+ }
1237
+ };
1238
+
1239
+ // src/commands/backup.ts
1240
+ import {
1241
+ copyFileSync,
1242
+ existsSync as existsSync8,
1243
+ readFileSync as readFileSync6,
1244
+ renameSync as renameSync2,
1245
+ rmSync,
1246
+ writeFileSync as writeFileSync3
1247
+ } from "fs";
1248
+ import { dirname as dirname3 } from "path";
1249
+
1250
+ // src/backup/provider.ts
1251
+ var BackupError = class extends Error {
1252
+ constructor(message) {
1253
+ super(message);
1254
+ this.name = "BackupError";
1255
+ }
1256
+ };
1257
+ var registry = /* @__PURE__ */ new Map();
1258
+ function register(provider) {
1259
+ registry.set(provider.name, provider);
1260
+ }
1261
+ function providers() {
1262
+ return [...registry.values()];
1263
+ }
1264
+ function resolveProvider(env, pinned) {
1265
+ const name = pinned ?? env["ENVS_BACKUP_PROVIDER"];
1266
+ if (name !== void 0 && name !== "") {
1267
+ const chosen = registry.get(name);
1268
+ if (chosen === void 0) {
1269
+ throw new BackupError(
1270
+ `"${name}" is not one of ${[...registry.keys()].join(", ")}`
1271
+ );
1272
+ }
1273
+ if (!chosen.eligible(env)) {
1274
+ throw new BackupError(
1275
+ `${name} is not configured: ${chosen.describe(env)}`
1276
+ );
1277
+ }
1278
+ return chosen;
1279
+ }
1280
+ for (const provider of registry.values()) {
1281
+ if (provider.eligible(env)) return provider;
1282
+ }
1283
+ throw new BackupError("no backup destination is configured");
1284
+ }
1285
+
1286
+ // src/backup/providers.ts
1287
+ import {
1288
+ mkdirSync,
1289
+ readFileSync as readFileSync4,
1290
+ readdirSync,
1291
+ statSync,
1292
+ writeFileSync
1293
+ } from "fs";
1294
+ import { isAbsolute, join, resolve } from "path";
1295
+
1296
+ // src/backup/sigv4.ts
1297
+ import { createHash, createHmac } from "crypto";
1298
+ var ALGORITHM = "AWS4-HMAC-SHA256";
1299
+ var sha256Hex2 = (data) => createHash("sha256").update(data).digest("hex");
1300
+ var hmac = (key, data) => new Uint8Array(createHmac("sha256", key).update(data, "utf8").digest());
1301
+ function amzDate(now) {
1302
+ return `${now.toISOString().replace(/[:-]|\.\d{3}/g, "")}`;
1303
+ }
1304
+ function encodePath(path) {
1305
+ return path.split("/").map(
1306
+ (segment) => encodeURIComponent(segment).replace(
1307
+ /[!'()*]/g,
1308
+ (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`
1309
+ )
1310
+ ).join("/");
1311
+ }
1312
+ function canonicalQuery(query) {
1313
+ return Object.keys(query).sort().map(
1314
+ (key) => `${encodeURIComponent(key)}=${encodeURIComponent(query[key] ?? "")}`
1315
+ ).join("&");
1316
+ }
1317
+ function signRequest(input) {
1318
+ const now = input.now ?? /* @__PURE__ */ new Date();
1319
+ const stamp = amzDate(now);
1320
+ const day = stamp.slice(0, 8);
1321
+ const payloadHash = sha256Hex2(input.body);
1322
+ const headers = {
1323
+ ...input.headers,
1324
+ "x-amz-date": stamp,
1325
+ "x-amz-content-sha256": payloadHash
1326
+ };
1327
+ if (input.sessionToken !== void 0 && input.sessionToken !== "") {
1328
+ headers["x-amz-security-token"] = input.sessionToken;
1329
+ }
1330
+ const names = Object.keys(headers).map((name) => name.toLowerCase()).sort();
1331
+ const lookup = new Map(
1332
+ Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value])
1333
+ );
1334
+ const canonicalHeaders = names.map((name) => `${name}:${(lookup.get(name) ?? "").trim()}
1335
+ `).join("");
1336
+ const signedHeaders = names.join(";");
1337
+ const canonicalRequest = [
1338
+ input.method.toUpperCase(),
1339
+ input.path,
1340
+ canonicalQuery(input.query ?? {}),
1341
+ canonicalHeaders,
1342
+ signedHeaders,
1343
+ payloadHash
1344
+ ].join("\n");
1345
+ const scope = `${day}/${input.region}/${input.service}/aws4_request`;
1346
+ const stringToSign = [
1347
+ ALGORITHM,
1348
+ stamp,
1349
+ scope,
1350
+ sha256Hex2(canonicalRequest)
1351
+ ].join("\n");
1352
+ const kDate = hmac(`AWS4${input.secretAccessKey}`, day);
1353
+ const kRegion = hmac(kDate, input.region);
1354
+ const kService = hmac(kRegion, input.service);
1355
+ const kSigning = hmac(kService, "aws4_request");
1356
+ const signature = Buffer.from(hmac(kSigning, stringToSign)).toString("hex");
1357
+ return {
1358
+ headers: {
1359
+ ...headers,
1360
+ authorization: `${ALGORITHM} Credential=${input.accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`
1361
+ },
1362
+ canonicalRequest,
1363
+ stringToSign,
1364
+ signature
1365
+ };
1366
+ }
1367
+
1368
+ // src/backup/providers.ts
1369
+ var fileProvider = {
1370
+ name: "file",
1371
+ describe: (env) => {
1372
+ const given = env["ENVS_BACKUP_DIR"];
1373
+ return given === void 0 || given === "" ? "needs ENVS_BACKUP_DIR or --to <dir>" : `writes to ${resolve(given)}`;
1374
+ },
1375
+ eligible: (env) => (env["ENVS_BACKUP_DIR"] ?? "") !== "",
1376
+ async put(env, name, bytes) {
1377
+ const dir = directoryOf(env);
1378
+ mkdirSync(dir, { recursive: true });
1379
+ writeFileSync(join(dir, name), bytes, { mode: 384 });
1380
+ },
1381
+ async get(env, name) {
1382
+ return new Uint8Array(readFileSync4(join(directoryOf(env), name)));
1383
+ },
1384
+ async list(env) {
1385
+ const dir = directoryOf(env);
1386
+ let names;
1387
+ try {
1388
+ names = readdirSync(dir);
1389
+ } catch {
1390
+ return [];
1391
+ }
1392
+ return names.filter((name) => name.endsWith(".envsnap")).map((name) => {
1393
+ const info = statSync(join(dir, name));
1394
+ return {
1395
+ name,
1396
+ size: info.size,
1397
+ modifiedAt: info.mtime.toISOString()
1398
+ };
1399
+ }).sort((a, b) => a.name.localeCompare(b.name));
1400
+ }
1401
+ };
1402
+ function directoryOf(env) {
1403
+ const given = env["ENVS_BACKUP_DIR"];
1404
+ if (given === void 0 || given === "") {
1405
+ throw new BackupError(
1406
+ "set ENVS_BACKUP_DIR to a directory, or pass --to <dir>"
1407
+ );
1408
+ }
1409
+ return isAbsolute(given) ? given : resolve(given);
1410
+ }
1411
+ function s3Config(env) {
1412
+ const bucket = env["ENVS_BACKUP_BUCKET"] ?? "";
1413
+ const accessKeyId = env["ENVS_BACKUP_ACCESS_KEY_ID"] ?? env["AWS_ACCESS_KEY_ID"] ?? "";
1414
+ const secretAccessKey = env["ENVS_BACKUP_SECRET_ACCESS_KEY"] ?? env["AWS_SECRET_ACCESS_KEY"] ?? "";
1415
+ if (bucket === "" || accessKeyId === "" || secretAccessKey === "") {
1416
+ return void 0;
1417
+ }
1418
+ const region = env["ENVS_BACKUP_REGION"] ?? env["AWS_REGION"] ?? "us-east-1";
1419
+ const endpoint = env["ENVS_BACKUP_ENDPOINT"] ?? `https://s3.${region}.amazonaws.com`;
1420
+ const token = env["ENVS_BACKUP_SESSION_TOKEN"] ?? env["AWS_SESSION_TOKEN"];
1421
+ return {
1422
+ endpoint: endpoint.replace(/\/+$/, ""),
1423
+ bucket,
1424
+ region,
1425
+ accessKeyId,
1426
+ secretAccessKey,
1427
+ ...token !== void 0 && token !== "" ? { sessionToken: token } : {},
1428
+ prefix: (env["ENVS_BACKUP_PREFIX"] ?? "").replace(/^\/+|\/+$/g, "")
1429
+ };
1430
+ }
1431
+ async function s3Send(config2, method, key, body, query = {}) {
1432
+ const url = new URL(`${config2.endpoint}/${config2.bucket}${key}`);
1433
+ for (const [name, value] of Object.entries(query)) {
1434
+ url.searchParams.set(name, value);
1435
+ }
1436
+ const signed = signRequest({
1437
+ method,
1438
+ path: encodePath(url.pathname),
1439
+ query,
1440
+ headers: { host: url.host },
1441
+ body,
1442
+ region: config2.region,
1443
+ service: "s3",
1444
+ accessKeyId: config2.accessKeyId,
1445
+ secretAccessKey: config2.secretAccessKey,
1446
+ ...config2.sessionToken ? { sessionToken: config2.sessionToken } : {}
1447
+ });
1448
+ const response = await fetch(url, {
1449
+ method,
1450
+ headers: signed.headers,
1451
+ ...method === "PUT" ? { body } : {}
1452
+ });
1453
+ if (!response.ok) {
1454
+ throw new BackupError(
1455
+ `${method} ${url.pathname} failed: ${response.status} ${(await response.text()).slice(0, 200)}`
1456
+ );
1457
+ }
1458
+ return response;
1459
+ }
1460
+ var s3Provider = {
1461
+ name: "s3",
1462
+ describe: (env) => s3Config(env) === void 0 ? "needs ENVS_BACKUP_BUCKET and credentials" : `writes to ${s3Config(env).endpoint}/${s3Config(env).bucket}`,
1463
+ eligible: (env) => s3Config(env) !== void 0,
1464
+ async put(env, name, bytes) {
1465
+ const config2 = s3Config(env);
1466
+ await s3Send(config2, "PUT", keyFor(config2, name), bytes);
1467
+ },
1468
+ async get(env, name) {
1469
+ const config2 = s3Config(env);
1470
+ const response = await s3Send(
1471
+ config2,
1472
+ "GET",
1473
+ keyFor(config2, name),
1474
+ new Uint8Array(0)
1475
+ );
1476
+ return new Uint8Array(await response.arrayBuffer());
1477
+ },
1478
+ async list(env) {
1479
+ const config2 = s3Config(env);
1480
+ const response = await s3Send(config2, "GET", "", new Uint8Array(0), {
1481
+ "list-type": "2",
1482
+ ...config2.prefix === "" ? {} : { prefix: `${config2.prefix}/` }
1483
+ });
1484
+ const xml = await response.text();
1485
+ const out = [];
1486
+ for (const block of xml.split("<Contents>").slice(1)) {
1487
+ const key = /<Key>([^<]*)<\/Key>/.exec(block)?.[1];
1488
+ if (key === void 0 || !key.endsWith(".envsnap")) continue;
1489
+ out.push({
1490
+ name: key.slice(key.lastIndexOf("/") + 1),
1491
+ size: Number(/<Size>(\d+)<\/Size>/.exec(block)?.[1] ?? 0),
1492
+ .../<LastModified>([^<]*)<\/LastModified>/.exec(block)?.[1] ? {
1493
+ modifiedAt: /<LastModified>([^<]*)<\/LastModified>/.exec(
1494
+ block
1495
+ )[1]
1496
+ } : {}
1497
+ });
1498
+ }
1499
+ return out.sort((a, b) => a.name.localeCompare(b.name));
1500
+ }
1501
+ };
1502
+ function keyFor(config2, name) {
1503
+ return config2.prefix === "" ? `/${name}` : `/${config2.prefix}/${name}`;
1504
+ }
1505
+ register(s3Provider);
1506
+ register(fileProvider);
1507
+
1508
+ // src/remote/session.ts
1509
+ import {
1510
+ constants,
1511
+ closeSync,
1512
+ fchmodSync,
1513
+ fstatSync,
1514
+ fsyncSync,
1515
+ lstatSync,
1516
+ mkdirSync as mkdirSync2,
1517
+ openSync,
1518
+ readFileSync as readFileSync5,
1519
+ renameSync,
1520
+ unlinkSync,
1521
+ writeFileSync as writeFileSync2
1522
+ } from "fs";
1523
+ import { randomUUID as randomUUID2 } from "crypto";
1524
+ import { dirname as dirname2, join as join2, resolve as resolve2 } from "path";
1525
+ var DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
1526
+ var form = (fields) => new URLSearchParams(fields).toString();
1527
+ var FORM_HEADERS = { "content-type": "application/x-www-form-urlencoded" };
1528
+ function validateIssuerUrl(value, issuer) {
1529
+ const url = new URL(value);
1530
+ if (url.protocol !== "https:" || url.origin !== new URL(issuer).origin || url.username || url.password || url.hash)
1531
+ throw new Error("OAuth endpoint must stay on the issuer origin");
1532
+ }
1533
+ async function discover(issuer, fetcher2) {
1534
+ const base = issuer.replace(/\/+$/, "");
1535
+ const source = new URL(base);
1536
+ if (source.protocol !== "https:" || source.username || source.password || source.search || source.hash || source.pathname !== "/")
1537
+ throw new Error("issuer must be an HTTPS origin");
1538
+ const res = await fetcher2(`${base}/.well-known/oauth-authorization-server`);
1539
+ if (!res.ok)
1540
+ throw new Error(`issuer did not answer discovery (${res.status})`);
1541
+ const body = await res.json();
1542
+ if (!body.token_endpoint || !body.device_authorization_endpoint) {
1543
+ throw new Error("issuer advertises no device grant");
1544
+ }
1545
+ if (!body.issuer || new URL(body.issuer).toString() !== source.toString()) {
1546
+ throw new Error("discovery issuer mismatch");
1547
+ }
1548
+ for (const endpoint of [
1549
+ body.token_endpoint,
1550
+ body.device_authorization_endpoint,
1551
+ body.revocation_endpoint
1552
+ ]) {
1553
+ if (endpoint !== void 0) validateIssuerUrl(endpoint, base);
1554
+ }
1555
+ const grants = body.grant_types_supported ?? [];
1556
+ if (grants.length > 0 && !grants.includes(DEVICE_GRANT)) {
1557
+ throw new Error("issuer does not support the device grant");
1558
+ }
1559
+ return body;
1560
+ }
1561
+ async function startDevice(metadata, request, fetcher2, now = Date.now) {
1562
+ const res = await fetcher2(metadata.device_authorization_endpoint, {
1563
+ method: "POST",
1564
+ headers: FORM_HEADERS,
1565
+ body: form({
1566
+ client_id: request.clientId,
1567
+ resource: request.resource,
1568
+ scope: request.scope
1569
+ })
1570
+ });
1571
+ const body = await res.json();
1572
+ if (!res.ok) {
1573
+ throw new Error(
1574
+ String(body["error"] ?? `device request failed (${res.status})`)
1575
+ );
1576
+ }
1577
+ const deviceCode = body["device_code"];
1578
+ const userCode = body["user_code"];
1579
+ const verificationUri = body["verification_uri"];
1580
+ if (typeof deviceCode !== "string" || typeof userCode !== "string" || typeof verificationUri !== "string") {
1581
+ throw new Error("device response is missing a field");
1582
+ }
1583
+ const interval = Number(body["interval"] ?? 5);
1584
+ const expires = Number(body["expires_in"] ?? 600);
1585
+ const complete = body["verification_uri_complete"];
1586
+ validateIssuerUrl(verificationUri, metadata.device_authorization_endpoint);
1587
+ if (typeof complete === "string")
1588
+ validateIssuerUrl(complete, metadata.device_authorization_endpoint);
1589
+ if (!Number.isFinite(interval) || interval < 1 || interval > 300 || !Number.isFinite(expires) || expires < 1 || expires > 3600)
1590
+ throw new Error("invalid device timing");
1591
+ return {
1592
+ deviceCode,
1593
+ userCode,
1594
+ verificationUri,
1595
+ ...typeof complete === "string" ? { verificationUriComplete: complete } : {},
1596
+ intervalMs: (Number.isFinite(interval) ? interval : 5) * 1e3,
1597
+ expiresAt: now() + (Number.isFinite(expires) ? expires : 600) * 1e3
1598
+ };
1599
+ }
1600
+ async function pollOnce(metadata, request, fetcher2, now = Date.now) {
1601
+ const res = await fetcher2(metadata.token_endpoint, {
1602
+ method: "POST",
1603
+ headers: FORM_HEADERS,
1604
+ body: form({
1605
+ grant_type: DEVICE_GRANT,
1606
+ device_code: request.deviceCode,
1607
+ client_id: request.clientId
1608
+ })
1609
+ });
1610
+ const body = await res.json();
1611
+ if (res.ok) {
1612
+ const token = body["access_token"];
1613
+ if (typeof token !== "string")
1614
+ throw new Error("token response has no token");
1615
+ const expiresIn = Number(body["expires_in"]);
1616
+ const refresh2 = body["refresh_token"];
1617
+ const scope = body["scope"];
1618
+ return {
1619
+ kind: "granted",
1620
+ session: {
1621
+ issuer: request.issuer,
1622
+ clientId: request.clientId,
1623
+ accessToken: token,
1624
+ ...typeof refresh2 === "string" ? { refreshToken: refresh2 } : {},
1625
+ ...Number.isFinite(expiresIn) ? { expiresAt: now() + expiresIn * 1e3 } : {},
1626
+ ...typeof scope === "string" ? { scope } : {}
1627
+ }
1628
+ };
1629
+ }
1630
+ switch (body["error"]) {
1631
+ case "authorization_pending":
1632
+ return { kind: "pending" };
1633
+ case "slow_down":
1634
+ return { kind: "slow_down" };
1635
+ case "expired_token":
1636
+ return { kind: "expired" };
1637
+ default:
1638
+ return { kind: "denied" };
1639
+ }
1640
+ }
1641
+ async function refresh(metadata, session, clientId, fetcher2, now = Date.now) {
1642
+ if (typeof session.refreshToken !== "string") return null;
1643
+ validateIssuerUrl(metadata.token_endpoint, session.issuer);
1644
+ let res;
1645
+ try {
1646
+ res = await fetcher2(metadata.token_endpoint, {
1647
+ method: "POST",
1648
+ headers: FORM_HEADERS,
1649
+ body: form({
1650
+ grant_type: "refresh_token",
1651
+ refresh_token: session.refreshToken,
1652
+ client_id: clientId
1653
+ })
1654
+ });
1655
+ } catch {
1656
+ return null;
1657
+ }
1658
+ if (!res.ok) return null;
1659
+ const body = await res.json();
1660
+ const token = body["access_token"];
1661
+ if (typeof token !== "string" || token === "") return null;
1662
+ const expiresIn = Number(body["expires_in"]);
1663
+ const rotated = body["refresh_token"];
1664
+ const scope = body["scope"];
1665
+ return {
1666
+ issuer: session.issuer,
1667
+ clientId,
1668
+ accessToken: token,
1669
+ // A server that rotates the refresh token invalidates the old one, so
1670
+ // keeping the previous value would sign the machine out on next use.
1671
+ refreshToken: typeof rotated === "string" && rotated !== "" ? rotated : session.refreshToken,
1672
+ ...Number.isFinite(expiresIn) ? { expiresAt: now() + expiresIn * 1e3 } : {},
1673
+ ...typeof scope === "string" ? { scope } : session.scope !== void 0 ? { scope: session.scope } : {}
1674
+ };
1675
+ }
1676
+ var sessionPath = (home) => join2(globalDir(home), "session.json");
1677
+ function absent(error) {
1678
+ return error.code === "ENOENT";
1679
+ }
1680
+ function secureDirectory(home, create = false) {
1681
+ const dir = resolve2(globalDir(home));
1682
+ for (let path = dirname2(dir); ; path = dirname2(path)) {
1683
+ const stat2 = lstatSync(path);
1684
+ if (!stat2.isDirectory() || stat2.isSymbolicLink())
1685
+ throw new Error(`unsafe session parent ${path}: not a plain directory`);
1686
+ if ((stat2.mode & 18) !== 0 && (stat2.mode & 512) === 0)
1687
+ throw new Error(
1688
+ `unsafe session parent ${path}: writable by others, so ${dir} is not private`
1689
+ );
1690
+ if (path === dirname2(path)) break;
1691
+ }
1692
+ if (create) mkdirSync2(dir, { mode: 448 });
1693
+ const stat = lstatSync(dir);
1694
+ if (!stat.isDirectory() || stat.isSymbolicLink())
1695
+ throw new Error(`unsafe session directory ${dir}: not a plain directory`);
1696
+ if (stat.uid !== process.getuid?.())
1697
+ throw new Error(`unsafe session directory ${dir}: owned by another user`);
1698
+ if ((stat.mode & 18) !== 0)
1699
+ throw new Error(
1700
+ `unsafe session directory ${dir}: not private; run chmod 700 ${dir}`
1701
+ );
1702
+ }
1703
+ function parseSession(value) {
1704
+ const session = value;
1705
+ if (!session || typeof session.issuer !== "string" || typeof session.accessToken !== "string" || !session.accessToken || session.refreshToken !== void 0 && typeof session.refreshToken !== "string" || session.expiresAt !== void 0 && !Number.isFinite(session.expiresAt))
1706
+ throw new Error("invalid stored session");
1707
+ const issuer = new URL(session.issuer);
1708
+ if (issuer.protocol !== "https:" || issuer.username || issuer.password || issuer.search || issuer.hash || issuer.pathname !== "/")
1709
+ throw new Error("invalid stored issuer");
1710
+ return session;
1711
+ }
1712
+ function readSession(home) {
1713
+ let fd;
1714
+ try {
1715
+ secureDirectory(home);
1716
+ fd = openSync(sessionPath(home), constants.O_RDONLY | constants.O_NOFOLLOW);
1717
+ const stat = fstatSync(fd);
1718
+ if (!stat.isFile() || stat.nlink !== 1 || stat.uid !== process.getuid?.() || (stat.mode & 63) !== 0 || stat.size > 65536)
1719
+ throw new Error("unsafe session file");
1720
+ return parseSession(JSON.parse(readFileSync5(fd, "utf8")));
1721
+ } catch (error) {
1722
+ if (absent(error)) return null;
1723
+ throw error;
1724
+ } finally {
1725
+ if (fd !== void 0) closeSync(fd);
1726
+ }
1727
+ }
1728
+ function writeSession(session, home) {
1729
+ parseSession(session);
1730
+ try {
1731
+ secureDirectory(home);
1732
+ } catch (error) {
1733
+ if (!absent(error)) throw error;
1734
+ secureDirectory(home, true);
1735
+ }
1736
+ const path = sessionPath(home);
1737
+ try {
1738
+ const stat = lstatSync(path);
1739
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.uid !== process.getuid?.())
1740
+ throw new Error("unsafe session destination");
1741
+ } catch (error) {
1742
+ if (!absent(error)) throw error;
1743
+ }
1744
+ const temporary = join2(globalDir(home), `.session-${randomUUID2()}`);
1745
+ const fd = openSync(
1746
+ temporary,
1747
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
1748
+ 384
1749
+ );
1750
+ try {
1751
+ fchmodSync(fd, 384);
1752
+ writeFileSync2(fd, `${JSON.stringify(session)}
1753
+ `);
1754
+ fsyncSync(fd);
1755
+ } finally {
1756
+ closeSync(fd);
1757
+ }
1758
+ try {
1759
+ renameSync(temporary, path);
1760
+ } finally {
1761
+ try {
1762
+ unlinkSync(temporary);
1763
+ } catch {
1764
+ }
1765
+ }
1766
+ }
1767
+ function clearSession(home) {
1768
+ try {
1769
+ secureDirectory(home);
1770
+ unlinkSync(sessionPath(home));
1771
+ } catch (error) {
1772
+ if (!absent(error)) throw error;
1773
+ }
1774
+ }
1775
+ async function revoke(metadata, session, clientId, fetcher2) {
1776
+ if (!metadata.revocation_endpoint) return false;
1777
+ validateIssuerUrl(metadata.revocation_endpoint, session.issuer);
1778
+ let ok = true;
1779
+ const tokens = [session.refreshToken, session.accessToken].filter(
1780
+ (token) => typeof token === "string"
1781
+ );
1782
+ for (const token of tokens) {
1783
+ try {
1784
+ const res = await fetcher2(metadata.revocation_endpoint, {
1785
+ method: "POST",
1786
+ headers: FORM_HEADERS,
1787
+ body: form({ token, client_id: clientId })
1788
+ });
1789
+ if (!res.ok) ok = false;
1790
+ } catch {
1791
+ ok = false;
1792
+ }
1793
+ }
1794
+ return ok;
1795
+ }
1796
+
1797
+ // src/remote/client.ts
1798
+ var DEFAULT_ISSUER = "https://auth.envs.build";
1799
+ var DEFAULT_CLIENT = "https://auth.envs.build/oauth-client.json";
1800
+ var DEFAULT_RESOURCE = "https://api.envs.build";
1801
+ var DEFAULT_SCOPE = "envs:catalog:read envs:catalog:write envs:team:manage";
1802
+ var setting = (env, name, fallback) => env[name] ?? fallback;
1803
+ var fetcher = (url, init) => fetch(url, {
1804
+ ...init,
1805
+ redirect: "error",
1806
+ signal: AbortSignal.timeout(15e3)
1807
+ });
1808
+ var RemoteError = class extends Error {
1809
+ constructor(message, detail, status) {
1810
+ super(message);
1811
+ this.detail = detail;
1812
+ this.status = status;
1813
+ this.name = "RemoteError";
1814
+ }
1815
+ detail;
1816
+ status;
1817
+ };
1818
+ var RENEW_BEFORE_MS = 6e4;
1819
+ function sessionState(env) {
1820
+ try {
1821
+ return { session: readSession(env["HOME"]) };
1822
+ } catch (error) {
1823
+ return { session: null, unreadable: error.message };
1824
+ }
1825
+ }
1826
+ async function access(env) {
1827
+ const state = sessionState(env);
1828
+ if (state.unreadable !== void 0) {
1829
+ throw new RemoteError("cannot read the sign-in here", state.unreadable);
1830
+ }
1831
+ const session = state.session;
1832
+ if (!session) {
1833
+ throw new RemoteError("not signed in", "run envs login");
1834
+ }
1835
+ const resource = setting(env, "ENVS_RESOURCE", DEFAULT_RESOURCE).replace(
1836
+ /\/+$/,
1837
+ ""
1838
+ );
1839
+ const stale = session.expiresAt !== void 0 && session.expiresAt - Date.now() < RENEW_BEFORE_MS;
1840
+ if (!stale) return { token: session.accessToken, resource };
1841
+ const clientId = session.clientId ?? setting(env, "ENVS_OAUTH_CLIENT_ID", DEFAULT_CLIENT);
1842
+ try {
1843
+ const metadata = await discover(session.issuer, fetcher);
1844
+ const renewed = await refresh(metadata, session, clientId, fetcher);
1845
+ if (renewed) {
1846
+ writeSession(renewed, env["HOME"]);
1847
+ return { token: renewed.accessToken, resource };
1848
+ }
1849
+ } catch {
1850
+ }
1851
+ return { token: session.accessToken, resource };
1852
+ }
1853
+ async function refusal(response) {
1854
+ const text = await response.text();
1855
+ try {
1856
+ const body = JSON.parse(text);
1857
+ const message = typeof body.error?.message === "string" ? body.error.message : "";
1858
+ const code = typeof body.error?.code === "string" ? body.error.code : "";
1859
+ const id = typeof body.requestId === "string" ? body.requestId : "";
1860
+ const said = message || code;
1861
+ if (!said) return text;
1862
+ return id ? `${said} (request ${id})` : said;
1863
+ } catch {
1864
+ return text;
1865
+ }
1866
+ }
1867
+ function explain(status, path, body) {
1868
+ const detail = body.slice(0, 200);
1869
+ const said = (message, why) => new RemoteError(message, why, status);
1870
+ switch (status) {
1871
+ case 401:
1872
+ return said("sign-in expired", "run envs login again");
1873
+ case 402:
1874
+ return said(
1875
+ "this destination needs a subscription",
1876
+ "your catalog is untouched and stays readable; see envs.build"
1877
+ );
1878
+ case 403:
1879
+ return said("this sign-in may not do that", detail);
1880
+ case 404:
1881
+ return said("not found on the remote", path);
1882
+ case 409:
1883
+ return said("the remote read different bytes", detail);
1884
+ case 412:
1885
+ return said(
1886
+ "the remote moved since you last read it",
1887
+ "someone else wrote it; read it again before writing"
1888
+ );
1889
+ case 413:
1890
+ return said("snapshot is too large for the remote", detail);
1891
+ case 428:
1892
+ return said("refusing to overwrite blindly", detail);
1893
+ default:
1894
+ return said(`remote refused: ${String(status)}`, detail);
1895
+ }
1896
+ }
1897
+ async function hub(grant, request) {
1898
+ const url = `${grant.resource}${request.path}`;
1899
+ let response;
1900
+ try {
1901
+ response = await fetch(url, {
1902
+ method: request.method,
1903
+ redirect: "error",
1904
+ signal: AbortSignal.timeout(3e4),
1905
+ headers: {
1906
+ authorization: `Bearer ${grant.token}`,
1907
+ accept: request.accept ?? "application/json",
1908
+ ...request.headers ?? {}
1909
+ },
1910
+ ...request.body ? {
1911
+ body: request.body.slice().buffer,
1912
+ duplex: "half"
1913
+ } : {}
1914
+ });
1915
+ } catch (error) {
1916
+ throw new RemoteError(
1917
+ "could not reach the remote",
1918
+ error instanceof Error ? error.message : void 0
1919
+ );
1920
+ }
1921
+ if (!response.ok) {
1922
+ throw explain(response.status, request.path, await refusal(response));
1923
+ }
1924
+ return response;
1925
+ }
1926
+ async function account(grant) {
1927
+ const body = await (await hub(grant, { method: "GET", path: "/v1/me" })).json();
1928
+ const subscription = body["subscription"];
1929
+ return {
1930
+ userId: String(body["userId"] ?? ""),
1931
+ subscription: subscription === "active" || subscription === "inactive" ? subscription : "unavailable",
1932
+ catalogs: Array.isArray(body["catalogs"]) ? body["catalogs"] : [],
1933
+ members: Array.isArray(body["members"]) ? body["members"] : [],
1934
+ teams: Array.isArray(body["teams"]) ? body["teams"] : [],
1935
+ teamManagement: body["capabilities"]?.teamManagement === true
1936
+ };
1937
+ }
1938
+
1939
+ // src/backup/remote.ts
1940
+ var REMOTE_ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
1941
+ var scopeOf = (env) => {
1942
+ const raw = env["ENVS_REMOTE_SCOPE"] ?? "";
1943
+ return raw === "" ? "" : `${raw.replace(/[^A-Za-z0-9._-]/g, "-")}-`;
1944
+ };
1945
+ function remoteId(env, name) {
1946
+ const id = `${scopeOf(env)}${name}`.toLowerCase();
1947
+ if (!REMOTE_ID.test(id)) {
1948
+ throw new BackupError(
1949
+ `"${name}" cannot be named on the remote; it must be letters, digits, dot, dash or underscore`
1950
+ );
1951
+ }
1952
+ return id;
1953
+ }
1954
+ async function currentVersion(grant, owner, id) {
1955
+ try {
1956
+ const response = await hub(grant, {
1957
+ method: "GET",
1958
+ path: `/v1/catalogs/${owner}/${id}/head`
1959
+ });
1960
+ const body = await response.json();
1961
+ return typeof body.version === "string" ? body.version : null;
1962
+ } catch (error) {
1963
+ if (error instanceof RemoteError && error.status === 404) return null;
1964
+ throw error;
1965
+ }
1966
+ }
1967
+ async function grantFor(env) {
1968
+ const grant = await access(env);
1969
+ const who = await account(grant);
1970
+ if (who.userId === "") {
1971
+ throw new BackupError("the remote did not say who this sign-in belongs to");
1972
+ }
1973
+ return { grant, who };
1974
+ }
1975
+ var remoteProvider = {
1976
+ name: "envs",
1977
+ describe: (env) => {
1978
+ const state = sessionState(env);
1979
+ if (state.unreadable !== void 0) return state.unreadable;
1980
+ return state.session ? `writes to ${env["ENVS_RESOURCE"] ?? "the hosted catalog"}` : "needs envs login";
1981
+ },
1982
+ // Declared, not discovered by failing: without a sign-in there is nothing to
1983
+ // try, and the file provider stays the default.
1984
+ eligible: (env) => sessionState(env).session !== null,
1985
+ async put(env, name, bytes) {
1986
+ const { grant, who } = await grantFor(env);
1987
+ const id = remoteId(env, name);
1988
+ const version = await currentVersion(grant, who.userId, id);
1989
+ await hub(grant, {
1990
+ method: "PUT",
1991
+ path: `/v1/catalogs/${who.userId}/${id}`,
1992
+ body: bytes,
1993
+ headers: {
1994
+ "content-type": "application/octet-stream",
1995
+ "x-envs-digest": await sha256Hex(bytes),
1996
+ // Says which of the two writes this is, so a concurrent write is
1997
+ // refused rather than silently overwriting someone else's snapshot.
1998
+ ...version === null ? { "if-none-match": "*" } : { "if-match": `"${version}"` }
1999
+ }
2000
+ });
2001
+ },
2002
+ async get(env, name) {
2003
+ const { grant, who } = await grantFor(env);
2004
+ const response = await hub(grant, {
2005
+ method: "GET",
2006
+ path: `/v1/catalogs/${who.userId}/${remoteId(env, name)}`,
2007
+ accept: "application/octet-stream"
2008
+ });
2009
+ return new Uint8Array(await response.arrayBuffer());
2010
+ },
2011
+ async list(env) {
2012
+ const { who } = await grantFor(env);
2013
+ const prefix = scopeOf(env);
2014
+ return who.catalogs.filter(
2015
+ (entry) => entry.id.startsWith(prefix) && entry.id.endsWith(".envsnap")
2016
+ ).map(
2017
+ (entry) => ({
2018
+ name: entry.id,
2019
+ size: entry.bytes,
2020
+ ...entry.updatedAt ? { modifiedAt: entry.updatedAt } : {}
2021
+ })
2022
+ ).sort((a, b) => a.name.localeCompare(b.name));
2023
+ }
2024
+ };
2025
+ register(remoteProvider);
2026
+
2027
+ // src/backup/snapshot.ts
2028
+ import { createHash as createHash2 } from "crypto";
2029
+ var SNAPSHOT_MAGIC = "ENVSNAP1";
2030
+ var encoder2 = new TextEncoder();
2031
+ var decoder = new TextDecoder();
2032
+ var b64 = (bytes) => Buffer.from(bytes).toString("base64");
2033
+ var unb64 = (text) => new Uint8Array(Buffer.from(text, "base64"));
2034
+ function snapshotContext(catalogId, createdAt) {
2035
+ return encoder2.encode(`envs:snapshot:v1:${catalogId}:${createdAt}`);
2036
+ }
2037
+ function pack(input) {
2038
+ const header = {
2039
+ magic: SNAPSHOT_MAGIC,
2040
+ catalogId: input.catalogId,
2041
+ schemaVersion: input.schemaVersion,
2042
+ createdAt: input.createdAt,
2043
+ // Of the plaintext, so a restore can say the bytes are the ones sealed.
2044
+ sha256: createHash2("sha256").update(input.catalogBytes).digest("hex"),
2045
+ wraps: input.wraps.map((wrap) => ({
2046
+ wrapId: wrap.wrapId,
2047
+ method: wrap.method,
2048
+ salt: b64(wrap.salt),
2049
+ n: wrap.n,
2050
+ r: wrap.r,
2051
+ p: wrap.p,
2052
+ envelope: b64(wrap.envelope)
2053
+ }))
2054
+ };
2055
+ const headerBytes = encoder2.encode(`${JSON.stringify(header)}
2056
+ `);
2057
+ const body = seal({
2058
+ kek: input.dek,
2059
+ kekVersion: 1,
2060
+ plaintext: input.catalogBytes,
2061
+ context: snapshotContext(input.catalogId, input.createdAt)
2062
+ });
2063
+ const out = new Uint8Array(headerBytes.length + body.length);
2064
+ out.set(headerBytes, 0);
2065
+ out.set(body, headerBytes.length);
2066
+ return out;
2067
+ }
2068
+ function readHeader(blob) {
2069
+ const newline = blob.indexOf(10);
2070
+ if (newline === -1) throw new BackupError("not a snapshot: no header");
2071
+ let header;
2072
+ try {
2073
+ header = JSON.parse(decoder.decode(blob.subarray(0, newline)));
2074
+ } catch {
2075
+ throw new BackupError("not a snapshot: the header is not JSON");
2076
+ }
2077
+ if (header.magic !== SNAPSHOT_MAGIC) {
2078
+ throw new BackupError(`not a snapshot: magic is ${String(header.magic)}`);
2079
+ }
2080
+ return { header, body: blob.subarray(newline + 1) };
2081
+ }
2082
+ function unpack(blob, unlock) {
2083
+ const { header, body } = readHeader(blob);
2084
+ const wraps = header.wraps.map((wrap) => ({
2085
+ wrapId: wrap.wrapId,
2086
+ method: wrap.method === "kek" ? "kek" : "recovery",
2087
+ salt: unb64(wrap.salt),
2088
+ n: wrap.n,
2089
+ r: wrap.r,
2090
+ p: wrap.p,
2091
+ envelope: unb64(wrap.envelope)
2092
+ }));
2093
+ const dek = unlockDek(wraps, unlock);
2094
+ const catalogBytes = open({
2095
+ kek: dek,
2096
+ blob: body,
2097
+ context: snapshotContext(header.catalogId, header.createdAt)
2098
+ });
2099
+ const digest = createHash2("sha256").update(catalogBytes).digest("hex");
2100
+ if (digest !== header.sha256) {
2101
+ throw new BackupError("snapshot contents do not match the header digest");
2102
+ }
2103
+ return { header, catalogBytes };
2104
+ }
2105
+ function snapshotName(catalogId, createdAt) {
2106
+ const stamp = createdAt.replace(/[:.]/g, "-");
2107
+ return `${catalogId.slice(0, 8)}-${stamp}.envsnap`;
2108
+ }
2109
+
2110
+ // src/catalog/dir.ts
2111
+ import { chmodSync, existsSync as existsSync7, mkdirSync as mkdirSync3, statSync as statSync2 } from "fs";
2112
+ function ensureCatalogDir(dir) {
2113
+ const existed = existsSync7(dir);
2114
+ mkdirSync3(dir, { recursive: true, mode: 448 });
2115
+ try {
2116
+ if ((statSync2(dir).mode & 63) === 0) return existed ? "ok" : "created";
2117
+ chmodSync(dir, 448);
2118
+ return existed ? "narrowed" : "created";
2119
+ } catch {
2120
+ return "unsupported";
2121
+ }
2122
+ }
2123
+
2124
+ // src/commands/backup.ts
2125
+ function reportFailure(ui, error) {
2126
+ const detail = error instanceof RemoteError ? error.detail : void 0;
2127
+ const message = error instanceof Error ? error.message : String(error);
2128
+ if (detail) ui.error(message, detail);
2129
+ else ui.error(message);
2130
+ }
2131
+ function destinationEnv(env, to) {
2132
+ if (to === void 0) return env;
2133
+ const remote = /^envs:\/\/(.*)$/.exec(to);
2134
+ if (remote) {
2135
+ const scope = remote[1].replace(/^\/+|\/+$/g, "");
2136
+ return {
2137
+ ...env,
2138
+ ENVS_BACKUP_PROVIDER: "envs",
2139
+ ...scope === "" ? {} : { ENVS_REMOTE_SCOPE: scope }
2140
+ };
2141
+ }
2142
+ const s3 = /^s3:\/\/([^/]+)(?:\/(.*))?$/.exec(to);
2143
+ if (s3) {
2144
+ const prefix = (s3[2] ?? "").replace(/^\/+|\/+$/g, "");
2145
+ return {
2146
+ ...env,
2147
+ ENVS_BACKUP_PROVIDER: "s3",
2148
+ ENVS_BACKUP_BUCKET: s3[1],
2149
+ ...prefix === "" ? {} : { ENVS_BACKUP_PREFIX: prefix }
2150
+ };
2151
+ }
2152
+ return { ...env, ENVS_BACKUP_DIR: to, ENVS_BACKUP_PROVIDER: "file" };
2153
+ }
2154
+ var DESTINATION_OPTIONS = [
2155
+ {
2156
+ name: "to",
2157
+ placeholder: "<dest>",
2158
+ describe: "a directory, s3://bucket/prefix, or envs:// for your account"
2159
+ },
2160
+ {
2161
+ name: "provider",
2162
+ placeholder: "<name>",
2163
+ describe: "file, s3 or envs; otherwise the first that is configured"
2164
+ },
2165
+ {
2166
+ name: "recovery-code",
2167
+ placeholder: "<code|->",
2168
+ describe: "unlock with a recovery code; - reads it from stdin"
2169
+ }
2170
+ ];
2171
+ var backupCommand = {
2172
+ name: "backup",
2173
+ describe: "write an encrypted snapshot of the catalog",
2174
+ usage: "envs backup [--to <dest>] [--provider file|s3|envs]",
2175
+ group: "backup",
2176
+ options: [...DESTINATION_OPTIONS],
2177
+ async run({ ui, args, env, cwd }) {
2178
+ const located = locateCatalogs({ cwd, env });
2179
+ if (!existsSync8(located.project)) {
2180
+ ui.error("no catalog here", located.project);
2181
+ ui.info("run envs init first");
2182
+ return 1;
2183
+ }
2184
+ const unlock = resolveUnlock(one(args, "recovery-code"), env);
2185
+ if (typeof unlock === "string") {
2186
+ ui.error(unlock);
2187
+ return 2;
2188
+ }
2189
+ try {
2190
+ const destination = destinationEnv(env, one(args, "to"));
2191
+ const provider = resolveProvider(destination, one(args, "provider"));
2192
+ const db = openDatabaseSync(located.project);
2193
+ let blob;
2194
+ let name;
2195
+ try {
2196
+ db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
2197
+ const meta = readMeta(db);
2198
+ if (meta === void 0) throw new BackupError("catalog has no schema");
2199
+ const wraps = readWraps(db);
2200
+ const dek = unlockDek(wraps, unlock);
2201
+ const createdAt = (/* @__PURE__ */ new Date()).toISOString();
2202
+ blob = pack({
2203
+ catalogBytes: new Uint8Array(readFileSync6(located.project)),
2204
+ catalogId: meta.catalogId,
2205
+ schemaVersion: meta.version,
2206
+ wraps,
2207
+ dek,
2208
+ createdAt
2209
+ });
2210
+ name = snapshotName(meta.catalogId, createdAt);
2211
+ audit(db, "backup.write", name, provider.name);
2212
+ } finally {
2213
+ db.close();
2214
+ }
2215
+ await provider.put(destination, name, blob);
2216
+ ui.success(`wrote ${name}`, `${blob.length} bytes`);
2217
+ ui.info(provider.describe(destination));
2218
+ return 0;
2219
+ } catch (error) {
2220
+ reportFailure(ui, error);
2221
+ return 1;
2222
+ }
2223
+ }
2224
+ };
2225
+ var restoreCommand = {
2226
+ name: "restore",
2227
+ describe: "put a snapshot back, keeping the catalog it replaces",
2228
+ usage: "envs restore <name> [--to <dest>] [--force] | envs restore --list",
2229
+ group: "backup",
2230
+ options: [
2231
+ ...DESTINATION_OPTIONS,
2232
+ {
2233
+ name: "list",
2234
+ boolean: true,
2235
+ describe: "list what the destination holds"
2236
+ },
2237
+ {
2238
+ name: "force",
2239
+ boolean: true,
2240
+ describe: "replace an existing catalog (the old one is kept beside it)"
2241
+ }
2242
+ ],
2243
+ async run({ ui, args, env, cwd }) {
2244
+ const destination = destinationEnv(env, one(args, "to"));
2245
+ let provider;
2246
+ try {
2247
+ provider = resolveProvider(destination, one(args, "provider"));
2248
+ } catch (error) {
2249
+ reportFailure(ui, error);
2250
+ ui.table(
2251
+ providers().map((candidate) => [
2252
+ candidate.name,
2253
+ candidate.eligible(destination) ? candidate.describe(destination) : `not configured \u2014 ${candidate.describe(destination)}`
2254
+ ])
2255
+ );
2256
+ return 2;
2257
+ }
2258
+ if (args.flags.has("list")) {
2259
+ try {
2260
+ const found = await provider.list(destination);
2261
+ if (found.length === 0) {
2262
+ ui.info("nothing there", provider.describe(destination));
2263
+ return 0;
2264
+ }
2265
+ ui.heading(provider.describe(destination));
2266
+ ui.table(
2267
+ found.map((snapshot) => [
2268
+ snapshot.name,
2269
+ `${snapshot.size} bytes ${snapshot.modifiedAt ?? ""}`.trimEnd()
2270
+ ])
2271
+ );
2272
+ return 0;
2273
+ } catch (error) {
2274
+ reportFailure(ui, error);
2275
+ return 1;
2276
+ }
2277
+ }
2278
+ const name = args.positional[0];
2279
+ if (name === void 0 || args.positional.length > 1) {
2280
+ ui.error("give one snapshot name", "envs restore --list shows them");
2281
+ return 2;
2282
+ }
2283
+ const unlock = resolveUnlock(one(args, "recovery-code"), env);
2284
+ if (typeof unlock === "string") {
2285
+ ui.error(unlock, "a recovery code is enough here");
2286
+ return 2;
2287
+ }
2288
+ const located = locateCatalogs({ cwd, env });
2289
+ if (existsSync8(located.project) && !args.flags.has("force")) {
2290
+ ui.error("a catalog is already here", located.project);
2291
+ ui.info("pass --force", "the one it replaces is kept beside it");
2292
+ return 1;
2293
+ }
2294
+ try {
2295
+ const blob = await provider.get(destination, name);
2296
+ const { header, catalogBytes } = unpack(blob, unlock);
2297
+ if (ensureCatalogDir(dirname3(located.project)) === "narrowed") {
2298
+ ui.warn(
2299
+ "narrowed the catalog directory to 700",
2300
+ "it was writable by others, who could have replaced the catalog"
2301
+ );
2302
+ }
2303
+ if (existsSync8(located.project)) {
2304
+ const kept = `${located.project}.replaced-${Date.now()}`;
2305
+ copyFileSync(located.project, kept);
2306
+ ui.info("kept the catalog it replaced", kept);
2307
+ }
2308
+ const incoming = `${located.project}.incoming`;
2309
+ writeFileSync3(incoming, catalogBytes, { mode: 384 });
2310
+ renameSync2(incoming, located.project);
2311
+ for (const suffix of ["-wal", "-shm"]) {
2312
+ rmSync(`${located.project}${suffix}`, { force: true });
2313
+ }
2314
+ ui.success(`restored ${name}`, `catalog ${header.catalogId.slice(0, 8)}`);
2315
+ ui.info("taken at", header.createdAt);
2316
+ ui.info("schema", String(header.schemaVersion));
2317
+ return 0;
2318
+ } catch (error) {
2319
+ reportFailure(ui, error);
2320
+ return 1;
2321
+ }
2322
+ }
2323
+ };
2324
+
2325
+ // src/commands/build.ts
2326
+ import { existsSync as existsSync9, mkdirSync as mkdirSync5, writeFileSync as writeFileSync4 } from "fs";
2327
+ import { dirname as dirname4, resolve as resolve3 } from "path";
2328
+ var DEFAULT_SENSITIVITY = "medium";
2329
+ var LEVELS = /* @__PURE__ */ new Set([
2330
+ "low",
2331
+ "medium",
2332
+ "high",
2333
+ "critical"
2334
+ ]);
2335
+ function isSensitivity(value) {
2336
+ return LEVELS.has(value);
2337
+ }
2338
+ function readSensitivity(db, dek, key) {
2339
+ const row = db.prepare(
2340
+ "SELECT sensitivity FROM keys WHERE key_hash = $hash"
2341
+ ).get({ hash: hashKeyName(dek, key) });
2342
+ const value = row?.sensitivity ?? null;
2343
+ return value !== null && isSensitivity(value) ? value : DEFAULT_SENSITIVITY;
2344
+ }
2345
+ var PUBLIC_PREFIXES = ["PUBLIC_", "VITE_", "NEXT_PUBLIC_"];
2346
+ function isPublicByName(key) {
2347
+ return PUBLIC_PREFIXES.some((prefix) => key.startsWith(prefix));
2348
+ }
2349
+ function asIdentifier(key) {
2350
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
2351
+ }
2352
+ function renderModule(chosen, generatedAt) {
2353
+ const lines = [
2354
+ "// Generated by envs. Do not edit.",
2355
+ `// ${generatedAt}`,
2356
+ "//",
2357
+ "// Every value here is baked into whatever imports this file. Only keys",
2358
+ "// classified low reach it; see envs build.",
2359
+ ""
2360
+ ];
2361
+ for (const entry of chosen) {
2362
+ lines.push(`/** from ${entry.alias} (${entry.path}) */`);
2363
+ lines.push(
2364
+ `export const ${asIdentifier(entry.key)} = ${JSON.stringify(entry.value)};`
2365
+ );
2366
+ }
2367
+ lines.push("");
2368
+ lines.push("export const ENV = {");
2369
+ for (const entry of chosen) {
2370
+ lines.push(` ${asIdentifier(entry.key)},`);
2371
+ }
2372
+ lines.push("} as const;");
2373
+ lines.push("");
2374
+ lines.push("export type EnvKey = keyof typeof ENV;");
2375
+ lines.push("");
2376
+ return lines.join("\n");
2377
+ }
2378
+ var buildCommand = {
2379
+ name: "build",
2380
+ describe: "write chosen values into a module, for targets that cannot read files",
2381
+ usage: "envs build --out <path> [--expose K1,K2]",
2382
+ group: "publish",
2383
+ options: [
2384
+ {
2385
+ name: "out",
2386
+ placeholder: "<path>",
2387
+ describe: "where to write the module"
2388
+ },
2389
+ {
2390
+ name: "expose",
2391
+ placeholder: "<K1,K2>",
2392
+ describe: "keys to include; repeatable",
2393
+ repeat: true
2394
+ },
2395
+ {
2396
+ name: "public",
2397
+ boolean: true,
2398
+ describe: `also include keys named ${PUBLIC_PREFIXES.join(", ")}`
2399
+ },
2400
+ {
2401
+ name: "allow-sensitive",
2402
+ boolean: true,
2403
+ describe: "bake a key that is not classified low (it cannot be recalled)"
2404
+ },
2405
+ {
2406
+ name: "recovery-code",
2407
+ placeholder: "<code|->",
2408
+ describe: "unlock with a recovery code; - reads it from stdin"
2409
+ }
2410
+ ],
2411
+ run({ ui, args, env, cwd }) {
2412
+ const out = one(args, "out");
2413
+ if (out === void 0) {
2414
+ ui.error("no output path", "envs build --out src/generated/env.ts");
2415
+ return 2;
2416
+ }
2417
+ const located = locateCatalogs({ cwd, env });
2418
+ if (!existsSync9(located.project)) {
2419
+ ui.error("no catalog here", located.project);
2420
+ ui.info("run envs init first");
2421
+ return 1;
2422
+ }
2423
+ const unlock = resolveUnlock(one(args, "recovery-code"), env);
2424
+ if (typeof unlock === "string") {
2425
+ ui.error(unlock);
2426
+ return 2;
2427
+ }
2428
+ const asked = new Set(
2429
+ many(args, "expose").flatMap((value) => value.split(",")).map((key) => key.trim()).filter((key) => key !== "")
2430
+ );
2431
+ const wantPublic = args.flags.has("public");
2432
+ if (asked.size === 0 && !wantPublic) {
2433
+ ui.error(
2434
+ "nothing selected, so nothing was written",
2435
+ "--expose K1,K2 or --public"
2436
+ );
2437
+ return 2;
2438
+ }
2439
+ const db = openDatabaseSync(located.project, { readOnly: true });
2440
+ try {
2441
+ const dek = unlockDek(readWraps(db), unlock);
2442
+ const entries = readEntries(db, { unlock });
2443
+ const seen = /* @__PURE__ */ new Map();
2444
+ for (const entry of entries) {
2445
+ if (!seen.has(entry.key)) seen.set(entry.key, entry);
2446
+ }
2447
+ const wanted = [...seen.values()].filter(
2448
+ (entry) => asked.has(entry.key) || wantPublic && isPublicByName(entry.key)
2449
+ );
2450
+ const missing = [...asked].filter((key) => !seen.has(key));
2451
+ if (missing.length > 0) {
2452
+ ui.error(`no value for ${missing.join(", ")}`, "nothing was written");
2453
+ return 1;
2454
+ }
2455
+ const refused = [];
2456
+ const chosen = [];
2457
+ for (const entry of wanted) {
2458
+ const level = readSensitivity(db, dek, entry.key);
2459
+ if (level === "low" || args.flags.has("allow-sensitive")) {
2460
+ chosen.push(entry);
2461
+ continue;
2462
+ }
2463
+ refused.push(`${entry.key} (${level})`);
2464
+ }
2465
+ if (refused.length > 0) {
2466
+ ui.error("these are not classified low", refused.join(", "));
2467
+ ui.info(
2468
+ "envs set <KEY> <value> --sensitivity low",
2469
+ "or --allow-sensitive to bake them anyway"
2470
+ );
2471
+ return 1;
2472
+ }
2473
+ if (chosen.length === 0) {
2474
+ ui.error("nothing to write", "no selected key survived the check");
2475
+ return 1;
2476
+ }
2477
+ const path = resolve3(cwd, out);
2478
+ mkdirSync5(dirname4(path), { recursive: true });
2479
+ writeFileSync4(path, renderModule(chosen, (/* @__PURE__ */ new Date()).toISOString()));
2480
+ ui.success(`wrote ${out}`, `${chosen.length} values`);
2481
+ ui.warn(
2482
+ "these are now in whatever imports that file",
2483
+ chosen.map((entry) => entry.key).join(", ")
2484
+ );
2485
+ return 0;
2486
+ } catch (error) {
2487
+ ui.error(error.message);
2488
+ return 1;
2489
+ } finally {
2490
+ db.close();
2491
+ }
2492
+ }
2493
+ };
2494
+
2495
+ // src/commands/doctor.ts
2496
+ import { existsSync as existsSync10, readFileSync as readFileSync7 } from "fs";
2497
+ import { join as join3, relative } from "path";
2498
+ function read(path, layer, unlock) {
2499
+ if (!existsSync10(path)) return [];
2500
+ const db = openDatabaseSync(path, { readOnly: true });
2501
+ try {
2502
+ return readEntries(db, { unlock }).map((entry) => ({ ...entry, layer }));
2503
+ } catch {
2504
+ return [];
2505
+ } finally {
2506
+ db.close();
2507
+ }
2508
+ }
2509
+ function conflicts(entries) {
2510
+ const byKey = /* @__PURE__ */ new Map();
2511
+ for (const entry of entries) {
2512
+ byKey.set(entry.key, [...byKey.get(entry.key) ?? [], entry]);
2513
+ }
2514
+ const findings = [];
2515
+ for (const [key, group] of byKey) {
2516
+ if (group.length < 2) continue;
2517
+ const same = group.every((entry) => entry.value === group[0].value);
2518
+ const where = group.map((e) => `${e.layer}:${e.alias}`).join(" and ");
2519
+ findings.push({
2520
+ level: same ? "warn" : "error",
2521
+ what: key,
2522
+ detail: same ? `declared twice, same value \u2014 ${where}` : `values differ \u2014 ${where}`
2523
+ });
2524
+ }
2525
+ return findings;
2526
+ }
2527
+ function globalOnly(entries) {
2528
+ const inProject = new Set(
2529
+ entries.filter((e) => e.layer === "project").map((e) => e.key)
2530
+ );
2531
+ return entries.filter((entry) => entry.layer === "global" && !inProject.has(entry.key)).map((entry) => ({
2532
+ level: "warn",
2533
+ what: entry.key,
2534
+ detail: `only in the machine-wide layer \u2014 a teammate will not have it`
2535
+ }));
2536
+ }
2537
+ function ghosts(entries) {
2538
+ const seen = /* @__PURE__ */ new Set();
2539
+ const findings = [];
2540
+ for (const entry of entries) {
2541
+ if (seen.has(entry.path) || entry.path.startsWith("envs:")) continue;
2542
+ seen.add(entry.path);
2543
+ if (!existsSync10(entry.path)) {
2544
+ findings.push({
2545
+ level: "warn",
2546
+ what: entry.alias,
2547
+ detail: `the file it was loaded from is gone \u2014 ${entry.path}`
2548
+ });
2549
+ }
2550
+ }
2551
+ return findings;
2552
+ }
2553
+ function catalogIgnored(catalogPath, root) {
2554
+ if (root === void 0) return [];
2555
+ const rel = relative(root, catalogPath);
2556
+ if (rel.startsWith("..")) return [];
2557
+ const ignoreFile = join3(root, ".gitignore");
2558
+ const lines = existsSync10(ignoreFile) ? readFileSync7(ignoreFile, "utf8").split(/\r?\n/).map((line) => line.trim()) : [];
2559
+ if (lines.includes(".envs/") || lines.includes(".envs")) return [];
2560
+ return [
2561
+ {
2562
+ level: "error",
2563
+ what: "the catalog is not ignored by git",
2564
+ detail: `add .envs/ to .gitignore \u2014 ${rel}`
2565
+ }
2566
+ ];
2567
+ }
2568
+ function readTemplates(path, unlock) {
2569
+ const setAt = /* @__PURE__ */ new Map();
2570
+ if (!existsSync10(path)) return { refs: [], setAt };
2571
+ const db = openDatabaseSync(path, { readOnly: true });
2572
+ try {
2573
+ const refs = appliedTemplates(db);
2574
+ if (refs.length === 0) return { refs, setAt };
2575
+ const dek = unlockDek(readWraps(db), unlock);
2576
+ const current = db.prepare(
2577
+ "SELECT revision_id FROM pointer WHERE id = 1"
2578
+ ).get({});
2579
+ if (current) {
2580
+ const at = db.prepare(
2581
+ "SELECT created_at FROM items WHERE key_hash = $hash AND revision_id = $revision"
2582
+ );
2583
+ for (const ref of refs) {
2584
+ for (const key of Object.keys(ref.template.keys)) {
2585
+ const row = at.get({
2586
+ hash: hashKeyName(dek, key),
2587
+ revision: current.revision_id
2588
+ });
2589
+ if (row) setAt.set(key, row.created_at);
2590
+ }
2591
+ }
2592
+ }
2593
+ return { refs, setAt };
2594
+ } catch {
2595
+ return { refs: [], setAt };
2596
+ } finally {
2597
+ db.close();
2598
+ }
2599
+ }
2600
+ function templateFindings(refs, entries, setAt, now) {
2601
+ if (refs.length === 0) return [];
2602
+ const findings = [];
2603
+ const value = new Map(entries.map((entry) => [entry.key, entry.value]));
2604
+ const declared = /* @__PURE__ */ new Set();
2605
+ for (const ref of refs) {
2606
+ for (const [key, spec] of Object.entries(ref.template.keys)) {
2607
+ declared.add(key);
2608
+ const held = value.get(key);
2609
+ if (held === void 0) {
2610
+ if (spec.required) {
2611
+ findings.push({
2612
+ level: "error",
2613
+ what: key,
2614
+ detail: `required by ${ref.name} and not set`
2615
+ });
2616
+ }
2617
+ continue;
2618
+ }
2619
+ if (spec.pattern !== void 0) {
2620
+ if (!new RegExp(spec.pattern, "u").test(held)) {
2621
+ findings.push({
2622
+ level: "error",
2623
+ what: key,
2624
+ // The value is not printed, here or anywhere else.
2625
+ detail: `does not match the shape ${ref.name} declares`
2626
+ });
2627
+ }
2628
+ }
2629
+ if (spec.rotateDays !== void 0) {
2630
+ const at = setAt(key);
2631
+ const age = at === void 0 ? void 0 : now - Date.parse(at);
2632
+ if (age !== void 0 && age > spec.rotateDays * 864e5) {
2633
+ findings.push({
2634
+ level: "warn",
2635
+ what: key,
2636
+ detail: `set ${String(Math.floor(age / 864e5))} days ago; ${ref.name} asks for rotation every ${String(spec.rotateDays)}`
2637
+ });
2638
+ }
2639
+ }
2640
+ }
2641
+ }
2642
+ for (const entry of entries) {
2643
+ if (declared.has(entry.key)) continue;
2644
+ findings.push({
2645
+ level: "warn",
2646
+ what: entry.key,
2647
+ detail: "set here but named by no applied template"
2648
+ });
2649
+ }
2650
+ return findings;
2651
+ }
2652
+ function report(ui, findings) {
2653
+ for (const finding of findings) {
2654
+ if (finding.level === "error") ui.error(finding.what, finding.detail);
2655
+ else ui.warn(finding.what, finding.detail);
2656
+ }
2657
+ }
2658
+ var doctorCommand = {
2659
+ name: "doctor",
2660
+ describe: "say where each value comes from, and what disagrees",
2661
+ usage: "envs doctor [--key <KEY>]",
2662
+ group: "check",
2663
+ options: [
2664
+ {
2665
+ name: "key",
2666
+ placeholder: "<KEY>",
2667
+ describe: "one key's sources and which one wins"
2668
+ },
2669
+ {
2670
+ name: "recovery-code",
2671
+ placeholder: "<code|->",
2672
+ describe: "unlock with a recovery code; - reads it from stdin"
2673
+ }
2674
+ ],
2675
+ run({ ui, args, env, cwd }) {
2676
+ const located = locateCatalogs({ cwd, env });
2677
+ if (!existsSync10(located.project)) {
2678
+ ui.error("no catalog here", located.project);
2679
+ ui.info("run envs init first");
2680
+ return 1;
2681
+ }
2682
+ const unlock = resolveUnlock(one(args, "recovery-code"), env);
2683
+ if (typeof unlock === "string") {
2684
+ ui.error(unlock);
2685
+ return 2;
2686
+ }
2687
+ ui.heading("catalogs");
2688
+ ui.table([
2689
+ ["project", located.project],
2690
+ ["global", located.global ?? "(none \u2014 this is the machine-wide catalog)"]
2691
+ ]);
2692
+ ui.line();
2693
+ const entries = [
2694
+ ...read(located.project, "project", unlock),
2695
+ ...located.global ? read(located.global, "global", unlock) : []
2696
+ ];
2697
+ const wanted = one(args, "key");
2698
+ if (wanted !== void 0) {
2699
+ const group = entries.filter((entry) => entry.key === wanted);
2700
+ if (group.length === 0) {
2701
+ ui.warn(`no source declares ${wanted}`);
2702
+ return 1;
2703
+ }
2704
+ ui.heading(wanted);
2705
+ ui.table(
2706
+ group.map((entry, index) => [
2707
+ `${index === 0 ? "*" : " "} ${entry.layer}:${entry.alias}`,
2708
+ entry.path
2709
+ ])
2710
+ );
2711
+ ui.info("* wins", "project beats global; earlier source beats later");
2712
+ return 0;
2713
+ }
2714
+ const applied = readTemplates(located.project, unlock);
2715
+ const findings = [
2716
+ ...catalogIgnored(located.project, located.projectRoot),
2717
+ ...conflicts(entries),
2718
+ ...globalOnly(entries),
2719
+ ...ghosts(entries),
2720
+ ...templateFindings(
2721
+ applied.refs,
2722
+ entries,
2723
+ (key) => applied.setAt.get(key),
2724
+ Date.now()
2725
+ )
2726
+ ];
2727
+ ui.heading("values");
2728
+ ui.table([
2729
+ ["keys", String(new Set(entries.map((entry) => entry.key)).size)],
2730
+ ["sources", String(new Set(entries.map((entry) => entry.alias)).size)]
2731
+ ]);
2732
+ ui.line();
2733
+ if (findings.length === 0) {
2734
+ ui.success("nothing to report");
2735
+ return 0;
2736
+ }
2737
+ ui.heading("findings");
2738
+ report(ui, findings);
2739
+ return findings.some((finding) => finding.level === "error") ? 1 : 0;
2740
+ }
2741
+ };
2742
+
2743
+ // src/commands/get.ts
2744
+ import { existsSync as existsSync11 } from "fs";
2745
+ var FORMATS2 = /* @__PURE__ */ new Set([
2746
+ "plain",
2747
+ "shell",
2748
+ "eval",
2749
+ "json"
2750
+ ]);
2751
+ function render(format, key, value) {
2752
+ if (format === "json")
2753
+ return `${JSON.stringify({ [key]: value }, null, 2)}
2754
+ `;
2755
+ if (format === "shell") return `${key}=${JSON.stringify(value)}
2756
+ `;
2757
+ if (format === "eval") return `export ${key}=${JSON.stringify(value)}
2758
+ `;
2759
+ return `${value}
2760
+ `;
2761
+ }
2762
+ var getCommand = {
2763
+ name: "get",
2764
+ describe: "print one value",
2765
+ usage: "envs get <KEY> [--format shell|eval|json]",
2766
+ group: "values",
2767
+ options: [
2768
+ {
2769
+ name: "source",
2770
+ placeholder: "<alias>",
2771
+ describe: "read the key from this source rather than the winner"
2772
+ },
2773
+ {
2774
+ name: "format",
2775
+ placeholder: "<fmt>",
2776
+ describe: "plain (default), shell, eval or json"
2777
+ },
2778
+ {
2779
+ name: "include-global",
2780
+ boolean: true,
2781
+ describe: "also look in the machine-wide layer"
2782
+ },
2783
+ {
2784
+ name: "recovery-code",
2785
+ placeholder: "<code|->",
2786
+ describe: "unlock with a recovery code; - reads it from stdin"
2787
+ }
2788
+ ],
2789
+ run({ ui, args, env, cwd }) {
2790
+ if (args.positional.length !== 1) {
2791
+ ui.error(
2792
+ "give exactly one key",
2793
+ "envs ls --keys lists them; envs export prints values"
2794
+ );
2795
+ return 2;
2796
+ }
2797
+ const key = args.positional[0];
2798
+ const located = locateCatalogs({ cwd, env });
2799
+ if (!existsSync11(located.project)) {
2800
+ ui.error("no catalog here", located.project);
2801
+ ui.info("run envs init first");
2802
+ return 1;
2803
+ }
2804
+ const unlock = resolveUnlock(one(args, "recovery-code"), env);
2805
+ if (typeof unlock === "string") {
2806
+ ui.error(unlock);
2807
+ return 2;
2808
+ }
2809
+ const paths = [located.project];
2810
+ if (args.flags.has("include-global") && located.global !== void 0) {
2811
+ paths.push(located.global);
2812
+ }
2813
+ const format = one(args, "format") ?? "plain";
2814
+ if (!FORMATS2.has(format)) {
2815
+ ui.error(`unknown format "${format}"`, "use plain, shell, eval or json");
2816
+ return 2;
2817
+ }
2818
+ const wantedSource = one(args, "source");
2819
+ for (const path of paths) {
2820
+ if (!existsSync11(path)) continue;
2821
+ const db = openDatabaseSync(path, { readOnly: true });
2822
+ try {
2823
+ for (const entry of readEntries(db, { unlock })) {
2824
+ if (entry.key !== key) continue;
2825
+ if (wantedSource !== void 0 && entry.alias !== wantedSource)
2826
+ continue;
2827
+ ui.data(render(format, key, entry.value));
2828
+ return 0;
2829
+ }
2830
+ } catch (error) {
2831
+ ui.error(error.message);
2832
+ return 1;
2833
+ } finally {
2834
+ db.close();
2835
+ }
2836
+ }
2837
+ ui.error(
2838
+ `no value for ${key}`,
2839
+ wantedSource === void 0 ? "envs doctor --key lists what declares it" : `not in source ${wantedSource}`
2840
+ );
2841
+ return 1;
2842
+ }
2843
+ };
2844
+
2845
+ // src/commands/hygiene.ts
2846
+ import { execFileSync } from "child_process";
2847
+ import {
2848
+ appendFileSync,
2849
+ existsSync as existsSync12,
2850
+ readFileSync as readFileSync8,
2851
+ writeFileSync as writeFileSync5
2852
+ } from "fs";
2853
+ import { join as join4, relative as relative2 } from "path";
2854
+ var IGNORE_LINE = ".envs/";
2855
+ function ensureIgnored(root, ui) {
2856
+ const path = join4(root, ".gitignore");
2857
+ if (!existsSync12(path)) {
2858
+ writeFileSync5(path, `${IGNORE_LINE}
2859
+ `);
2860
+ ui.success("created .gitignore", IGNORE_LINE);
2861
+ return true;
2862
+ }
2863
+ const text = readFileSync8(path, "utf8");
2864
+ if (text.split(/\r?\n/).some((line) => line.trim() === IGNORE_LINE)) {
2865
+ ui.info(".gitignore already ignores .envs/", "left alone");
2866
+ return false;
2867
+ }
2868
+ appendFileSync(
2869
+ path,
2870
+ text.endsWith("\n") ? `${IGNORE_LINE}
2871
+ ` : `
2872
+ ${IGNORE_LINE}
2873
+ `
2874
+ );
2875
+ ui.success("added to .gitignore", IGNORE_LINE);
2876
+ return true;
2877
+ }
2878
+ var gitignoreCommand = {
2879
+ name: "gitignore",
2880
+ describe: "make sure git ignores the catalog",
2881
+ usage: "envs gitignore",
2882
+ group: "check",
2883
+ run({ ui, env, cwd }) {
2884
+ const located = locateCatalogs({ cwd, env });
2885
+ if (located.projectRoot === void 0) {
2886
+ ui.error("no project root here", "nothing to ignore");
2887
+ return 1;
2888
+ }
2889
+ ensureIgnored(located.projectRoot, ui);
2890
+ return 0;
2891
+ }
2892
+ };
2893
+ function trackedFiles(root) {
2894
+ try {
2895
+ const out = execFileSync("git", ["ls-files", "-z"], {
2896
+ cwd: root,
2897
+ encoding: "utf8",
2898
+ stdio: ["ignore", "pipe", "ignore"]
2899
+ });
2900
+ return new Set(out.split("\0").filter((line) => line !== ""));
2901
+ } catch {
2902
+ return /* @__PURE__ */ new Set();
2903
+ }
2904
+ }
2905
+ var precommitCommand = {
2906
+ name: "precommit",
2907
+ describe: "refuse the commit if a secret is about to go into it",
2908
+ usage: "envs precommit",
2909
+ group: "check",
2910
+ options: [
2911
+ {
2912
+ name: "recovery-code",
2913
+ placeholder: "<code|->",
2914
+ describe: "unlock with a recovery code; - reads it from stdin"
2915
+ }
2916
+ ],
2917
+ run({ ui, args, env, cwd }) {
2918
+ const located = locateCatalogs({ cwd, env });
2919
+ const root = located.projectRoot;
2920
+ if (root === void 0) {
2921
+ ui.error("no project root here", "nothing to check");
2922
+ return 1;
2923
+ }
2924
+ const tracked = trackedFiles(root);
2925
+ if (tracked.size === 0) {
2926
+ ui.warn("git tracks nothing here", "not a repository, or an empty one");
2927
+ return 0;
2928
+ }
2929
+ const problems = [];
2930
+ const catalogRel = relative2(root, located.project);
2931
+ if (!catalogRel.startsWith("..") && tracked.has(catalogRel)) {
2932
+ problems.push(
2933
+ `${catalogRel} is tracked \u2014 the catalog must not be committed`
2934
+ );
2935
+ }
2936
+ if (existsSync12(located.project)) {
2937
+ const unlock = resolveUnlock(one(args, "recovery-code"), env);
2938
+ if (typeof unlock !== "string") {
2939
+ const db = openDatabaseSync(located.project, { readOnly: true });
2940
+ try {
2941
+ const seen = /* @__PURE__ */ new Set();
2942
+ for (const entry of readEntries(db, { unlock })) {
2943
+ if (seen.has(entry.path) || entry.path.startsWith("envs:"))
2944
+ continue;
2945
+ seen.add(entry.path);
2946
+ const rel = relative2(root, entry.path);
2947
+ if (!rel.startsWith("..") && tracked.has(rel)) {
2948
+ problems.push(`${rel} is tracked and holds plaintext values`);
2949
+ }
2950
+ }
2951
+ } catch {
2952
+ } finally {
2953
+ db.close();
2954
+ }
2955
+ } else {
2956
+ ui.info(
2957
+ "no key available",
2958
+ "checked tracked files only, not their contents"
2959
+ );
2960
+ }
2961
+ }
2962
+ if (problems.length === 0) {
2963
+ ui.success("nothing tracked that should not be");
2964
+ return 0;
2965
+ }
2966
+ for (const problem of problems) ui.error(problem);
2967
+ ui.info(
2968
+ "git rm --cached <path>",
2969
+ "removes it from the commit, keeps the file"
2970
+ );
2971
+ return 1;
2972
+ }
2973
+ };
2974
+ var genexampleCommand = {
2975
+ name: "genexample",
2976
+ describe: "write an example file with the key names and no values",
2977
+ usage: "envs genexample [--out <path>] [--requires]",
2978
+ group: "check",
2979
+ options: [
2980
+ {
2981
+ name: "out",
2982
+ placeholder: "<path>",
2983
+ describe: "where to write (default .env.example)"
2984
+ },
2985
+ {
2986
+ name: "requires",
2987
+ boolean: true,
2988
+ describe: "write envs.requires instead \u2014 names only, for config() to enforce"
2989
+ },
2990
+ {
2991
+ name: "recovery-code",
2992
+ placeholder: "<code|->",
2993
+ describe: "unlock with a recovery code; - reads it from stdin"
2994
+ }
2995
+ ],
2996
+ run({ ui, args, env, cwd }) {
2997
+ const located = locateCatalogs({ cwd, env });
2998
+ if (!existsSync12(located.project)) {
2999
+ ui.error("no catalog here", located.project);
3000
+ ui.info("run envs init first");
3001
+ return 1;
3002
+ }
3003
+ const unlock = resolveUnlock(one(args, "recovery-code"), env);
3004
+ if (typeof unlock === "string") {
3005
+ ui.error(unlock, "key names are sealed with the values");
3006
+ return 2;
3007
+ }
3008
+ const db = openDatabaseSync(located.project, { readOnly: true });
3009
+ let keys;
3010
+ try {
3011
+ keys = [
3012
+ ...new Set(readEntries(db, { unlock }).map((entry) => entry.key))
3013
+ ].sort();
3014
+ } catch (error) {
3015
+ ui.error(error.message);
3016
+ return 1;
3017
+ } finally {
3018
+ db.close();
3019
+ }
3020
+ if (keys.length === 0) {
3021
+ ui.error("no values to describe", "envs load <path> first");
3022
+ return 1;
3023
+ }
3024
+ const wantRequires = args.flags.has("requires");
3025
+ const target = one(args, "out") ?? (wantRequires ? "envs.requires" : ".env.example");
3026
+ const path = join4(located.projectRoot ?? cwd, target);
3027
+ const body = wantRequires ? `${keys.join("\n")}
3028
+ ` : `${keys.map((key) => `${key}=`).join("\n")}
3029
+ `;
3030
+ writeFileSync5(path, body);
3031
+ ui.success(`wrote ${target}`, `${keys.length} keys, no values`);
3032
+ if (wantRequires) {
3033
+ ui.info("commit it", "config() then fails loudly when one is missing");
3034
+ }
3035
+ return 0;
3036
+ }
3037
+ };
3038
+
3039
+ // src/commands/history.ts
3040
+ import { existsSync as existsSync13 } from "fs";
3041
+ function releases(db, limit) {
3042
+ return db.prepare(
3043
+ `SELECT r.revision_id, r.created_at, r.note,
3044
+ (SELECT count(*) FROM items i WHERE i.revision_id = r.revision_id) AS items
3045
+ FROM releases r
3046
+ ORDER BY r.created_at DESC, r.rowid DESC
3047
+ LIMIT $limit`
3048
+ ).all({ limit });
3049
+ }
3050
+ function openHere(cwd, env) {
3051
+ const located = locateCatalogs({ cwd, env });
3052
+ if (!existsSync13(located.project)) return located.project;
3053
+ return { db: openDatabaseSync(located.project), path: located.project };
3054
+ }
3055
+ var historyCommand = {
3056
+ name: "history",
3057
+ describe: "list releases and say which one is current",
3058
+ usage: "envs history [--limit <n>]",
3059
+ group: "history",
3060
+ options: [
3061
+ {
3062
+ name: "limit",
3063
+ placeholder: "<n>",
3064
+ describe: "how many to show (default 20)"
3065
+ }
3066
+ ],
3067
+ run({ ui, args, env, cwd }) {
3068
+ const opened = openHere(cwd, env);
3069
+ if (typeof opened === "string") {
3070
+ ui.error("no catalog here", opened);
3071
+ ui.info("run envs init first");
3072
+ return 1;
3073
+ }
3074
+ const { db, path } = opened;
3075
+ try {
3076
+ const limit = Number(one(args, "limit") ?? 20);
3077
+ if (!Number.isInteger(limit) || limit < 1) {
3078
+ ui.error("--limit must be a whole number of at least 1");
3079
+ return 2;
3080
+ }
3081
+ const current = currentRevisionId(db);
3082
+ const rows = releases(db, limit);
3083
+ if (rows.length === 0) {
3084
+ ui.info("no releases yet", "envs load <path> makes the first");
3085
+ return 0;
3086
+ }
3087
+ ui.heading(path);
3088
+ ui.table(
3089
+ rows.map((row) => [
3090
+ `${row.revision_id === current ? "*" : " "} ${row.revision_id.slice(0, 8)}`,
3091
+ `${row.created_at} ${row.items} values ${row.note ?? ""}`.trimEnd()
3092
+ ])
3093
+ );
3094
+ ui.info("* is current", "envs rollback <id> moves the pointer");
3095
+ return 0;
3096
+ } finally {
3097
+ db.close();
3098
+ }
3099
+ }
3100
+ };
3101
+ var rollbackCommand = {
3102
+ name: "rollback",
3103
+ describe: "point at an earlier release",
3104
+ usage: "envs rollback <revision-id>",
3105
+ group: "history",
3106
+ run({ ui, args, env, cwd }) {
3107
+ if (args.positional.length !== 1) {
3108
+ ui.error("give one revision id", "envs history shows them");
3109
+ return 2;
3110
+ }
3111
+ const opened = openHere(cwd, env);
3112
+ if (typeof opened === "string") {
3113
+ ui.error("no catalog here", opened);
3114
+ ui.info("run envs init first");
3115
+ return 1;
3116
+ }
3117
+ const { db } = opened;
3118
+ try {
3119
+ const prefix = args.positional[0];
3120
+ const matches = db.prepare(
3121
+ "SELECT revision_id FROM releases WHERE revision_id LIKE $like ORDER BY created_at DESC"
3122
+ ).all({ like: `${prefix}%` });
3123
+ if (matches.length === 0) {
3124
+ ui.error(`no release starting with "${prefix}"`);
3125
+ return 1;
3126
+ }
3127
+ if (matches.length > 1) {
3128
+ ui.error(
3129
+ `"${prefix}" matches ${matches.length} releases`,
3130
+ matches.map((m) => m.revision_id.slice(0, 12)).join(", ")
3131
+ );
3132
+ return 1;
3133
+ }
3134
+ const target = matches[0].revision_id;
3135
+ const current = currentRevisionId(db);
3136
+ if (target === current) {
3137
+ ui.info("already current", target.slice(0, 8));
3138
+ return 0;
3139
+ }
3140
+ movePointer(db, target);
3141
+ ui.success(
3142
+ `now at ${target.slice(0, 8)}`,
3143
+ `was ${current?.slice(0, 8) ?? "none"}`
3144
+ );
3145
+ return 0;
3146
+ } finally {
3147
+ db.close();
3148
+ }
3149
+ }
3150
+ };
3151
+
3152
+ // src/commands/ls.ts
3153
+ import { existsSync as existsSync14 } from "fs";
3154
+ var lsCommand = {
3155
+ name: "ls",
3156
+ describe: "list the sources in the catalog, and their keys",
3157
+ usage: "envs ls [--keys]",
3158
+ group: "values",
3159
+ options: [
3160
+ {
3161
+ name: "keys",
3162
+ boolean: true,
3163
+ describe: "also list key names (needs a key)"
3164
+ },
3165
+ {
3166
+ name: "recovery-code",
3167
+ placeholder: "<code|->",
3168
+ describe: "unlock with a recovery code; - reads it from stdin"
3169
+ }
3170
+ ],
3171
+ run({ ui, args, env, cwd }) {
3172
+ const located = locateCatalogs({ cwd, env });
3173
+ if (!existsSync14(located.project)) {
3174
+ ui.error("no catalog here", located.project);
3175
+ ui.info("run envs init first");
3176
+ return 1;
3177
+ }
3178
+ const db = openDatabaseSync(located.project, { readOnly: true });
3179
+ try {
3180
+ const sources = db.prepare(
3181
+ "SELECT alias, path, added_at, retired_at FROM sources ORDER BY added_at"
3182
+ ).all();
3183
+ ui.heading(located.project);
3184
+ if (sources.length === 0) {
3185
+ ui.info("no sources yet", "envs load <path> adds one");
3186
+ return 0;
3187
+ }
3188
+ ui.table(
3189
+ sources.map((source) => [
3190
+ source.retired_at === null ? source.alias : `${source.alias} (retired)`,
3191
+ source.path
3192
+ ])
3193
+ );
3194
+ const revision = currentRevisionId(db);
3195
+ ui.line();
3196
+ ui.info(
3197
+ revision === void 0 ? "no release yet" : `release ${revision.slice(0, 8)} is current`,
3198
+ "envs history lists them"
3199
+ );
3200
+ if (!args.flags.has("keys")) return 0;
3201
+ const unlock = resolveUnlock(one(args, "recovery-code"), env);
3202
+ if (typeof unlock === "string") {
3203
+ ui.error(unlock, "key names are sealed with the values");
3204
+ return 2;
3205
+ }
3206
+ const byAlias = /* @__PURE__ */ new Map();
3207
+ for (const entry of readEntries(db, { unlock })) {
3208
+ byAlias.set(entry.alias, [
3209
+ ...byAlias.get(entry.alias) ?? [],
3210
+ entry.key
3211
+ ]);
3212
+ }
3213
+ ui.line();
3214
+ ui.heading("keys");
3215
+ for (const [alias, keys] of byAlias) {
3216
+ ui.table([[alias, keys.sort().join(" ")]]);
3217
+ }
3218
+ return 0;
3219
+ } catch (error) {
3220
+ ui.error(error.message);
3221
+ return 1;
3222
+ } finally {
3223
+ db.close();
3224
+ }
3225
+ }
3226
+ };
3227
+
3228
+ // src/commands/init.ts
3229
+ import { randomBytes } from "crypto";
3230
+ import { chmodSync as chmodSync2, existsSync as existsSync15 } from "fs";
3231
+ import { dirname as dirname5 } from "path";
3232
+ var DEFAULT_CODES = 5;
3233
+ var initCommand = {
3234
+ name: "init",
3235
+ describe: "create a catalog and print its recovery codes once",
3236
+ usage: "envs init [--recovery-codes <n>] [--no-gitignore]",
3237
+ group: "start here",
3238
+ options: [
3239
+ {
3240
+ name: "recovery-codes",
3241
+ placeholder: "<n>",
3242
+ describe: `how many to mint (default ${DEFAULT_CODES}, 0 for none)`
3243
+ },
3244
+ {
3245
+ name: "no-gitignore",
3246
+ boolean: true,
3247
+ describe: "do not touch .gitignore"
3248
+ }
3249
+ ],
3250
+ run({ ui, args, env, cwd }) {
3251
+ const located = locateCatalogs({ cwd, env });
3252
+ const path = located.project;
3253
+ if (existsSync15(path)) {
3254
+ ui.error("a catalog already exists here", path);
3255
+ ui.info("nothing was changed", "delete it deliberately to start over");
3256
+ return 1;
3257
+ }
3258
+ const requested = one(args, "recovery-codes");
3259
+ const count = requested === void 0 ? DEFAULT_CODES : Number(requested);
3260
+ if (!Number.isInteger(count) || count < 0 || count > 20) {
3261
+ ui.error(`--recovery-codes must be a whole number from 0 to 20`);
3262
+ return 2;
3263
+ }
3264
+ const givenKek = env["ENVS_KEK"] ?? "";
3265
+ let kek;
3266
+ let mintedKek = false;
3267
+ if (givenKek !== "") {
3268
+ kek = new Uint8Array(Buffer.from(givenKek, "base64"));
3269
+ if (kek.length !== 32) {
3270
+ ui.error("ENVS_KEK must be 32 bytes, base64 encoded");
3271
+ return 2;
3272
+ }
3273
+ } else {
3274
+ kek = new Uint8Array(randomBytes(32));
3275
+ mintedKek = true;
3276
+ }
3277
+ const keyring = createKeyring({ kek, recoveryCodes: count });
3278
+ const dir = ensureCatalogDir(dirname5(path));
3279
+ const db = openDatabaseSync(path);
3280
+ try {
3281
+ createSchema(db);
3282
+ writeWraps(db, keyring.wraps);
3283
+ } finally {
3284
+ db.close();
3285
+ }
3286
+ try {
3287
+ chmodSync2(path, 384);
3288
+ } catch {
3289
+ }
3290
+ ui.success("catalog created", path);
3291
+ if (dir === "narrowed") {
3292
+ ui.warn(
3293
+ "narrowed the catalog directory to 700",
3294
+ "it was writable by others, who could have replaced the catalog"
3295
+ );
3296
+ }
3297
+ if (located.source === "global") {
3298
+ ui.warn(
3299
+ "no project root here, so this is the machine-wide catalog",
3300
+ located.project
3301
+ );
3302
+ }
3303
+ if (args.flags.has("no-gitignore")) {
3304
+ ui.info("left .gitignore alone", "as asked");
3305
+ } else if (located.projectRoot !== void 0) {
3306
+ ensureIgnored(located.projectRoot, ui);
3307
+ }
3308
+ ui.line();
3309
+ if (mintedKek) {
3310
+ ui.heading("Your key \u2014 save it now, it is not stored");
3311
+ ui.line(` ENVS_KEK=${Buffer.from(kek).toString("base64")}`);
3312
+ ui.line();
3313
+ } else {
3314
+ ui.info("used the ENVS_KEK already in your environment", "not reprinted");
3315
+ }
3316
+ if (keyring.recoveryCodes.length > 0) {
3317
+ ui.heading(
3318
+ `Recovery codes \u2014 ${keyring.recoveryCodes.length}, shown once, not stored`
3319
+ );
3320
+ for (const code of keyring.recoveryCodes) ui.line(` ${code}`);
3321
+ ui.line();
3322
+ ui.info(
3323
+ "any one of them opens this catalog without the key",
3324
+ "keep them somewhere the key is not"
3325
+ );
3326
+ } else {
3327
+ ui.warn(
3328
+ "no recovery codes were minted",
3329
+ "losing ENVS_KEK will lose this catalog"
3330
+ );
3331
+ }
3332
+ ui.line();
3333
+ ui.info("next", "envs load <path> to put values in");
3334
+ return 0;
3335
+ }
3336
+ };
3337
+
3338
+ // src/commands/load.ts
3339
+ import { createHash as createHash3 } from "crypto";
3340
+ import { existsSync as existsSync16, readFileSync as readFileSync9 } from "fs";
3341
+ import { resolve as resolve4 } from "path";
3342
+ var loadCommand = {
3343
+ name: "load",
3344
+ describe: "put the values of one or more env files into a new release",
3345
+ usage: "envs load <path>... [--alias <name>] [--replace]",
3346
+ group: "start here",
3347
+ options: [
3348
+ {
3349
+ name: "alias",
3350
+ placeholder: "<name>",
3351
+ describe: "name this source explicitly; only with a single path"
3352
+ },
3353
+ {
3354
+ name: "replace",
3355
+ boolean: true,
3356
+ describe: "drop sources not named here instead of carrying them forward"
3357
+ },
3358
+ {
3359
+ name: "recovery-code",
3360
+ placeholder: "<code|->",
3361
+ describe: "unlock with a recovery code; - reads it from stdin"
3362
+ }
3363
+ ],
3364
+ run({ ui, args, env, cwd }) {
3365
+ if (args.positional.length === 0) {
3366
+ ui.error("no paths given", "envs load <path>...");
3367
+ return 2;
3368
+ }
3369
+ const explicitAlias = one(args, "alias");
3370
+ if (explicitAlias !== void 0 && args.positional.length > 1) {
3371
+ ui.error("--alias names one source", "give a single path with it");
3372
+ return 2;
3373
+ }
3374
+ const located = locateCatalogs({ cwd, env });
3375
+ if (!existsSync16(located.project)) {
3376
+ ui.error("no catalog here", located.project);
3377
+ ui.info("run envs init first");
3378
+ return 1;
3379
+ }
3380
+ const parsed = [];
3381
+ for (const given of args.positional) {
3382
+ const path = resolve4(cwd, given);
3383
+ if (!existsSync16(path)) {
3384
+ ui.error(path, "no such file");
3385
+ return 1;
3386
+ }
3387
+ const text = readFileSync9(path, "utf8");
3388
+ const result = parseEnv(text);
3389
+ const record = toRecord(result);
3390
+ if (record === null) {
3391
+ ui.error(path, `${result.findings.length} problems, nothing loaded`);
3392
+ ui.table(
3393
+ result.findings.map((f) => [`line ${f.line}`, f.code]),
3394
+ " "
3395
+ );
3396
+ return 1;
3397
+ }
3398
+ parsed.push({
3399
+ path,
3400
+ values: record,
3401
+ digest: createHash3("sha256").update(text).digest("hex")
3402
+ });
3403
+ }
3404
+ const unlock = resolveUnlock(one(args, "recovery-code"), env);
3405
+ if (typeof unlock === "string") {
3406
+ ui.error(unlock);
3407
+ return 2;
3408
+ }
3409
+ const db = openDatabaseSync(located.project);
3410
+ try {
3411
+ const dek = unlockDek(readWraps(db), unlock);
3412
+ const named = /* @__PURE__ */ new Set();
3413
+ const items = [];
3414
+ for (const file of parsed) {
3415
+ const source = ensureSource(db, file.path, {
3416
+ ...explicitAlias ? { alias: explicitAlias } : {},
3417
+ digest: file.digest
3418
+ });
3419
+ named.add(source.sourceId);
3420
+ for (const [key, value] of Object.entries(file.values)) {
3421
+ items.push({ sourceId: source.sourceId, key, value });
3422
+ }
3423
+ }
3424
+ let carried = 0;
3425
+ if (!args.flags.has("replace")) {
3426
+ try {
3427
+ for (const entry of readEntries(db, { unlock })) {
3428
+ if (named.has(entry.sourceId)) continue;
3429
+ items.push({
3430
+ sourceId: entry.sourceId,
3431
+ key: entry.key,
3432
+ value: entry.value
3433
+ });
3434
+ carried += 1;
3435
+ }
3436
+ } catch {
3437
+ }
3438
+ }
3439
+ const release = writeRelease(db, dek, items, {
3440
+ note: `load ${parsed.map((f) => f.path).join(", ")}`
3441
+ });
3442
+ for (const file of parsed) {
3443
+ ui.success(file.path, `${Object.keys(file.values).length} keys`);
3444
+ }
3445
+ if (carried > 0) {
3446
+ ui.info(
3447
+ `carried ${carried} values from other sources`,
3448
+ "use --replace to drop them"
3449
+ );
3450
+ }
3451
+ ui.success(
3452
+ `release ${release.revisionId.slice(0, 8)} is current`,
3453
+ `${release.count} values`
3454
+ );
3455
+ return 0;
3456
+ } catch (error) {
3457
+ ui.error(error.message);
3458
+ return 1;
3459
+ } finally {
3460
+ db.close();
3461
+ }
3462
+ }
3463
+ };
3464
+
3465
+ // src/commands/rotate.ts
3466
+ import { randomBytes as randomBytes2 } from "crypto";
3467
+ import { existsSync as existsSync17 } from "fs";
3468
+ var rotateCommand = {
3469
+ name: "rotate",
3470
+ describe: "replace the key, the recovery codes, or both",
3471
+ usage: "envs rotate [--key] [--recovery-codes <n>]",
3472
+ group: "history",
3473
+ options: [
3474
+ {
3475
+ name: "key",
3476
+ boolean: true,
3477
+ describe: "mint a new ENVS_KEK and retire the old wrap"
3478
+ },
3479
+ {
3480
+ name: "recovery-codes",
3481
+ placeholder: "<n>",
3482
+ describe: "mint this many new codes and retire the old ones"
3483
+ },
3484
+ {
3485
+ name: "recovery-code",
3486
+ placeholder: "<code|->",
3487
+ describe: "unlock with a recovery code; - reads it from stdin"
3488
+ }
3489
+ ],
3490
+ run({ ui, args, env, cwd }) {
3491
+ const wantKey = args.flags.has("key");
3492
+ const codesRaw = one(args, "recovery-codes");
3493
+ const wantCodes = codesRaw !== void 0;
3494
+ if (!wantKey && !wantCodes) {
3495
+ ui.error("nothing to rotate", "--key, --recovery-codes <n>, or both");
3496
+ return 2;
3497
+ }
3498
+ const codeCount = wantCodes ? Number(codesRaw) : 0;
3499
+ if (wantCodes && (!Number.isInteger(codeCount) || codeCount < 1 || codeCount > 20)) {
3500
+ ui.error("--recovery-codes must be a whole number from 1 to 20");
3501
+ return 2;
3502
+ }
3503
+ const located = locateCatalogs({ cwd, env });
3504
+ if (!existsSync17(located.project)) {
3505
+ ui.error("no catalog here", located.project);
3506
+ ui.info("run envs init first");
3507
+ return 1;
3508
+ }
3509
+ const unlock = resolveUnlock(one(args, "recovery-code"), env);
3510
+ if (typeof unlock === "string") {
3511
+ ui.error(unlock);
3512
+ return 2;
3513
+ }
3514
+ const db = openDatabaseSync(located.project);
3515
+ try {
3516
+ const before = readWraps(db);
3517
+ const dek = unlockDek(before, unlock);
3518
+ const added = [];
3519
+ let newKek;
3520
+ let newCodes = [];
3521
+ if (wantKey) {
3522
+ newKek = new Uint8Array(randomBytes2(32));
3523
+ const wrap = addWrap(dek, { kek: newKek }, crypto.randomUUID());
3524
+ writeWraps(db, [wrap]);
3525
+ added.push(wrap.wrapId);
3526
+ }
3527
+ if (wantCodes) {
3528
+ const minted = createKeyring({ kek: dek, recoveryCodes: codeCount });
3529
+ newCodes = minted.recoveryCodes;
3530
+ const wraps = minted.recoveryCodes.map(
3531
+ (code) => addWrap(dek, { recoveryCode: code }, crypto.randomUUID())
3532
+ );
3533
+ writeWraps(db, wraps);
3534
+ added.push(...wraps.map((wrap) => wrap.wrapId));
3535
+ }
3536
+ db.transaction(() => {
3537
+ const retire = db.prepare(
3538
+ "UPDATE dek_wraps SET retired_at = $at WHERE wrap_id = $id"
3539
+ );
3540
+ const at = (/* @__PURE__ */ new Date()).toISOString();
3541
+ for (const wrap of before) {
3542
+ const stale = wantKey && wrap.method === "kek" || wantCodes && wrap.method === "recovery";
3543
+ if (stale) retire.run({ at, id: wrap.wrapId });
3544
+ }
3545
+ audit(
3546
+ db,
3547
+ "keyring.rotate",
3548
+ added.join(" "),
3549
+ `retired ${before.length}`
3550
+ );
3551
+ });
3552
+ ui.success(
3553
+ "rotated",
3554
+ `${added.length} new wraps, ${before.length} retired`
3555
+ );
3556
+ ui.line();
3557
+ if (newKek !== void 0) {
3558
+ ui.heading("Your new key \u2014 save it now, it is not stored");
3559
+ ui.line(` ENVS_KEK=${Buffer.from(newKek).toString("base64")}`);
3560
+ ui.line();
3561
+ ui.warn("the old ENVS_KEK no longer opens this catalog");
3562
+ }
3563
+ if (newCodes.length > 0) {
3564
+ ui.heading(`New recovery codes \u2014 ${newCodes.length}, shown once`);
3565
+ for (const code of newCodes) ui.line(` ${code}`);
3566
+ ui.line();
3567
+ ui.warn("the old codes no longer open this catalog");
3568
+ }
3569
+ return 0;
3570
+ } catch (error) {
3571
+ ui.error(error.message);
3572
+ return 1;
3573
+ } finally {
3574
+ db.close();
3575
+ }
3576
+ }
3577
+ };
3578
+
3579
+ // src/commands/run.ts
3580
+ import { spawnSync } from "child_process";
3581
+ var runCommand = {
3582
+ name: "run",
3583
+ describe: "run a command with the values in its environment",
3584
+ usage: "envs run -- <command> [args...]",
3585
+ group: "values",
3586
+ options: [
3587
+ {
3588
+ name: "alias",
3589
+ placeholder: "<name>",
3590
+ repeat: true,
3591
+ describe: "restrict to these sources, in precedence order"
3592
+ },
3593
+ {
3594
+ name: "no-global",
3595
+ boolean: true,
3596
+ describe: "leave the machine-wide layer out; CI should"
3597
+ },
3598
+ {
3599
+ name: "override",
3600
+ boolean: true,
3601
+ describe: "let the store win over values already in the environment"
3602
+ }
3603
+ ],
3604
+ run({ ui, args, env, cwd }) {
3605
+ const [command, ...rest] = args.positional;
3606
+ if (command === void 0) {
3607
+ ui.error("no command given", "envs run -- node server.js");
3608
+ return 2;
3609
+ }
3610
+ const aliases = many(args, "alias");
3611
+ const target = { ...env };
3612
+ const result = config({
3613
+ cwd,
3614
+ env,
3615
+ processEnv: target,
3616
+ override: args.flags.has("override"),
3617
+ global: !args.flags.has("no-global"),
3618
+ ...aliases.length > 0 ? { aliases } : {}
3619
+ });
3620
+ if (result.error) {
3621
+ ui.error(result.error.message);
3622
+ return 1;
3623
+ }
3624
+ const count = Object.keys(result.parsed ?? {}).length;
3625
+ if (count === 0) {
3626
+ ui.error("no values resolved", "envs load <path> first, or envs doctor");
3627
+ return 1;
3628
+ }
3629
+ ui.info(`running with ${count} values`, command);
3630
+ const child = spawnSync(command, rest, {
3631
+ cwd,
3632
+ env: target,
3633
+ stdio: "inherit",
3634
+ // The child shares this process group, so a terminal's SIGINT reaches it
3635
+ // directly. A signal sent only to this process cannot be forwarded from a
3636
+ // synchronous spawn, and config() being synchronous is the harder rule.
3637
+ shell: false
3638
+ });
3639
+ if (child.error !== void 0) {
3640
+ ui.error(`could not run ${command}`, child.error.message);
3641
+ return 127;
3642
+ }
3643
+ if (child.signal !== null && child.signal !== void 0) {
3644
+ ui.warn(`${command} was killed`, child.signal);
3645
+ return 128 + (SIGNAL_NUMBER[child.signal] ?? 0);
3646
+ }
3647
+ return child.status ?? 0;
3648
+ }
3649
+ };
3650
+ var SIGNAL_NUMBER = {
3651
+ SIGHUP: 1,
3652
+ SIGINT: 2,
3653
+ SIGQUIT: 3,
3654
+ SIGKILL: 9,
3655
+ SIGTERM: 15
3656
+ };
3657
+
3658
+ // src/commands/serve.ts
3659
+ import {
3660
+ createServer
3661
+ } from "http";
3662
+ import { timingSafeEqual } from "crypto";
3663
+ import { existsSync as existsSync18, readFileSync as readFileSync10 } from "fs";
3664
+ function constantTimeEqual(a, b) {
3665
+ const left = Buffer.from(a);
3666
+ const right = Buffer.from(b);
3667
+ return left.length === right.length && timingSafeEqual(left, right);
3668
+ }
3669
+ function authorised(header, token) {
3670
+ if (header === void 0) return false;
3671
+ const prefix = "Bearer ";
3672
+ if (!header.startsWith(prefix)) return false;
3673
+ return constantTimeEqual(header.slice(prefix.length), token);
3674
+ }
3675
+ function snapshotBytes(catalogPath) {
3676
+ const db = openDatabaseSync(catalogPath);
3677
+ try {
3678
+ db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
3679
+ } finally {
3680
+ db.close();
3681
+ }
3682
+ return new Uint8Array(readFileSync10(catalogPath));
3683
+ }
3684
+ function handle(request, response, options) {
3685
+ const url = request.url ?? "/";
3686
+ if (url === "/healthz") {
3687
+ response.writeHead(200, { "content-type": "application/json" });
3688
+ response.end(JSON.stringify({ ok: true }));
3689
+ return false;
3690
+ }
3691
+ if (!authorised(request.headers.authorization, options.token)) {
3692
+ response.writeHead(401, { "content-type": "application/json" });
3693
+ response.end(JSON.stringify({ error: "unauthorised" }));
3694
+ return false;
3695
+ }
3696
+ if (url === "/v1/meta") {
3697
+ const db = openDatabaseSync(options.catalogPath, { readOnly: true });
3698
+ try {
3699
+ const meta = readMeta(db);
3700
+ response.writeHead(200, { "content-type": "application/json" });
3701
+ response.end(JSON.stringify(meta ?? { error: "no schema" }));
3702
+ } finally {
3703
+ db.close();
3704
+ }
3705
+ return false;
3706
+ }
3707
+ if (url === "/v1/catalog" && request.method === "GET") {
3708
+ const bytes = snapshotBytes(options.catalogPath);
3709
+ response.writeHead(200, {
3710
+ "content-type": "application/octet-stream",
3711
+ "content-length": String(bytes.length)
3712
+ });
3713
+ response.end(Buffer.from(bytes));
3714
+ options.ui.info("served the catalog", `${bytes.length} bytes`);
3715
+ return true;
3716
+ }
3717
+ response.writeHead(404, { "content-type": "application/json" });
3718
+ response.end(JSON.stringify({ error: "not found" }));
3719
+ return false;
3720
+ }
3721
+ var serveCommand = {
3722
+ name: "serve",
3723
+ describe: "hand the sealed catalog to teammates over HTTP",
3724
+ usage: "envs serve [--port <n>] [--host <addr>]",
3725
+ group: "publish",
3726
+ options: [
3727
+ { name: "port", placeholder: "<n>", describe: "default 7373" },
3728
+ {
3729
+ name: "host",
3730
+ placeholder: "<addr>",
3731
+ describe: "default 127.0.0.1; anything else exposes it"
3732
+ },
3733
+ {
3734
+ name: "once",
3735
+ boolean: true,
3736
+ describe: "stop after handing the catalog over once, for a scripted pull"
3737
+ }
3738
+ ],
3739
+ async run({ ui, args, env, cwd }) {
3740
+ const located = locateCatalogs({ cwd, env });
3741
+ if (!existsSync18(located.project)) {
3742
+ ui.error("no catalog here", located.project);
3743
+ ui.info("run envs init first");
3744
+ return 1;
3745
+ }
3746
+ const token = env["ENVS_SERVE_TOKEN"] ?? "";
3747
+ if (token.length < 16) {
3748
+ ui.error(
3749
+ "set ENVS_SERVE_TOKEN to at least 16 characters",
3750
+ "clients send it as Authorization: Bearer <token>"
3751
+ );
3752
+ return 2;
3753
+ }
3754
+ const port = Number(one(args, "port") ?? 7373);
3755
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
3756
+ ui.error("--port must be a port number");
3757
+ return 2;
3758
+ }
3759
+ const host = one(args, "host") ?? "127.0.0.1";
3760
+ const options = {
3761
+ catalogPath: located.project,
3762
+ token,
3763
+ port,
3764
+ host,
3765
+ ui
3766
+ };
3767
+ return new Promise((resolveRun) => {
3768
+ const server = createServer((request, response) => {
3769
+ let served = false;
3770
+ try {
3771
+ served = handle(request, response, options);
3772
+ } catch (error) {
3773
+ ui.error(error.message);
3774
+ response.writeHead(500, { "content-type": "application/json" });
3775
+ response.end(JSON.stringify({ error: "failed" }));
3776
+ }
3777
+ if (served && args.flags.has("once")) {
3778
+ server.close(() => resolveRun(0));
3779
+ }
3780
+ });
3781
+ server.on("error", (error) => {
3782
+ ui.error(error.message);
3783
+ resolveRun(1);
3784
+ });
3785
+ server.listen(port, host, () => {
3786
+ ui.success(`listening on http://${host}:${port}`, located.project);
3787
+ ui.info("it serves the sealed catalog", "clients still need a key");
3788
+ if (host !== "127.0.0.1" && host !== "localhost") {
3789
+ ui.warn(
3790
+ "this is reachable from the network",
3791
+ "put TLS in front of it: the token crosses the wire"
3792
+ );
3793
+ }
3794
+ ui.info("stop with ctrl-c");
3795
+ });
3796
+ const stop = () => {
3797
+ server.close(() => resolveRun(0));
3798
+ };
3799
+ process.once("SIGINT", stop);
3800
+ process.once("SIGTERM", stop);
3801
+ });
3802
+ }
3803
+ };
3804
+
3805
+ // src/commands/session.ts
3806
+ var wait = (ms) => new Promise((resolve6) => setTimeout(resolve6, ms));
3807
+ var loginCommand = {
3808
+ name: "login",
3809
+ describe: "sign in on this machine, for a remote catalog",
3810
+ usage: "envs login [--issuer <url>]",
3811
+ group: "hosted account",
3812
+ options: [
3813
+ {
3814
+ name: "issuer",
3815
+ describe: "override the auth issuer",
3816
+ placeholder: "<url>"
3817
+ }
3818
+ ],
3819
+ async run({ ui, args, env }) {
3820
+ const issuer = args.options.get("issuer")?.[0] ?? setting(env, "ENVS_AUTH_ISSUER", DEFAULT_ISSUER);
3821
+ const clientId = setting(env, "ENVS_OAUTH_CLIENT_ID", DEFAULT_CLIENT);
3822
+ const resource = setting(env, "ENVS_OAUTH_RESOURCE", DEFAULT_RESOURCE);
3823
+ const scope = setting(env, "ENVS_OAUTH_SCOPE", DEFAULT_SCOPE);
3824
+ let metadata;
3825
+ try {
3826
+ metadata = await discover(issuer, fetcher);
3827
+ } catch (error) {
3828
+ ui.error("cannot sign in", error.message);
3829
+ return 1;
3830
+ }
3831
+ let start;
3832
+ try {
3833
+ start = await startDevice(
3834
+ metadata,
3835
+ { clientId, resource, scope },
3836
+ fetcher
3837
+ );
3838
+ } catch (error) {
3839
+ ui.error("cannot start sign-in", error.message);
3840
+ return 1;
3841
+ }
3842
+ ui.info("open", start.verificationUriComplete ?? start.verificationUri);
3843
+ ui.info("code", start.userCode);
3844
+ ui.line();
3845
+ ui.info("waiting", "approve it and this finishes on its own");
3846
+ let interval = start.intervalMs;
3847
+ for (; ; ) {
3848
+ if (Date.now() >= start.expiresAt) {
3849
+ ui.error("expired", "the code timed out; run envs login again");
3850
+ return 1;
3851
+ }
3852
+ await wait(interval);
3853
+ const outcome = await pollOnce(
3854
+ metadata,
3855
+ { clientId, deviceCode: start.deviceCode, issuer },
3856
+ fetcher
3857
+ );
3858
+ if (outcome.kind === "granted" && outcome.session) {
3859
+ writeSession(outcome.session, env["HOME"]);
3860
+ ui.success("signed in", issuer);
3861
+ return 0;
3862
+ }
3863
+ if (outcome.kind === "slow_down") interval += 5e3;
3864
+ if (outcome.kind === "denied") {
3865
+ ui.error("declined", "the request was not approved");
3866
+ return 1;
3867
+ }
3868
+ if (outcome.kind === "expired") {
3869
+ ui.error("expired", "the code timed out; run envs login again");
3870
+ return 1;
3871
+ }
3872
+ }
3873
+ }
3874
+ };
3875
+ var logoutCommand = {
3876
+ name: "logout",
3877
+ describe: "forget the sign-in on this machine",
3878
+ usage: "envs logout",
3879
+ group: "hosted account",
3880
+ async run({ ui, env }) {
3881
+ const state = sessionState(env);
3882
+ if (state.unreadable !== void 0) {
3883
+ ui.error("cannot read the sign-in here", state.unreadable);
3884
+ return 1;
3885
+ }
3886
+ const session = state.session;
3887
+ if (!session) {
3888
+ ui.info("not signed in", "nothing to forget");
3889
+ return 0;
3890
+ }
3891
+ const clientId = session.clientId ?? setting(env, "ENVS_OAUTH_CLIENT_ID", DEFAULT_CLIENT);
3892
+ clearSession(env["HOME"]);
3893
+ try {
3894
+ const metadata = await discover(session.issuer, fetcher);
3895
+ const revoked = await revoke(metadata, session, clientId, fetcher);
3896
+ ui.success(
3897
+ "signed out",
3898
+ revoked ? "access and refresh credentials revoked" : "local copy removed; remote revocation was not confirmed"
3899
+ );
3900
+ } catch {
3901
+ ui.success(
3902
+ "signed out",
3903
+ "local copy removed; remote revocation was not confirmed"
3904
+ );
3905
+ }
3906
+ return 0;
3907
+ }
3908
+ };
3909
+ async function describeAccount(env) {
3910
+ try {
3911
+ const who = await account(await access(env));
3912
+ return {
3913
+ account: who.userId,
3914
+ subscription: who.subscription,
3915
+ snapshots: who.catalogs.filter((c) => c.id.endsWith(".envsnap")).length,
3916
+ teams: who.teams,
3917
+ members: who.members
3918
+ };
3919
+ } catch {
3920
+ return null;
3921
+ }
3922
+ }
3923
+ var whoamiCommand = {
3924
+ name: "whoami",
3925
+ describe: "whether this machine is signed in, and to what",
3926
+ usage: "envs whoami [--json]",
3927
+ group: "hosted account",
3928
+ options: [
3929
+ { name: "json", boolean: true, describe: "answer as one JSON object" }
3930
+ ],
3931
+ async run({ ui, args, env }) {
3932
+ const asJson = args.flags.has("json");
3933
+ const answer = (value) => {
3934
+ ui.data(`${JSON.stringify(value)}
3935
+ `);
3936
+ };
3937
+ const state = sessionState(env);
3938
+ if (state.unreadable !== void 0) {
3939
+ if (asJson) answer({ signedIn: false, unreadable: state.unreadable });
3940
+ else ui.data("cannot tell\n");
3941
+ ui.error("cannot read the sign-in here", state.unreadable);
3942
+ return 1;
3943
+ }
3944
+ const session = state.session;
3945
+ if (!session) {
3946
+ if (asJson) answer({ signedIn: false });
3947
+ else ui.data("not signed in\n");
3948
+ ui.info("not signed in", "run envs login");
3949
+ return 1;
3950
+ }
3951
+ if (asJson) {
3952
+ const account2 = await describeAccount(env);
3953
+ answer({
3954
+ signedIn: true,
3955
+ issuer: session.issuer,
3956
+ ...session.scope ? { scope: session.scope } : {},
3957
+ ...account2 ?? {}
3958
+ });
3959
+ return 0;
3960
+ }
3961
+ ui.data(`signed in to ${session.issuer}
3962
+ `);
3963
+ ui.info("issuer", session.issuer);
3964
+ if (session.scope) ui.info("scope", session.scope);
3965
+ if (session.expiresAt !== void 0) {
3966
+ const left = session.expiresAt - Date.now();
3967
+ ui.info(
3968
+ "token",
3969
+ left > 0 ? `valid for ${String(Math.floor(left / 6e4))} min` : "expired, will renew on next use"
3970
+ );
3971
+ }
3972
+ try {
3973
+ const who = await account(await access(env));
3974
+ ui.info("account", who.userId);
3975
+ ui.info(
3976
+ "subscription",
3977
+ who.subscription === "active" ? "active" : who.subscription === "inactive" ? "none; envs:// destinations will be refused" : "could not be read; try again shortly"
3978
+ );
3979
+ const snapshots = who.catalogs.filter(
3980
+ (entry) => entry.id.endsWith(".envsnap")
3981
+ );
3982
+ ui.info("snapshots", String(snapshots.length));
3983
+ if (who.teams.length > 0) ui.info("member of", who.teams.join(" "));
3984
+ if (who.members.length > 0) ui.info("shared with", who.members.join(" "));
3985
+ } catch (error) {
3986
+ ui.info(
3987
+ "account",
3988
+ error instanceof RemoteError ? `${error.message}${error.detail ? ` \u2014 ${error.detail}` : ""}` : "could not be reached"
3989
+ );
3990
+ }
3991
+ return 0;
3992
+ }
3993
+ };
3994
+
3995
+ // src/commands/set.ts
3996
+ import { existsSync as existsSync19 } from "fs";
3997
+ function parseAssignment(text) {
3998
+ const result = parseEnv(text);
3999
+ const assignments = result.entries.filter((e) => e.kind === "assignment");
4000
+ if (!result.ok || assignments.length !== 1) {
4001
+ return `"${text.split("=")[0] ?? text}" is not a single KEY=VALUE assignment`;
4002
+ }
4003
+ const [entry] = assignments;
4004
+ return { key: entry.key, value: entry.value };
4005
+ }
4006
+ var setCommand = {
4007
+ name: "set",
4008
+ describe: "change one value, as a new release",
4009
+ usage: "envs set KEY VALUE | envs set KEY=VALUE",
4010
+ group: "values",
4011
+ options: [
4012
+ {
4013
+ name: "source",
4014
+ placeholder: "<alias>",
4015
+ describe: "which source owns the key; required when there is more than one"
4016
+ },
4017
+ {
4018
+ name: "sensitivity",
4019
+ placeholder: "<level>",
4020
+ describe: "low, medium, high or critical; only low may be baked by build"
4021
+ },
4022
+ {
4023
+ name: "recovery-code",
4024
+ placeholder: "<code|->",
4025
+ describe: "unlock with a recovery code; - reads it from stdin"
4026
+ }
4027
+ ],
4028
+ run({ ui, args, env, cwd }) {
4029
+ const [first, second] = args.positional;
4030
+ if (first === void 0 || args.positional.length > 2) {
4031
+ ui.error("give one KEY=VALUE or KEY VALUE", "envs set KEY VALUE");
4032
+ return 2;
4033
+ }
4034
+ if (second !== void 0 && first.includes("=")) {
4035
+ ui.error(
4036
+ "that mixes both spellings",
4037
+ 'either "set KEY VALUE" or "set KEY=VALUE", not both'
4038
+ );
4039
+ return 2;
4040
+ }
4041
+ const joined = second === void 0 ? first : `${first}=${second}`;
4042
+ const assignment = parseAssignment(joined);
4043
+ if (typeof assignment === "string") {
4044
+ ui.error(assignment);
4045
+ return 2;
4046
+ }
4047
+ const located = locateCatalogs({ cwd, env });
4048
+ if (!existsSync19(located.project)) {
4049
+ ui.error("no catalog here", located.project);
4050
+ ui.info("run envs init first");
4051
+ return 1;
4052
+ }
4053
+ const unlock = resolveUnlock(one(args, "recovery-code"), env);
4054
+ if (typeof unlock === "string") {
4055
+ ui.error(unlock);
4056
+ return 2;
4057
+ }
4058
+ const db = openDatabaseSync(located.project);
4059
+ try {
4060
+ const dek = unlockDek(readWraps(db), unlock);
4061
+ const sources = db.prepare(
4062
+ "SELECT source_id, alias, path FROM sources WHERE retired_at IS NULL ORDER BY added_at"
4063
+ ).all();
4064
+ const wanted = one(args, "source");
4065
+ let target;
4066
+ if (wanted !== void 0) {
4067
+ target = sources.find((source) => source.alias === wanted);
4068
+ if (target === void 0) {
4069
+ ui.error(
4070
+ `no source "${wanted}"`,
4071
+ sources.map((s) => s.alias).join(", ")
4072
+ );
4073
+ return 1;
4074
+ }
4075
+ } else if (sources.length === 1) {
4076
+ target = sources[0];
4077
+ } else if (sources.length === 0) {
4078
+ const created = ensureSource(db, "envs:set", { alias: "set" });
4079
+ target = {
4080
+ source_id: created.sourceId,
4081
+ alias: created.alias,
4082
+ path: created.path
4083
+ };
4084
+ ui.info(
4085
+ `created source "${created.alias}"`,
4086
+ "values set by hand live here"
4087
+ );
4088
+ } else {
4089
+ ui.error(
4090
+ "more than one source; say which with --source",
4091
+ sources.map((s) => s.alias).join(", ")
4092
+ );
4093
+ return 2;
4094
+ }
4095
+ const items = [];
4096
+ let replaced = false;
4097
+ try {
4098
+ for (const entry of readEntries(db, { unlock })) {
4099
+ if (entry.sourceId === target.source_id && entry.key === assignment.key) {
4100
+ replaced = true;
4101
+ continue;
4102
+ }
4103
+ items.push({
4104
+ sourceId: entry.sourceId,
4105
+ key: entry.key,
4106
+ value: entry.value
4107
+ });
4108
+ }
4109
+ } catch {
4110
+ }
4111
+ items.push({
4112
+ sourceId: target.source_id,
4113
+ key: assignment.key,
4114
+ value: assignment.value
4115
+ });
4116
+ const release = writeRelease(db, dek, items, {
4117
+ note: `set ${assignment.key} in ${target.alias}`
4118
+ });
4119
+ const level = one(args, "sensitivity");
4120
+ if (level !== void 0) {
4121
+ if (!isSensitivity(level)) {
4122
+ ui.error(
4123
+ `"${level}" is not a level`,
4124
+ "low, medium, high or critical"
4125
+ );
4126
+ return 2;
4127
+ }
4128
+ db.prepare(
4129
+ "UPDATE keys SET sensitivity = $level WHERE key_hash = $hash"
4130
+ ).run({ level, hash: hashKeyName(dek, assignment.key) });
4131
+ ui.info(`classified ${assignment.key}`, level);
4132
+ }
4133
+ ui.success(
4134
+ `${replaced ? "replaced" : "added"} ${assignment.key}`,
4135
+ `source ${target.alias}`
4136
+ );
4137
+ ui.success(
4138
+ `release ${release.revisionId.slice(0, 8)} is current`,
4139
+ `${release.count} values`
4140
+ );
4141
+ return 0;
4142
+ } catch (error) {
4143
+ ui.error(error.message);
4144
+ return 1;
4145
+ } finally {
4146
+ db.close();
4147
+ }
4148
+ }
4149
+ };
4150
+
4151
+ // src/commands/team.ts
4152
+ var USAGE = "envs team <ls|invite|join <code>|remove <member>>";
4153
+ var report2 = (ui, error) => {
4154
+ if (error instanceof RemoteError) {
4155
+ ui.error(error.message, error.detail);
4156
+ return 1;
4157
+ }
4158
+ throw error;
4159
+ };
4160
+ async function list({ ui, env }) {
4161
+ const who = await account(await access(env));
4162
+ ui.heading(who.userId);
4163
+ if (!who.teamManagement) {
4164
+ ui.warn(
4165
+ "team sharing is not on this plan",
4166
+ "invites will be refused; see envs.build for the plan that carries it"
4167
+ );
4168
+ }
4169
+ if (who.members.length === 0) ui.info("shared with", "nobody yet");
4170
+ else ui.table(who.members.map((member) => [member, "member"]));
4171
+ if (who.teams.length > 0) {
4172
+ ui.table(who.teams.map((owner) => [owner, "you are a member"]));
4173
+ }
4174
+ return 0;
4175
+ }
4176
+ async function invite({ ui, env }) {
4177
+ const response = await hub(await access(env), {
4178
+ method: "POST",
4179
+ path: "/v1/invites"
4180
+ });
4181
+ const body = await response.json();
4182
+ if (typeof body.invite !== "string") {
4183
+ ui.error("the remote answered without an invite");
4184
+ return 1;
4185
+ }
4186
+ ui.data(`${body.invite}
4187
+ `);
4188
+ ui.success("invite created", "one use only, and it is not stored anywhere");
4189
+ if (typeof body.expiresAt === "string") ui.info("expires", body.expiresAt);
4190
+ ui.info(
4191
+ "they also need the key",
4192
+ "send ENVS_KEK or a recovery code separately; the invite alone opens nothing"
4193
+ );
4194
+ return 0;
4195
+ }
4196
+ async function join5(ctx, code) {
4197
+ const { ui, env } = ctx;
4198
+ const response = await hub(await access(env), {
4199
+ method: "POST",
4200
+ path: "/v1/invites/accept",
4201
+ body: new TextEncoder().encode(JSON.stringify({ invite: code })),
4202
+ headers: { "content-type": "application/json" }
4203
+ });
4204
+ const body = await response.json();
4205
+ ui.success(
4206
+ "joined",
4207
+ typeof body.owner === "string" ? `shared by ${body.owner}` : void 0
4208
+ );
4209
+ ui.info(
4210
+ "next",
4211
+ "ask the owner for the key, then envs restore --provider envs"
4212
+ );
4213
+ return 0;
4214
+ }
4215
+ async function remove(ctx, member) {
4216
+ const { ui, env } = ctx;
4217
+ await hub(await access(env), {
4218
+ method: "DELETE",
4219
+ path: `/v1/members/${encodeURIComponent(member)}`
4220
+ });
4221
+ ui.success("removed", member);
4222
+ ui.warn(
4223
+ "rotate the key if they had it",
4224
+ "envs rotate --key, then back up again"
4225
+ );
4226
+ return 0;
4227
+ }
4228
+ var teamCommand = {
4229
+ name: "team",
4230
+ describe: "share a hosted catalog, and see who has it",
4231
+ usage: USAGE,
4232
+ group: "hosted account",
4233
+ async run(ctx) {
4234
+ const [verb, argument] = ctx.args.positional;
4235
+ try {
4236
+ switch (verb) {
4237
+ case void 0:
4238
+ case "ls":
4239
+ return await list(ctx);
4240
+ case "invite":
4241
+ return await invite(ctx);
4242
+ case "join":
4243
+ if (argument === void 0) {
4244
+ ctx.ui.error("say which code", "envs team join <code>");
4245
+ return 2;
4246
+ }
4247
+ return await join5(ctx, argument);
4248
+ case "remove":
4249
+ if (argument === void 0) {
4250
+ ctx.ui.error("say which member", "envs team remove <member>");
4251
+ return 2;
4252
+ }
4253
+ return await remove(ctx, argument);
4254
+ default:
4255
+ ctx.ui.error(`unknown "${verb}"`, USAGE);
4256
+ return 2;
4257
+ }
4258
+ } catch (error) {
4259
+ return report2(ctx.ui, error);
4260
+ }
4261
+ }
4262
+ };
4263
+
4264
+ // src/commands/validate.ts
4265
+ import { readFileSync as readFileSync11 } from "fs";
4266
+ var validateCommand = {
4267
+ name: "validate",
4268
+ describe: "check that files are env format",
4269
+ usage: "envs validate <path>...",
4270
+ group: "check",
4271
+ run({ ui, args }) {
4272
+ if (args.positional.length === 0) {
4273
+ ui.error("no paths given", "envs validate <path>...");
4274
+ return 2;
4275
+ }
4276
+ let failed = 0;
4277
+ for (const path of args.positional) {
4278
+ let text;
4279
+ try {
4280
+ text = readFileSync11(path, "utf8");
4281
+ } catch (error) {
4282
+ ui.error(path, error.message);
4283
+ failed += 1;
4284
+ continue;
4285
+ }
4286
+ const result = parseEnv(text);
4287
+ if (result.ok) {
4288
+ const keys = result.entries.filter(
4289
+ (e) => e.kind === "assignment"
4290
+ ).length;
4291
+ ui.success(path, `${keys} keys`);
4292
+ continue;
4293
+ }
4294
+ failed += 1;
4295
+ ui.error(path, `${result.findings.length} problems`);
4296
+ ui.table(
4297
+ result.findings.map((finding) => [
4298
+ `line ${finding.line}`,
4299
+ finding.code
4300
+ ]),
4301
+ " "
4302
+ );
4303
+ }
4304
+ return failed === 0 ? 0 : 1;
4305
+ }
4306
+ };
4307
+
4308
+ // src/commands/watch.ts
4309
+ import { randomUUID as randomUUID3 } from "crypto";
4310
+ import { existsSync as existsSync20, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
4311
+ import { join as join6, relative as relative3, resolve as resolve5, sep as sep2 } from "path";
4312
+
4313
+ // src/catalog/glob.ts
4314
+ function globToRegExp(pattern) {
4315
+ let source = "^";
4316
+ for (let i = 0; i < pattern.length; i += 1) {
4317
+ const ch = pattern[i];
4318
+ if (ch === "*") {
4319
+ if (pattern[i + 1] === "*") {
4320
+ const slash = pattern[i + 2] === "/";
4321
+ source += slash ? "(?:.*/)?" : ".*";
4322
+ i += slash ? 2 : 1;
4323
+ continue;
4324
+ }
4325
+ source += "[^/]*";
4326
+ continue;
4327
+ }
4328
+ if (ch === "?") {
4329
+ source += "[^/]";
4330
+ continue;
4331
+ }
4332
+ source += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
4333
+ }
4334
+ return new RegExp(`${source}$`);
4335
+ }
4336
+ function matchesGlob(pattern, path) {
4337
+ return globToRegExp(pattern).test(path);
4338
+ }
4339
+ function selected(path, includes, excludes) {
4340
+ if (excludes.some((pattern) => matchesGlob(pattern, path))) return false;
4341
+ return includes.some((pattern) => matchesGlob(pattern, path));
4342
+ }
4343
+ var DEFAULT_INCLUDES = [".env", ".env.*", "*.env"];
4344
+ var DEFAULT_EXCLUDES = [
4345
+ "**/.env.example",
4346
+ "**/.env.sample",
4347
+ "**/.env.template",
4348
+ "**/node_modules/**",
4349
+ "**/.envs/**"
4350
+ ];
4351
+
4352
+ // src/commands/watch.ts
4353
+ function readTargets(db) {
4354
+ return db.prepare(
4355
+ "SELECT pattern, mode FROM watch_targets ORDER BY added_at, pattern"
4356
+ ).all().map((row) => ({
4357
+ pattern: row.pattern,
4358
+ mode: row.mode === "exclude" ? "exclude" : "include"
4359
+ }));
4360
+ }
4361
+ function scan(root, targets) {
4362
+ const includes = [
4363
+ ...DEFAULT_INCLUDES,
4364
+ ...targets.filter((t) => t.mode === "include").map((t) => t.pattern)
4365
+ ];
4366
+ const excludes = [
4367
+ ...DEFAULT_EXCLUDES,
4368
+ ...targets.filter((t) => t.mode === "exclude").map((t) => t.pattern)
4369
+ ];
4370
+ const found = [];
4371
+ const walk = (dir, depth) => {
4372
+ if (depth > 8) return;
4373
+ let entries;
4374
+ try {
4375
+ entries = readdirSync2(dir, { withFileTypes: true });
4376
+ } catch {
4377
+ return;
4378
+ }
4379
+ for (const entry of entries) {
4380
+ const full = join6(dir, entry.name);
4381
+ const rel = relative3(root, full).split(sep2).join("/");
4382
+ if (entry.isDirectory()) {
4383
+ if (selected(`${rel}/`, ["**"], excludes)) walk(full, depth + 1);
4384
+ continue;
4385
+ }
4386
+ if (selected(rel, includes, excludes)) found.push(full);
4387
+ }
4388
+ };
4389
+ walk(root, 0);
4390
+ return found.sort();
4391
+ }
4392
+ function open2(cwd, env) {
4393
+ const located = locateCatalogs({ cwd, env });
4394
+ if (!existsSync20(located.project)) return located.project;
4395
+ return { db: openDatabaseSync(located.project), located };
4396
+ }
4397
+ var SUB = /* @__PURE__ */ new Set(["add", "exclude", "list", "remove", "scan"]);
4398
+ var watchCommand = {
4399
+ name: "watch",
4400
+ describe: "choose which env files this project looks at",
4401
+ usage: "envs watch add|exclude|remove <pattern> | envs watch list|scan",
4402
+ group: "backup",
4403
+ run({ ui, args, env, cwd }) {
4404
+ const [sub, ...rest] = args.positional;
4405
+ if (sub === void 0 || !SUB.has(sub)) {
4406
+ ui.error(
4407
+ sub === void 0 ? "no subcommand" : `unknown subcommand "${sub}"`,
4408
+ [...SUB].join(", ")
4409
+ );
4410
+ return 2;
4411
+ }
4412
+ const opened = open2(cwd, env);
4413
+ if (typeof opened === "string") {
4414
+ ui.error("no catalog here", opened);
4415
+ ui.info("run envs init first");
4416
+ return 1;
4417
+ }
4418
+ const { db, located } = opened;
4419
+ const root = located.projectRoot ?? cwd;
4420
+ try {
4421
+ if (sub === "list") {
4422
+ const targets = readTargets(db);
4423
+ ui.heading("always looked at");
4424
+ ui.table(DEFAULT_INCLUDES.map((pattern2) => [pattern2, "default"]));
4425
+ ui.heading("never looked at");
4426
+ ui.table(DEFAULT_EXCLUDES.map((pattern2) => [pattern2, "default"]));
4427
+ if (targets.length > 0) {
4428
+ ui.line();
4429
+ ui.heading("added here");
4430
+ ui.table(targets.map((target) => [target.pattern, target.mode]));
4431
+ }
4432
+ return 0;
4433
+ }
4434
+ if (sub === "scan") {
4435
+ const paths = scan(root, readTargets(db));
4436
+ if (paths.length === 0) {
4437
+ ui.info("nothing matched", "envs watch add <pattern> widens it");
4438
+ return 0;
4439
+ }
4440
+ ui.heading(`${paths.length} files`);
4441
+ ui.table(paths.map((path) => [relative3(root, path), ""]));
4442
+ ui.info("envs load <path>...", "puts them in a release");
4443
+ return 0;
4444
+ }
4445
+ const pattern = rest[0];
4446
+ if (pattern === void 0 || rest.length > 1) {
4447
+ ui.error("give exactly one pattern", `envs watch ${sub} <pattern>`);
4448
+ return 2;
4449
+ }
4450
+ if (sub === "remove") {
4451
+ const gone = db.prepare("DELETE FROM watch_targets WHERE pattern = $pattern").run({ pattern });
4452
+ if (Number(gone.changes) === 0) {
4453
+ ui.error(`no target "${pattern}"`, "envs watch list shows them");
4454
+ return 1;
4455
+ }
4456
+ audit(db, "watch.remove", pattern);
4457
+ ui.success("removed", pattern);
4458
+ return 0;
4459
+ }
4460
+ const stored = pattern.startsWith("/") ? relative3(root, resolve5(pattern)).split(sep2).join("/") : pattern;
4461
+ const mode = sub === "exclude" ? "exclude" : "include";
4462
+ try {
4463
+ db.prepare(
4464
+ "INSERT INTO watch_targets (target_id, pattern, mode, added_at) VALUES ($id, $pattern, $mode, $at)"
4465
+ ).run({
4466
+ id: randomUUID3(),
4467
+ pattern: stored,
4468
+ mode,
4469
+ at: (/* @__PURE__ */ new Date()).toISOString()
4470
+ });
4471
+ } catch {
4472
+ ui.error(
4473
+ `"${stored}" is already a target`,
4474
+ "envs watch list shows them"
4475
+ );
4476
+ return 1;
4477
+ }
4478
+ audit(db, `watch.${mode}`, stored);
4479
+ ui.success(`${mode} ${stored}`);
4480
+ if (mode === "include") {
4481
+ const absolute = resolve5(root, stored);
4482
+ if (existsSync20(absolute) && statSync3(absolute).isDirectory()) {
4483
+ ui.warn(
4484
+ "that is a directory, so it matches no file by itself",
4485
+ `did you mean ${stored}/**`
4486
+ );
4487
+ }
4488
+ }
4489
+ const matched = scan(root, readTargets(db)).length;
4490
+ ui.info(`${matched} files match now`, "envs watch scan lists them");
4491
+ return 0;
4492
+ } finally {
4493
+ db.close();
4494
+ }
4495
+ }
4496
+ };
4497
+
4498
+ // src/commands/index.ts
4499
+ var COMMANDS = [
4500
+ initCommand,
4501
+ addCommand,
4502
+ loadCommand,
4503
+ setCommand,
4504
+ getCommand,
4505
+ delCommand,
4506
+ lsCommand,
4507
+ runCommand,
4508
+ validateCommand,
4509
+ doctorCommand,
4510
+ historyCommand,
4511
+ rollbackCommand,
4512
+ exportCommand,
4513
+ rotateCommand,
4514
+ genexampleCommand,
4515
+ gitignoreCommand,
4516
+ precommitCommand,
4517
+ watchCommand,
4518
+ backupCommand,
4519
+ restoreCommand,
4520
+ buildCommand,
4521
+ serveCommand,
4522
+ templateCommand,
4523
+ migrateCommand,
4524
+ loginCommand,
4525
+ logoutCommand,
4526
+ whoamiCommand,
4527
+ teamCommand
4528
+ ];
4529
+ var PLANNED = [];
4530
+ function dispatch(argv, options = {}) {
4531
+ const ui = options.ui ?? new Ui();
4532
+ const env = options.env ?? process.env;
4533
+ const cwd = options.cwd ?? process.cwd();
4534
+ const [verb, ...rest] = argv;
4535
+ if (verb === void 0) {
4536
+ printHelp(ui, COMMANDS, PLANNED);
4537
+ return 2;
4538
+ }
4539
+ if (verb === "--help" || verb === "-h" || verb === "help") {
4540
+ const named = COMMANDS.find((command2) => command2.name === rest[0]);
4541
+ if (named) printCommandHelp(ui, named);
4542
+ else printHelp(ui, COMMANDS, PLANNED);
4543
+ return 0;
4544
+ }
4545
+ const command = COMMANDS.find((candidate) => candidate.name === verb);
4546
+ if (command === void 0) {
4547
+ if (PLANNED.includes(verb)) {
4548
+ ui.error(`"${verb}" is designed but not implemented yet`);
4549
+ } else {
4550
+ ui.error(`unknown command "${verb}"`);
4551
+ }
4552
+ printHelp(ui, COMMANDS, PLANNED);
4553
+ return 2;
4554
+ }
4555
+ if (rest.includes("--help") || rest.includes("-h")) {
4556
+ printCommandHelp(ui, command);
4557
+ return 0;
4558
+ }
4559
+ const refuse = (error) => {
4560
+ if (error instanceof ArgumentError) {
4561
+ ui.error(error.message);
4562
+ printCommandHelp(ui, command);
4563
+ return 2;
4564
+ }
4565
+ ui.error(
4566
+ `envs ${command.name} could not finish`,
4567
+ error instanceof Error ? error.message : String(error)
4568
+ );
4569
+ return 1;
4570
+ };
4571
+ try {
4572
+ const outcome = command.run({
4573
+ ui,
4574
+ args: parseArgs(rest, command.options),
4575
+ env,
4576
+ cwd
4577
+ });
4578
+ return outcome instanceof Promise ? outcome.catch(refuse) : outcome;
4579
+ } catch (error) {
4580
+ return refuse(error);
4581
+ }
4582
+ }
4583
+
4584
+ export {
4585
+ csvField,
4586
+ exportValues,
4587
+ Ui,
4588
+ COMMANDS,
4589
+ PLANNED,
4590
+ dispatch
4591
+ };