@jondotsoy/envs 0.1.8 → 0.1.9-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/envs.js CHANGED
@@ -13,6 +13,21 @@ var __esm = (fn, res, err) => () => {
13
13
  };
14
14
 
15
15
  // src/lint-rules.ts
16
+ function dotenvProblem(key, text) {
17
+ if (!/^[\w.-]+$/.test(key)) {
18
+ return "the name is not a valid variable name (only letters, digits, `_`, `.` and `-`)";
19
+ }
20
+ if (text.includes("'") && text.includes('"') && text.includes("`")) {
21
+ return "the value has single, double and backtick quotes at once, so no .env quoting can hold it as is";
22
+ }
23
+ if (/[\r\n]/.test(text)) {
24
+ return "the value has a line break, which a .env file cannot hold safely (write it on one line, with `\\n` escapes if the application expects them)";
25
+ }
26
+ return;
27
+ }
28
+ function hasSecretPattern(text) {
29
+ return SECRET_PATTERNS.some(([pattern]) => pattern.test(text));
30
+ }
16
31
  function isSensitiveKey(key) {
17
32
  return SENSITIVE_KEY.test(key);
18
33
  }
@@ -55,10 +70,11 @@ function parseUrl(text) {
55
70
  function envFiles(ctx) {
56
71
  return [
57
72
  { location: valuesFile, mode: ctx.valuesMode },
73
+ ...ctx.hasHistory ? [{ location: historyFile, mode: ctx.historyMode }] : [],
58
74
  ...ctx.dotenvs.map((d) => ({ location: `${d.name}/.env`, mode: d.mode }))
59
75
  ];
60
76
  }
61
- var SENSITIVE_KEY, WEAK_VALUES, SECRET_PATTERNS, valuesFile = ".envs/values.yml", rules;
77
+ var SENSITIVE_KEY, WEAK_VALUES, SECRET_PATTERNS, valuesFile = ".envs/values.yml", historyFile = ".envs/history.ndjson", rules;
62
78
  var init_lint_rules = __esm(() => {
63
79
  SENSITIVE_KEY = /(^|_)(SECRET|PASSWORD|PASSWD|PWD|TOKEN|API_?KEY|PRIVATE_?KEY|ACCESS_?KEY|CREDENTIALS?|AUTH|SALT|SIGNING)(_|$)/i;
64
80
  WEAK_VALUES = new Set([
@@ -120,17 +136,11 @@ var init_lint_rules = __esm(() => {
120
136
  },
121
137
  {
122
138
  id: "open-permissions",
123
- description: "values.yml is readable by other users.",
124
- check({ valuesMode }) {
125
- if (valuesMode === undefined || (valuesMode & 63) === 0)
126
- return [];
127
- return [
128
- {
129
- location: valuesFile,
130
- message: `values.yml is readable by other users (mode ${(valuesMode & 511).toString(8)}). Run \`chmod 600 .envs/values.yml\`.`
131
- }
132
- ];
133
- }
139
+ description: "values.yml, the history or a worktree's .env is readable by other users.",
140
+ check: (ctx) => envFiles(ctx).filter(({ mode }) => mode !== undefined && (mode & 63) !== 0).map(({ location, mode }) => ({
141
+ location,
142
+ message: `${location.replace(/^\.envs\//, "")} is readable by other users (mode ${(mode & 511).toString(8)}). Run \`chmod 600 ${location}\`.`
143
+ }))
134
144
  },
135
145
  {
136
146
  id: "executable-files",
@@ -148,6 +158,38 @@ var init_lint_rules = __esm(() => {
148
158
  message: `The file is writable by group or other users (mode ${(f.mode & 511).toString(8)}), who could change your environment. Run \`chmod go-w\` on it.`
149
159
  }))
150
160
  },
161
+ {
162
+ id: "tracked-history",
163
+ description: "The history (.envs/history.ndjson) is tracked by git.",
164
+ async check(ctx) {
165
+ if (!ctx.hasHistory || !await ctx.isTracked(ctx.root, historyFile))
166
+ return [];
167
+ return [
168
+ {
169
+ location: historyFile,
170
+ message: "The history is tracked by git, and it holds the previous and the new value of every variable, secrets included. Run `git rm --cached .envs/history.ndjson`."
171
+ }
172
+ ];
173
+ }
174
+ },
175
+ {
176
+ id: "unignored-history",
177
+ description: "The history (.envs/history.ndjson) is neither tracked nor ignored by git.",
178
+ async check(ctx) {
179
+ if (!ctx.hasHistory)
180
+ return [];
181
+ if (await ctx.isTracked(ctx.root, historyFile))
182
+ return [];
183
+ if (await ctx.isIgnored(ctx.root, historyFile))
184
+ return [];
185
+ return [
186
+ {
187
+ location: historyFile,
188
+ message: "The history is not ignored by git and could be committed by accident, with its secrets. Add `*` to .envs/.gitignore."
189
+ }
190
+ ];
191
+ }
192
+ },
151
193
  {
152
194
  id: "tracked-dotenv",
153
195
  description: "A worktree's .env is tracked by git.",
@@ -235,12 +277,162 @@ var init_lint_rules = __esm(() => {
235
277
  location: `uses.${worktree}`,
236
278
  message: `Profile "${profile}" is not defined under profiles; \`envs edit\` creates it from default.`
237
279
  }))
280
+ },
281
+ {
282
+ id: "unsafe-dotenv-value",
283
+ description: "A variable name or value cannot be written to a .env file safely (line break, invalid name).",
284
+ check: (ctx) => entries(ctx).flatMap((e) => {
285
+ const problem = dotenvProblem(e.key, e.text);
286
+ return problem ? [{ location: e.location, message: `${JSON.stringify(e.key)}: ${problem}.` }] : [];
287
+ })
288
+ },
289
+ {
290
+ id: "unknown-worktree",
291
+ description: "values.yml has values for a worktree that git does not list (deleted, or its branch was renamed).",
292
+ check({ values, worktrees }) {
293
+ if (!worktrees)
294
+ return [];
295
+ const known = new Set(worktrees);
296
+ const found = [];
297
+ const note = (location, name) => found.push({
298
+ location,
299
+ message: `"${name}" is not a git worktree anymore (it was deleted, or its branch renamed). Its values stay in values.yml but nothing uses them: remove them with \`envs edit\`.`
300
+ });
301
+ for (const name of Object.keys(values.uses ?? {}))
302
+ if (!known.has(name))
303
+ note(`uses.${name}`, name);
304
+ const scan = (prefix, envs) => {
305
+ for (const [variable, byWorktree] of Object.entries(envs ?? {})) {
306
+ for (const name of Object.keys(byWorktree ?? {}))
307
+ if (!known.has(name))
308
+ note(`${prefix}envs.${variable}.${name}`, name);
309
+ }
310
+ };
311
+ scan("", values.envs);
312
+ for (const [profile, section] of Object.entries(values.profiles ?? {}))
313
+ scan(`profiles.${profile}.`, section?.envs);
314
+ return found;
315
+ }
238
316
  }
239
317
  ];
240
318
  });
241
319
 
320
+ // src/history.ts
321
+ function cloneProfile(profile) {
322
+ const copy = emptyProfile();
323
+ for (const [key, value] of Object.entries(profile.defaults))
324
+ put(copy.defaults, key, value);
325
+ for (const [key, byWorktree] of Object.entries(profile.envs)) {
326
+ const target = dict();
327
+ for (const [worktree, value] of Object.entries(byWorktree))
328
+ put(target, worktree, value);
329
+ put(copy.envs, key, target);
330
+ }
331
+ return copy;
332
+ }
333
+ function cloneState(state) {
334
+ const copy = emptyState();
335
+ for (const [worktree, profile] of Object.entries(state.uses))
336
+ put(copy.uses, worktree, profile);
337
+ for (const [name, profile] of Object.entries(state.profiles))
338
+ put(copy.profiles, name, cloneProfile(profile));
339
+ return copy;
340
+ }
341
+ function diffValues(before, after) {
342
+ const changes = [];
343
+ const compare = (path, a, b) => {
344
+ if (a !== b)
345
+ changes.push({ path, ...a !== undefined && { from: a }, ...b !== undefined && { to: b } });
346
+ };
347
+ for (const w of keys(before.uses, after.uses))
348
+ compare(["uses", w], own(before.uses, w), own(after.uses, w));
349
+ for (const p of keys(before.profiles, after.profiles)) {
350
+ const a = own(before.profiles, p);
351
+ const b = own(after.profiles, p);
352
+ if (p !== DEFAULT_PROFILE && !a && b)
353
+ changes.push({ path: ["profiles", p], to: true });
354
+ for (const k of keys(a?.defaults, b?.defaults)) {
355
+ compare(["profiles", p, "defaults", k], own(a?.defaults, k), own(b?.defaults, k));
356
+ }
357
+ for (const k of keys(a?.envs, b?.envs)) {
358
+ const ea = own(a?.envs, k);
359
+ const eb = own(b?.envs, k);
360
+ for (const w of keys(ea, eb))
361
+ compare(["profiles", p, "envs", k, w], own(ea, w), own(eb, w));
362
+ }
363
+ if (p !== DEFAULT_PROFILE && a && !b)
364
+ changes.push({ path: ["profiles", p], from: true });
365
+ }
366
+ return changes;
367
+ }
368
+ function applyChanges(state, changes) {
369
+ for (const { path, to } of changes) {
370
+ const [section, ...rest] = path;
371
+ if (section === "uses") {
372
+ const [worktree] = rest;
373
+ if (to === undefined)
374
+ delete state.uses[worktree];
375
+ else
376
+ put(state.uses, worktree, String(to));
377
+ continue;
378
+ }
379
+ const [profileName, kind, variable, worktree] = rest;
380
+ if (kind === undefined) {
381
+ if (to === undefined)
382
+ delete state.profiles[profileName];
383
+ else if (!own(state.profiles, profileName))
384
+ put(state.profiles, profileName, emptyProfile());
385
+ continue;
386
+ }
387
+ let profile = own(state.profiles, profileName);
388
+ if (!profile)
389
+ put(state.profiles, profileName, profile = emptyProfile());
390
+ if (kind === "defaults") {
391
+ if (to === undefined)
392
+ delete profile.defaults[variable];
393
+ else
394
+ put(profile.defaults, variable, to);
395
+ } else {
396
+ let byWorktree = own(profile.envs, variable);
397
+ if (!byWorktree)
398
+ put(profile.envs, variable, byWorktree = dict());
399
+ if (to === undefined)
400
+ delete byWorktree[worktree];
401
+ else
402
+ put(byWorktree, worktree, to);
403
+ if (Object.keys(byWorktree).length === 0)
404
+ delete profile.envs[variable];
405
+ }
406
+ }
407
+ }
408
+ function findHistoryPoint(points, hash) {
409
+ const matches = hash === "" ? [] : points.filter((point) => point.hash.startsWith(hash));
410
+ if (matches.length === 1)
411
+ return { point: matches[0] };
412
+ return { error: matches.length === 0 ? "none" : "ambiguous", matches: matches.length };
413
+ }
414
+ function withDefaults(state) {
415
+ for (const name of [DEFAULT_PROFILE, ...Object.values(state.uses)]) {
416
+ if (!own(state.profiles, name))
417
+ put(state.profiles, name, emptyProfile());
418
+ }
419
+ return state;
420
+ }
421
+ function stateAfter(points, target) {
422
+ const state = emptyState();
423
+ for (const point of points) {
424
+ applyChanges(state, point.changes);
425
+ if (point === target)
426
+ break;
427
+ }
428
+ withDefaults(state);
429
+ return state;
430
+ }
431
+ var DEFAULT_PROFILE = "default", REDACTED_PREFIX = "envs:redacted:", isRedacted = (value) => typeof value === "string" && value.startsWith(REDACTED_PREFIX), dict = () => Object.create(null), own = (map, key) => map !== undefined && Object.hasOwn(map, key) ? map[key] : undefined, put = (map, key, value) => void Object.defineProperty(map, key, { value, writable: true, enumerable: true, configurable: true }), emptyProfile = () => ({ defaults: dict(), envs: dict() }), emptyState = () => ({ uses: dict(), profiles: dict() }), keys = (a, b) => [...new Set([...Object.keys(a ?? {}), ...Object.keys(b ?? {})])];
432
+
242
433
  // src/ui/server.ts
243
434
  import { join } from "path";
435
+ import { randomBytes, timingSafeEqual } from "crypto";
244
436
  import { readdir } from "fs/promises";
245
437
  function parseBody(body) {
246
438
  if (!isRecord(body) || !isRecord(body.uses) || !isRecord(body.profiles))
@@ -257,7 +449,24 @@ function parseBody(body) {
257
449
  return;
258
450
  }
259
451
  }
260
- return body;
452
+ if (body.version !== undefined && typeof body.version !== "string")
453
+ return;
454
+ return { values: body, ...body.version !== undefined && { base: body.version } };
455
+ }
456
+ function parseRestoreBody(body) {
457
+ if (!isRecord(body) || typeof body.hash !== "string" || !/^[0-9a-f]{12}$/.test(body.hash))
458
+ return;
459
+ return body.hash;
460
+ }
461
+ function capture(ctx) {
462
+ const logs = [];
463
+ const errors = [];
464
+ const captured = {
465
+ ...ctx,
466
+ log: (m) => logs.push(m.replace(ANSI, "")),
467
+ error: (m) => errors.push(m.replace(ANSI, ""))
468
+ };
469
+ return { ctx: captured, logs, errors };
261
470
  }
262
471
  async function loadAssets() {
263
472
  const assets = new Map;
@@ -274,83 +483,328 @@ async function loadAssets() {
274
483
  }
275
484
  return assets;
276
485
  }
486
+ function sameToken(presented, token) {
487
+ if (!presented)
488
+ return false;
489
+ const a = Buffer.from(presented);
490
+ const b = Buffer.from(token);
491
+ return a.length === b.length && timingSafeEqual(a, b);
492
+ }
493
+ function presentedToken(req) {
494
+ const header = req.headers.get("x-envs-token");
495
+ if (header)
496
+ return header;
497
+ return req.headers.get("cookie")?.split(";").map((c) => c.trim().split("=")).find(([name]) => name === TOKEN_COOKIE)?.[1];
498
+ }
277
499
  async function serveUi(ctx) {
278
500
  const assets = await loadAssets();
501
+ const token = randomBytes(24).toString("base64url");
502
+ const guard = (handler) => async (req) => {
503
+ const port = server.port;
504
+ const hosts = [`127.0.0.1:${port}`, `localhost:${port}`];
505
+ if (!hosts.includes(req.headers.get("host") ?? "")) {
506
+ return new Response("Misdirected request", { status: 421 });
507
+ }
508
+ const origin = req.headers.get("origin");
509
+ if (origin !== null && !hosts.some((host) => origin === `http://${host}`)) {
510
+ return new Response("Forbidden origin", { status: 403 });
511
+ }
512
+ const url = new URL(req.url);
513
+ if (url.pathname === "/" && sameToken(url.searchParams.get("token"), token)) {
514
+ return new Response(null, {
515
+ status: 302,
516
+ headers: {
517
+ location: "/",
518
+ "set-cookie": `${TOKEN_COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/`
519
+ }
520
+ });
521
+ }
522
+ if (!sameToken(presentedToken(req), token)) {
523
+ return new Response("Unauthorized: open the address printed by `envs edit --ui`.", { status: 401 });
524
+ }
525
+ if (req.method !== "GET" && req.method !== "HEAD" && !req.headers.get("content-type")?.toLowerCase().startsWith("application/json")) {
526
+ return new Response("Unsupported media type", { status: 415 });
527
+ }
528
+ return handler(req);
529
+ };
279
530
  const server = Bun.serve({
280
531
  hostname: "127.0.0.1",
281
532
  port: 0,
533
+ maxRequestBodySize: MAX_BODY_BYTES,
282
534
  routes: {
283
535
  "/api/values": {
284
- GET: async () => Response.json(await loadUiState(ctx)),
285
- PUT: async (req) => {
286
- const values = parseBody(await req.json().catch(() => {
536
+ GET: guard(async () => Response.json(await loadUiState(ctx))),
537
+ PUT: guard(async (req) => {
538
+ const parsed = parseBody(await req.json().catch(() => {
287
539
  return;
288
540
  }));
289
- if (!values)
541
+ if (!parsed)
290
542
  return Response.json({ error: "Invalid values" }, { status: 400 });
291
- const logs = [];
292
- const errors = [];
293
- const capture = {
294
- ...ctx,
295
- log: (m) => logs.push(m.replace(ANSI, "")),
296
- error: (m) => errors.push(m.replace(ANSI, ""))
297
- };
298
- const { created, code } = await saveUiState(capture, values);
299
- return Response.json({ ok: code === 0, created, logs, errors }, { status: code === 0 ? 200 : 500 });
300
- }
543
+ const { ctx: captured, logs, errors } = capture(ctx);
544
+ const { created, code, conflict } = await saveUiState(captured, parsed.values, { base: parsed.base });
545
+ return Response.json({ ok: code === 0, created, logs, errors }, { status: code === 0 ? 200 : conflict ? 409 : 500 });
546
+ })
547
+ },
548
+ "/api/history": {
549
+ GET: guard(async () => Response.json(await loadUiHistory(ctx)))
550
+ },
551
+ "/api/restore": {
552
+ POST: guard(async (req) => {
553
+ const hash = parseRestoreBody(await req.json().catch(() => {
554
+ return;
555
+ }));
556
+ if (!hash)
557
+ return Response.json({ error: "Invalid hash" }, { status: 400 });
558
+ const root = await findRoot(ctx.cwd);
559
+ if (!await historyEnabled(root))
560
+ return Response.json({ error: "The history is off" }, { status: 409 });
561
+ if (!(await readHistoryPoints(root)).some((point) => point.hash === hash)) {
562
+ return Response.json({ error: "Unknown history point" }, { status: 404 });
563
+ }
564
+ const { ctx: captured, logs, errors } = capture(ctx);
565
+ const code = await restore(captured, hash);
566
+ return Response.json({ ok: code === 0, created: [], logs, errors }, { status: code === 0 ? 200 : 500 });
567
+ })
301
568
  }
302
569
  },
303
- fetch: (req) => {
570
+ fetch: guard((req) => {
304
571
  const path = new URL(req.url).pathname.slice(1) || "index.html";
305
572
  const asset = assets.get(path);
306
573
  return asset ? new Response(asset) : new Response("Not found", { status: 404 });
307
- }
574
+ })
308
575
  });
309
- const url = `http://127.0.0.1:${server.port}`;
576
+ const url = `http://127.0.0.1:${server.port}/?token=${token}`;
310
577
  ctx.log(`Web editor running at ${url} (Ctrl+C to stop)`);
311
578
  await ctx.openUrl?.(url);
312
579
  await (ctx.waitForExit ?? (() => new Promise(() => {})))();
313
580
  await server.stop(true);
314
581
  return 0;
315
582
  }
316
- var ANSI, isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v), isScalar = (v) => ["string", "number", "boolean"].includes(typeof v);
583
+ var ANSI, isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v), isScalar = (v) => ["string", "number", "boolean"].includes(typeof v), TOKEN_COOKIE = "envs_token", MAX_BODY_BYTES;
317
584
  var init_server = __esm(() => {
318
585
  init_envs();
319
586
  ANSI = /\x1b\[[0-9;]*m/g;
587
+ MAX_BODY_BYTES = 5 * 1024 * 1024;
320
588
  });
321
589
 
322
590
  // src/envs.ts
323
- import { mkdir } from "fs/promises";
591
+ import { AsyncLocalStorage } from "async_hooks";
592
+ import { chmod, mkdir, open, rename, unlink, writeFile } from "fs/promises";
324
593
  import { basename, dirname, join as join2 } from "path";
325
594
  import { stat } from "fs/promises";
595
+ import { createHash, createHmac, randomBytes as randomBytes2 } from "crypto";
596
+ import { appendFile, truncate } from "fs/promises";
326
597
  var {$ } = globalThis.Bun;
327
598
  function normalizeValues(raw) {
328
- const profiles = {};
599
+ const profiles = dict();
329
600
  const add = (name, profile) => {
330
- const target = profiles[name] ??= { defaults: {}, envs: {} };
601
+ const target = profiles[name] ??= { defaults: dict(), envs: dict() };
331
602
  Object.assign(target.defaults, profile?.defaults);
332
603
  for (const [key, byWorktree] of Object.entries(profile?.envs ?? {})) {
333
- Object.assign(target.envs[key] ??= {}, byWorktree);
604
+ Object.assign(target.envs[key] ??= dict(), byWorktree);
334
605
  }
335
606
  };
336
607
  add(DEFAULT_PROFILE, { defaults: raw.defaults, envs: raw.envs });
337
608
  for (const [name, profile] of Object.entries(raw.profiles ?? {})) {
338
609
  add(name, profile);
339
610
  }
340
- return { uses: { ...raw.uses }, profiles };
611
+ const uses = dict();
612
+ for (const [worktree, profile] of Object.entries(raw.uses ?? {}))
613
+ uses[worktree] = profile;
614
+ return { uses, profiles };
341
615
  }
342
616
  function materializeProfiles(values) {
343
617
  const created = [];
344
618
  for (const name of Object.values(values.uses).map(String)) {
345
619
  if (hasProfile(values, name))
346
620
  continue;
347
- values.profiles[name] = structuredClone(values.profiles[DEFAULT_PROFILE]);
621
+ values.profiles[name] = cloneProfile(values.profiles[DEFAULT_PROFILE]);
348
622
  created.push(name);
349
623
  }
350
624
  return created;
351
625
  }
626
+ async function keepsSecrets(root) {
627
+ return (await readConfig(join2(root, ".envs/config.yml")))["history.secrets"] === true;
628
+ }
629
+ async function historyKey(root) {
630
+ const path = join2(root, ".envs/history.key");
631
+ const read = async () => (await Bun.file(path).text()).trim();
632
+ if (await Bun.file(path).exists())
633
+ return await read();
634
+ await mkdir(join2(root, ".envs"), { recursive: true, mode: 448 });
635
+ try {
636
+ await writeFile(path, randomBytes2(32).toString("hex") + `
637
+ `, { mode: 384, flag: "wx" });
638
+ } catch (error) {
639
+ if (error.code !== "EEXIST")
640
+ throw error;
641
+ }
642
+ return await read();
643
+ }
644
+ async function redactor(root) {
645
+ if (await keepsSecrets(root))
646
+ return { mark: (_, value) => value, state: (state) => cloneState(state) };
647
+ const key = await historyKey(root);
648
+ const mark = (name, value) => REDACTED_PREFIX + createHmac("sha256", key).update(`${name}\x00${value}`).digest("hex").slice(0, 16);
649
+ const one = (name, value) => typeof value === "string" && isRedacted(value) ? value : isSecretEntry(name, String(value)) ? mark(name, String(value)) : value;
650
+ return {
651
+ mark,
652
+ state(state) {
653
+ const copy = cloneState(state);
654
+ for (const profile of Object.values(copy.profiles)) {
655
+ for (const [name, value] of Object.entries(profile.defaults))
656
+ profile.defaults[name] = one(name, value);
657
+ for (const [name, byWorktree] of Object.entries(profile.envs)) {
658
+ for (const [worktree, value] of Object.entries(byWorktree))
659
+ byWorktree[worktree] = one(name, value);
660
+ }
661
+ }
662
+ return copy;
663
+ }
664
+ };
665
+ }
666
+ function unredact(restored, current, mark) {
667
+ const notes = [];
668
+ for (const [profileName, profile] of Object.entries(restored.profiles)) {
669
+ const now = Object.hasOwn(current.profiles, profileName) ? current.profiles[profileName] : undefined;
670
+ const settle = (map, key, variable, where, currentValue) => {
671
+ const value = map[key];
672
+ if (!isRedacted(value))
673
+ return;
674
+ if (currentValue === undefined) {
675
+ delete map[key];
676
+ notes.push(`${variable} (${where}): not restored, the history does not keep its value`);
677
+ return;
678
+ }
679
+ map[key] = currentValue;
680
+ if (mark(variable, String(currentValue)) !== value) {
681
+ notes.push(`${variable} (${where}): kept the current value, the history does not keep the old one`);
682
+ }
683
+ };
684
+ for (const variable of Object.keys(profile.defaults)) {
685
+ const nowValue = now && Object.hasOwn(now.defaults, variable) ? now.defaults[variable] : undefined;
686
+ settle(profile.defaults, variable, variable, `${profileName} defaults`, nowValue);
687
+ }
688
+ for (const [variable, byWorktree] of Object.entries(profile.envs)) {
689
+ const nowByWorktree = now && Object.hasOwn(now.envs, variable) ? now.envs[variable] : undefined;
690
+ for (const worktree of Object.keys(byWorktree)) {
691
+ const nowValue = nowByWorktree && Object.hasOwn(nowByWorktree, worktree) ? nowByWorktree[worktree] : undefined;
692
+ settle(byWorktree, worktree, variable, `${profileName} ${worktree}`, nowValue);
693
+ }
694
+ if (Object.keys(byWorktree).length === 0)
695
+ delete profile.envs[variable];
696
+ }
697
+ }
698
+ return notes;
699
+ }
700
+ async function recordHistory(root, before, after, now = () => new Date, warn = () => {}) {
701
+ return await withLock(root, () => recordHistoryLocked(root, before, after, now, warn));
702
+ }
703
+ async function recordHistoryLocked(root, before, after, now, warn) {
704
+ if (!await historyEnabled(root))
705
+ return;
706
+ const historyPath = join2(root, ".envs/history.ndjson");
707
+ const scan = await scanHistory(root);
708
+ if (scan.torn)
709
+ warn(".envs/history.ndjson ended with an incomplete line (an interrupted write): it is ignored.");
710
+ if (scan.damaged.length > 0) {
711
+ warn(`.envs/history.ndjson has damaged lines (${scan.damaged.join(", ")}): they are ignored, so the history may be incomplete.`);
712
+ }
713
+ if (scan.broken.length > 0) {
714
+ warn(`.envs/history.ndjson: the hash chain is broken at ${describeBroken(scan.broken)}. The history may have been altered; \`envs restore\` refuses until it is checked.`);
715
+ }
716
+ const points = scan.points;
717
+ let prev = points.at(-1)?.hash ?? null;
718
+ let repaired = false;
719
+ const append = async (changes, extra = {}) => {
720
+ if (!repaired) {
721
+ repaired = true;
722
+ if (scan.torn)
723
+ await truncate(historyPath, Buffer.byteLength(scan.complete));
724
+ else if (!scan.endsWithNewline)
725
+ await appendFile(historyPath, `
726
+ `);
727
+ }
728
+ const point = { prev, timestamp: now().toISOString(), changes, ...extra };
729
+ const hash = createHash("sha256").update(JSON.stringify(point)).digest("hex").slice(0, 12);
730
+ await appendFile(historyPath, JSON.stringify({ hash, ...point }) + `
731
+ `, { mode: 384 });
732
+ prev = hash;
733
+ return hash;
734
+ };
735
+ const { state: redact } = await redactor(root);
736
+ const redactedBefore = redact(before);
737
+ const redactedAfter = redact(after);
738
+ if (points.length === 0) {
739
+ const changes = diffValues({ uses: {}, profiles: {} }, redactedAfter);
740
+ return changes.length === 0 ? undefined : await append(changes);
741
+ }
742
+ const drift = diffValues(redact(stateAfter(points, points.at(-1))), redactedBefore);
743
+ if (drift.length > 0)
744
+ await append(drift, { resync: true });
745
+ const changes = diffValues(redactedBefore, redactedAfter);
746
+ return changes.length === 0 ? undefined : await append(changes);
747
+ }
748
+ async function redactHistory(ctx) {
749
+ const root = await findRoot(ctx.cwd);
750
+ return await withLock(root, async () => {
751
+ const scan = await scanHistory(root);
752
+ if (scan.points.length === 0 && scan.damaged.length === 0) {
753
+ ctx.log("There is no history to redact.");
754
+ return 0;
755
+ }
756
+ if (scan.damaged.length > 0 || scan.broken.length > 0) {
757
+ ctx.error(".envs/history.ndjson is damaged or its hash chain is broken: check it before rewriting it.");
758
+ return 1;
759
+ }
760
+ const { mark } = await redactor(root);
761
+ let redacted = 0;
762
+ const redactValue = (variable, value) => {
763
+ if (value === undefined || isRedacted(value) || !isSecretEntry(variable, String(value)))
764
+ return value;
765
+ redacted++;
766
+ return mark(variable, String(value));
767
+ };
768
+ let prev = null;
769
+ const lines = [];
770
+ for (const old of scan.points) {
771
+ const changes = old.changes.map((change) => {
772
+ const [section, , kind, variable] = change.path;
773
+ if (section !== "profiles" || kind !== "defaults" && kind !== "envs" || variable === undefined)
774
+ return change;
775
+ const from = redactValue(variable, change.from);
776
+ const to = redactValue(variable, change.to);
777
+ return { path: change.path, ...from !== undefined && { from }, ...to !== undefined && { to } };
778
+ });
779
+ const point = { prev, timestamp: old.timestamp, changes, ...old.resync && { resync: true } };
780
+ const hash = createHash("sha256").update(JSON.stringify(point)).digest("hex").slice(0, 12);
781
+ lines.push(JSON.stringify({ hash, ...point }));
782
+ prev = hash;
783
+ }
784
+ if (redacted === 0) {
785
+ ctx.log("No secrets in clear in the history: nothing to redact.");
786
+ return 0;
787
+ }
788
+ const path = join2(root, ".envs/history.ndjson");
789
+ await writeFile(`${path}.tmp`, lines.join(`
790
+ `) + `
791
+ `, { mode: 384 });
792
+ await rename(`${path}.tmp`, path);
793
+ ctx.log(green(`\u2713 redacted ${redacted} secret value(s) in the history; its hash chain was rewritten (the hashes of its points changed)`));
794
+ return 0;
795
+ });
796
+ }
797
+ async function historyCommand(ctx, args) {
798
+ if (args[0] === "redact")
799
+ return await redactHistory(ctx);
800
+ ctx.error("Usage: envs history redact");
801
+ return 1;
802
+ }
803
+ async function historyEnabled(root) {
804
+ return (await readConfig(join2(root, ".envs/config.yml"))).history !== false;
805
+ }
352
806
  function resolveEntries(values, worktree, profile, includeProfileEnvs = true) {
353
- const entries = {};
807
+ const entries = dict();
354
808
  for (const name of new Set([DEFAULT_PROFILE, profile])) {
355
809
  const layer = values.profiles[name];
356
810
  for (const [key, value] of Object.entries(layer.defaults)) {
@@ -359,7 +813,7 @@ function resolveEntries(values, worktree, profile, includeProfileEnvs = true) {
359
813
  if (name === profile && !includeProfileEnvs)
360
814
  continue;
361
815
  for (const [key, byWorktree] of Object.entries(layer.envs)) {
362
- if (byWorktree && worktree in byWorktree) {
816
+ if (byWorktree && Object.hasOwn(byWorktree, worktree)) {
363
817
  entries[key] = String(byWorktree[worktree]);
364
818
  }
365
819
  }
@@ -407,7 +861,7 @@ async function listWorktrees(cwd) {
407
861
  });
408
862
  }
409
863
  function parseDotenv(text) {
410
- const result = {};
864
+ const result = dict();
411
865
  for (const raw of text.split(/\r?\n/)) {
412
866
  const line = raw.trim();
413
867
  if (!line || line.startsWith("#"))
@@ -439,29 +893,113 @@ function parseValue(value) {
439
893
  return value;
440
894
  }
441
895
  function formatDotenvValue(value) {
442
- if (value !== "" && !/[\s#"'\\$]/.test(value))
896
+ if (value !== "" && !/[\s#"'`\\$]/.test(value))
443
897
  return value;
444
- return value.includes('"') ? `'${value}'` : `"${value}"`;
898
+ if (!value.includes('"') && !/\\[nr]/.test(value))
899
+ return `"${value}"`;
900
+ if (!value.includes("'"))
901
+ return `'${value}'`;
902
+ return `\`${value}\``;
445
903
  }
446
- function updateDotenv(text, entries) {
904
+ function updateDotenv(text, entries, remove = new Set) {
447
905
  const pending = new Map(Object.entries(entries));
448
906
  const lines = text === "" ? [] : text.replace(/\n$/, "").split(`
449
907
  `);
450
- const updated = lines.map((line) => {
908
+ const updated = [];
909
+ for (const line of lines) {
451
910
  const key = line.trim().match(/^(?:export\s+)?([\w.-]+)\s*=/)?.[1];
452
- if (key === undefined || !pending.has(key))
453
- return line;
911
+ if (key !== undefined && remove.has(key) && !pending.has(key))
912
+ continue;
913
+ if (key === undefined || !pending.has(key)) {
914
+ updated.push(line);
915
+ continue;
916
+ }
454
917
  const value = pending.get(key);
455
918
  pending.delete(key);
456
- return `${key}=${formatDotenvValue(value)}`;
457
- });
919
+ updated.push(`${key}=${formatDotenvValue(value)}`);
920
+ }
458
921
  for (const [key, value] of pending) {
459
922
  updated.push(`${key}=${formatDotenvValue(value)}`);
460
923
  }
461
- return updated.join(`
924
+ return updated.length === 0 ? "" : updated.join(`
462
925
  `) + `
463
926
  `;
464
927
  }
928
+ async function managedVariables(root, values) {
929
+ const names = new Set;
930
+ for (const profile of Object.values(values.profiles)) {
931
+ for (const key of Object.keys(profile.defaults))
932
+ names.add(key);
933
+ for (const key of Object.keys(profile.envs))
934
+ names.add(key);
935
+ }
936
+ const points = await readHistoryPoints(root);
937
+ for (const { changes } of points) {
938
+ for (const { path } of changes) {
939
+ if (path[0] === "profiles" && (path[2] === "defaults" || path[2] === "envs") && path[3] !== undefined) {
940
+ names.add(path[3]);
941
+ }
942
+ }
943
+ }
944
+ return names;
945
+ }
946
+ async function hardenPermissions(root) {
947
+ if (process.platform === "win32")
948
+ return;
949
+ const dir = join2(root, ".envs");
950
+ const tighten = async (path, mode) => {
951
+ const current = await stat(path).catch(() => {
952
+ return;
953
+ });
954
+ if (current && (current.mode & 511 & ~mode) !== 0)
955
+ await chmod(path, mode).catch(() => {});
956
+ };
957
+ await tighten(dir, 448);
958
+ for (const file of ["values.yml", "history.ndjson", "history.key", "config.yml"])
959
+ await tighten(join2(dir, file), 384);
960
+ }
961
+ async function withLock(root, fn) {
962
+ const held = heldLocks.getStore();
963
+ if (held?.has(root))
964
+ return await fn();
965
+ const dir = join2(root, ".envs");
966
+ if (!await Bun.file(join2(dir, ".gitignore")).exists() && !await Bun.file(join2(dir, "values.yml")).exists()) {
967
+ return await fn();
968
+ }
969
+ await hardenPermissions(root);
970
+ const path = join2(dir, "lock");
971
+ const deadline = Date.now() + 30000;
972
+ for (;; ) {
973
+ try {
974
+ const handle = await open(path, "wx", 384);
975
+ await handle.writeFile(`${process.pid} ${Date.now()}
976
+ `);
977
+ await handle.close();
978
+ break;
979
+ } catch (error) {
980
+ if (error.code !== "EEXIST")
981
+ throw error;
982
+ const text = await Bun.file(path).text().catch(() => "");
983
+ const pid = Number(text.split(" ")[0]);
984
+ const age = Date.now() - ((await stat(path).catch(() => {
985
+ return;
986
+ }))?.mtimeMs ?? Date.now());
987
+ if (Number.isInteger(pid) && pid > 0 && !processAlive(pid) || age > 120000) {
988
+ await unlink(path).catch(() => {});
989
+ continue;
990
+ }
991
+ if (Date.now() > deadline) {
992
+ throw new LockError(`.envs/lock is held by another envs command (pid ${Number.isInteger(pid) ? pid : "unknown"}). Wait for it, or remove .envs/lock if it is not running.`);
993
+ }
994
+ await Bun.sleep(15 + Math.random() * 35);
995
+ }
996
+ }
997
+ try {
998
+ return await heldLocks.run(new Set([...held ?? [], root]), fn);
999
+ } finally {
1000
+ await unlink(path).catch(() => {});
1001
+ }
1002
+ }
465
1003
  async function readValues(ctx) {
466
1004
  const path = join2(await findRoot(ctx.cwd), ".envs/values.yml");
467
1005
  if (!await Bun.file(path).exists()) {
@@ -475,7 +1013,7 @@ async function init(ctx) {
475
1013
  const root = await findRoot(ctx.cwd);
476
1014
  const files = { ".envs/.gitignore": `*
477
1015
  `, ".envs/values.yml": SCHEMA_COMMENT };
478
- await mkdir(join2(root, ".envs"), { recursive: true });
1016
+ await mkdir(join2(root, ".envs"), { recursive: true, mode: 448 });
479
1017
  for (const [name, content] of Object.entries(files)) {
480
1018
  const path = join2(root, name);
481
1019
  if (await Bun.file(path).exists()) {
@@ -485,9 +1023,90 @@ async function init(ctx) {
485
1023
  ctx.log(`Created ${path}`);
486
1024
  }
487
1025
  }
1026
+ await hardenPermissions(root);
488
1027
  return 0;
489
1028
  }
1029
+ async function readConfig(path) {
1030
+ if (!await Bun.file(path).exists())
1031
+ return {};
1032
+ return Bun.YAML.parse(await Bun.file(path).text()) ?? {};
1033
+ }
1034
+ async function writeConfig(root, path, config) {
1035
+ await mkdir(join2(root, ".envs"), { recursive: true, mode: 448 });
1036
+ const gitignore = join2(root, ".envs/.gitignore");
1037
+ if (!await Bun.file(gitignore).exists())
1038
+ await Bun.write(gitignore, `*
1039
+ `);
1040
+ const sorted = Object.fromEntries(Object.entries(config).sort(([a], [b]) => a.localeCompare(b)));
1041
+ await Bun.write(path, Object.keys(sorted).length ? Bun.YAML.stringify(sorted, null, 2) + `
1042
+ ` : "", { mode: 384 });
1043
+ await chmod(path, 384);
1044
+ }
1045
+ async function config(ctx, args) {
1046
+ const [action, key, ...rest] = args;
1047
+ const root = await findRoot(ctx.cwd);
1048
+ const path = join2(root, ".envs/config.yml");
1049
+ const current = await readConfig(path);
1050
+ switch (action) {
1051
+ case "set": {
1052
+ if (!key || rest.length === 0) {
1053
+ ctx.error(`${CONFIG_USAGE}
1054
+
1055
+ Missing key or value for \`config set\`.`);
1056
+ return 1;
1057
+ }
1058
+ current[key] = parseValue(rest.join(" "));
1059
+ await writeConfig(root, path, current);
1060
+ ctx.log(`${key}=${current[key]}`);
1061
+ return 0;
1062
+ }
1063
+ case "get": {
1064
+ if (!key) {
1065
+ for (const [k, v] of Object.entries(current))
1066
+ ctx.log(`${k}=${v}`);
1067
+ return 0;
1068
+ }
1069
+ if (!(key in current)) {
1070
+ ctx.error(`Config key not found: ${key}`);
1071
+ return 1;
1072
+ }
1073
+ ctx.log(String(current[key]));
1074
+ return 0;
1075
+ }
1076
+ case "delete": {
1077
+ if (!key) {
1078
+ ctx.error(`${CONFIG_USAGE}
1079
+
1080
+ Missing key for \`config delete\`.`);
1081
+ return 1;
1082
+ }
1083
+ if (!(key in current)) {
1084
+ ctx.error(`Config key not found: ${key}`);
1085
+ return 1;
1086
+ }
1087
+ delete current[key];
1088
+ await writeConfig(root, path, current);
1089
+ ctx.log(`Deleted ${key}`);
1090
+ return 0;
1091
+ }
1092
+ default:
1093
+ ctx.error(CONFIG_USAGE);
1094
+ return 1;
1095
+ }
1096
+ }
490
1097
  async function push(ctx, only) {
1098
+ return await withLock(await findRoot(ctx.cwd), () => pushLocked(ctx, only));
1099
+ }
1100
+ async function pull(ctx) {
1101
+ return await withLock(await findRoot(ctx.cwd), () => pullLocked(ctx));
1102
+ }
1103
+ async function use(ctx, profile) {
1104
+ return await withLock(await findRoot(ctx.cwd), () => useLocked(ctx, profile));
1105
+ }
1106
+ async function restore(ctx, hash, options = {}) {
1107
+ return await withLock(await findRoot(ctx.cwd), () => restoreLocked(ctx, hash, options));
1108
+ }
1109
+ async function pushLocked(ctx, only) {
491
1110
  const read = await readValues(ctx);
492
1111
  if (!read)
493
1112
  return 1;
@@ -495,37 +1114,62 @@ async function push(ctx, only) {
495
1114
  const worktrees = (await listWorktrees(ctx.cwd)).filter(({ name }) => only === undefined || name === only);
496
1115
  if (!checkProfiles(ctx, values, worktrees))
497
1116
  return 1;
1117
+ let safe = true;
1118
+ for (const { name } of worktrees) {
1119
+ const profile = values.uses[name] ?? DEFAULT_PROFILE;
1120
+ for (const [key, value] of Object.entries(resolveEntries(values, name, profile))) {
1121
+ const problem = dotenvProblem(key, value);
1122
+ if (problem) {
1123
+ safe = false;
1124
+ ctx.error(`${JSON.stringify(key)} (${name}): ${problem}. Nothing was written.`);
1125
+ }
1126
+ }
1127
+ }
1128
+ if (!safe)
1129
+ return 1;
1130
+ const managed = await managedVariables(await findRoot(ctx.cwd), values);
498
1131
  const changes = new Map;
1132
+ const removals = new Map;
499
1133
  for (const { name, path } of worktrees) {
500
1134
  const profile = values.uses[name] ?? DEFAULT_PROFILE;
501
1135
  const entries = resolveEntries(values, name, profile);
502
- if (Object.keys(entries).length === 0)
503
- continue;
504
1136
  const label = profile === DEFAULT_PROFILE ? name : `${name} [${profile}]`;
505
1137
  const dotenv = Bun.file(join2(path, ".env"));
506
1138
  const current = await dotenv.exists() ? await dotenv.text() : "";
507
1139
  const existing = parseDotenv(current);
1140
+ const orphans = new Set(Object.keys(existing).filter((key) => managed.has(key) && !(key in entries)));
1141
+ if (Object.keys(entries).length === 0 && orphans.size === 0)
1142
+ continue;
508
1143
  for (const [key, value] of Object.entries(entries)) {
509
1144
  if (existing[key] === value)
510
1145
  continue;
511
- const shown = isMaskedKey(key) ? MASK : formatDotenvValue(value);
1146
+ const shown = displayValue(key, value);
512
1147
  const line = `${key}=${shown}`;
513
1148
  changes.set(line, [...changes.get(line) ?? [], label]);
514
1149
  }
515
- await Bun.write(dotenv, updateDotenv(current, entries));
1150
+ for (const key of orphans)
1151
+ removals.set(key, [...removals.get(key) ?? [], label]);
1152
+ const dotenvPath = join2(path, ".env");
1153
+ await writeFile(dotenvPath, updateDotenv(current, entries, orphans), { mode: 384 });
1154
+ await chmod(dotenvPath, 384).catch(() => {});
516
1155
  }
517
1156
  for (const [line, names] of changes) {
518
1157
  ctx.log(yellow(`\u21BB ${line} \u2192 ${names.join(", ")}`));
519
1158
  }
1159
+ for (const [key, names] of removals) {
1160
+ ctx.log(red(`\u2715 ${key} removed from ${names.join(", ")}`));
1161
+ }
520
1162
  return 0;
521
1163
  }
522
- async function pull(ctx) {
1164
+ async function pullLocked(ctx) {
523
1165
  const read = await readValues(ctx);
524
1166
  if (!read)
525
1167
  return 1;
526
1168
  const values = normalizeValues(read.values);
1169
+ const before = cloneState(values);
527
1170
  const worktrees = await listWorktrees(ctx.cwd);
528
1171
  materializeProfiles(values);
1172
+ const missingDefaults = new Map;
529
1173
  const pulledNames = [];
530
1174
  const pulledKeys = new Set;
531
1175
  for (const { name, path } of worktrees) {
@@ -537,10 +1181,18 @@ async function pull(ctx) {
537
1181
  pulledNames.push(name);
538
1182
  for (const key of Object.keys(parsed))
539
1183
  pulledKeys.add(key);
1184
+ for (const layer of new Set([DEFAULT_PROFILE, profile])) {
1185
+ for (const key of Object.keys(values.profiles[layer].defaults)) {
1186
+ if (!(key in parsed))
1187
+ missingDefaults.set(key, [...missingDefaults.get(key) ?? [], name]);
1188
+ }
1189
+ }
540
1190
  const { envs } = values.profiles[profile];
541
- for (const [key, byWorktree] of Object.entries(envs)) {
542
- if (!(key in parsed))
543
- delete byWorktree[name];
1191
+ for (const layer of new Set([DEFAULT_PROFILE, profile])) {
1192
+ for (const [key, byWorktree] of Object.entries(values.profiles[layer].envs)) {
1193
+ if (!(key in parsed))
1194
+ delete byWorktree[name];
1195
+ }
544
1196
  }
545
1197
  const base = resolveEntries(values, name, profile, false);
546
1198
  for (const [key, value] of Object.entries(parsed)) {
@@ -549,17 +1201,22 @@ async function pull(ctx) {
549
1201
  if (envs[key])
550
1202
  delete envs[key][name];
551
1203
  } else {
552
- (envs[key] ??= {})[name] = parsedValue;
1204
+ (envs[key] ??= dict())[name] = parsedValue;
553
1205
  }
554
1206
  }
555
1207
  }
556
1208
  await Bun.write(read.path, stringifyValues(values));
1209
+ const root = await findRoot(ctx.cwd);
1210
+ await recordHistory(root, before, values, ctx.now, ctx.error);
1211
+ for (const [key, names] of missingDefaults) {
1212
+ ctx.log(yellow(`! ${key} is in the defaults but missing from the .env of ${names.join(", ")}: \`envs push\` writes it back. Remove it from the defaults to drop it from every worktree.`));
1213
+ }
557
1214
  if (pulledNames.length > 0) {
558
1215
  ctx.log(green(`\u2193 pulling ${pulledNames.join(", ")} - ${pulledKeys.size} variables`));
559
1216
  }
560
1217
  return 0;
561
1218
  }
562
- async function use(ctx, profile) {
1219
+ async function useLocked(ctx, profile) {
563
1220
  const read = await readValues(ctx);
564
1221
  if (!read)
565
1222
  return 1;
@@ -581,12 +1238,103 @@ async function use(ctx, profile) {
581
1238
  ctx.error("Run `envs use` inside a git worktree.");
582
1239
  return 1;
583
1240
  }
1241
+ const before = cloneState(values);
584
1242
  for (const { name } of worktrees)
585
1243
  values.uses[name] ??= DEFAULT_PROFILE;
586
1244
  values.uses[current.name] = profile;
587
1245
  await Bun.write(read.path, stringifyValues(values));
1246
+ await recordHistory(await findRoot(ctx.cwd), before, values, ctx.now, ctx.error);
588
1247
  return await push(ctx, current.name);
589
1248
  }
1249
+ async function scanHistory(root) {
1250
+ const file = Bun.file(join2(root, ".envs/history.ndjson"));
1251
+ const scan = { points: [], damaged: [], torn: false, complete: "", endsWithNewline: true, broken: [] };
1252
+ if (!await file.exists())
1253
+ return scan;
1254
+ const text = await file.text();
1255
+ scan.endsWithNewline = text === "" || text.endsWith(`
1256
+ `);
1257
+ scan.complete = text.slice(0, text.lastIndexOf(`
1258
+ `) + 1);
1259
+ const lines = text.split(`
1260
+ `);
1261
+ lines.forEach((line, i) => {
1262
+ if (line.trim() === "")
1263
+ return;
1264
+ let value;
1265
+ try {
1266
+ value = JSON.parse(line);
1267
+ } catch {
1268
+ value = undefined;
1269
+ }
1270
+ if (isHistoryPoint(value))
1271
+ scan.points.push(value);
1272
+ else if (i === lines.length - 1 && !scan.endsWithNewline)
1273
+ scan.torn = true;
1274
+ else
1275
+ scan.damaged.push(i + 1);
1276
+ });
1277
+ scan.broken = verifyChain(scan.points);
1278
+ return scan;
1279
+ }
1280
+ function verifyChain(points) {
1281
+ const broken = [];
1282
+ let previous = null;
1283
+ for (const point of points) {
1284
+ if (hashOf(point) !== point.hash)
1285
+ broken.push({ hash: point.hash, reason: "content" });
1286
+ else if (point.prev !== previous)
1287
+ broken.push({ hash: point.hash, reason: "prev" });
1288
+ previous = point.hash;
1289
+ }
1290
+ return broken;
1291
+ }
1292
+ async function readHistoryPoints(root) {
1293
+ return (await scanHistory(root)).points;
1294
+ }
1295
+ async function restoreLocked(ctx, hash, options = {}) {
1296
+ if (!hash) {
1297
+ ctx.error("Usage: envs restore <hash> [--force]");
1298
+ return 1;
1299
+ }
1300
+ const read = await readValues(ctx);
1301
+ if (!read)
1302
+ return 1;
1303
+ const root = await findRoot(ctx.cwd);
1304
+ if (!await historyEnabled(root)) {
1305
+ ctx.error("The history is off, so a restore would not be recorded. Run `envs config set history true` first.");
1306
+ return 1;
1307
+ }
1308
+ const scan = await scanHistory(root);
1309
+ if (scan.damaged.length > 0) {
1310
+ ctx.error(`.envs/history.ndjson has damaged lines (${scan.damaged.join(", ")}), so the state of a point cannot be rebuilt exactly. Repair or remove those lines and try again.`);
1311
+ return 1;
1312
+ }
1313
+ if (scan.broken.length > 0) {
1314
+ const message = `.envs/history.ndjson: the hash chain is broken at ${describeBroken(scan.broken)}. The history may have been altered, so the state of a point cannot be trusted.`;
1315
+ if (!options.force) {
1316
+ ctx.error(`${message} Check the file, or run \`envs restore ${hash} --force\` to restore anyway.`);
1317
+ return 1;
1318
+ }
1319
+ ctx.error(`${message} Restoring anyway (--force).`);
1320
+ }
1321
+ const points = scan.points;
1322
+ const found = findHistoryPoint(points, hash);
1323
+ if ("error" in found) {
1324
+ ctx.error(found.error === "none" ? `No history point matches "${hash}". Run \`envs restore\` with a hash from .envs/history.ndjson.` : `"${hash}" matches ${found.matches} history points; use more characters.`);
1325
+ return 1;
1326
+ }
1327
+ const target = found.point;
1328
+ const restored = stateAfter(points, target);
1329
+ const current = normalizeValues(read.values);
1330
+ const notes = unredact(restored, current, (await redactor(root)).mark);
1331
+ await Bun.write(read.path, stringifyValues(restored));
1332
+ for (const note of notes)
1333
+ ctx.log(yellow(`! ${note}`));
1334
+ const added = await recordHistory(root, current, restored, ctx.now, ctx.error);
1335
+ ctx.log(added ? green(`\u21BA restored ${target.hash} - new history point ${added}`) : `Already at ${target.hash}: nothing to restore`);
1336
+ return await push(ctx);
1337
+ }
590
1338
  async function isIgnored(dir, file) {
591
1339
  const result = await $`git check-ignore -q ${file}`.cwd(dir).nothrow().quiet();
592
1340
  return result.exitCode === 0;
@@ -609,10 +1357,15 @@ async function lint(ctx) {
609
1357
  dotenvs.push({ ...worktree, mode: await modeOf(path) });
610
1358
  }
611
1359
  }
1360
+ const historyPath = join2(root, ".envs/history.ndjson");
1361
+ const hasHistory = await Bun.file(historyPath).exists();
612
1362
  const lintContext = {
613
1363
  values: read.values,
614
1364
  root,
615
1365
  valuesMode: await modeOf(read.path),
1366
+ hasHistory,
1367
+ historyMode: hasHistory ? await modeOf(historyPath) : undefined,
1368
+ worktrees: worktrees.map(({ name }) => name),
616
1369
  dotenvs,
617
1370
  isTracked,
618
1371
  isIgnored
@@ -644,24 +1397,30 @@ async function edit(ctx, options = {}) {
644
1397
  return await serveUi(ctx);
645
1398
  }
646
1399
  const original = await Bun.file(valuesPath).text();
1400
+ const before = normalizeValues(Bun.YAML.parse(original) ?? {});
647
1401
  await Bun.write(valuesPath, EDIT_NOTICE + original);
648
1402
  const code = await ctx.openEditor(valuesPath);
649
1403
  const edited = await Bun.file(valuesPath).text();
650
- if (edited.startsWith(EDIT_NOTICE)) {
651
- await Bun.write(valuesPath, edited.slice(EDIT_NOTICE.length));
652
- }
653
- if (code !== 0)
1404
+ if (code !== 0) {
1405
+ await Bun.write(valuesPath, original);
654
1406
  return code;
655
- const raw = Bun.YAML.parse(await Bun.file(valuesPath).text()) ?? {};
656
- const values = normalizeValues(raw);
657
- const created = materializeProfiles(values);
658
- if (created.length > 0 || !hasCompleteProfiles(raw)) {
659
- await Bun.write(valuesPath, stringifyValues(values));
660
1407
  }
661
- if (created.length > 0) {
662
- ctx.log(green(`+ created ${created.join(", ")} from ${DEFAULT_PROFILE}`));
1408
+ if (edited.startsWith(EDIT_NOTICE)) {
1409
+ await Bun.write(valuesPath, edited.slice(EDIT_NOTICE.length));
663
1410
  }
664
- return await push(ctx);
1411
+ return await withLock(await findRoot(ctx.cwd), async () => {
1412
+ const raw = Bun.YAML.parse(await Bun.file(valuesPath).text()) ?? {};
1413
+ const values = normalizeValues(raw);
1414
+ const created = materializeProfiles(values);
1415
+ if (created.length > 0 || !hasCompleteProfiles(raw)) {
1416
+ await Bun.write(valuesPath, stringifyValues(values));
1417
+ }
1418
+ if (created.length > 0) {
1419
+ ctx.log(green(`+ created ${created.join(", ")} from ${DEFAULT_PROFILE}`));
1420
+ }
1421
+ await recordHistory(await findRoot(ctx.cwd), before, values, ctx.now, ctx.error);
1422
+ return await push(ctx);
1423
+ });
665
1424
  }
666
1425
  async function loadUiState(ctx) {
667
1426
  const read = await readValues(ctx);
@@ -670,16 +1429,84 @@ async function loadUiState(ctx) {
670
1429
  const [main, ...others] = (await listWorktrees(ctx.cwd)).map(({ name }) => name);
671
1430
  others.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
672
1431
  const worktrees = main === undefined ? [] : [main, ...others];
673
- return { ...normalizeValues(read.values), worktrees };
1432
+ return { ...normalizeValues(read.values), worktrees, version: valuesVersion(await Bun.file(read.path).text()) };
1433
+ }
1434
+ async function loadUiHistory(ctx) {
1435
+ const root = await findRoot(ctx.cwd);
1436
+ const enabled = await historyEnabled(root);
1437
+ if (!enabled)
1438
+ return { enabled, points: [] };
1439
+ const { points, damaged, broken } = await scanHistory(root);
1440
+ return {
1441
+ enabled,
1442
+ points,
1443
+ ...damaged.length > 0 && { damaged },
1444
+ ...broken.length > 0 && { broken: broken.map(({ hash }) => hash) }
1445
+ };
1446
+ }
1447
+ async function saveUiState(ctx, input, options = {}) {
1448
+ return await withLock(await findRoot(ctx.cwd), () => saveUiStateLocked(ctx, input, options));
674
1449
  }
675
- async function saveUiState(ctx, input) {
1450
+ async function saveUiStateLocked(ctx, input, options = {}) {
1451
+ const root = await findRoot(ctx.cwd);
1452
+ const path = join2(root, ".envs/values.yml");
1453
+ const currentText = await Bun.file(path).exists() ? await Bun.file(path).text() : "";
1454
+ if (options.base !== undefined && options.base !== valuesVersion(currentText)) {
1455
+ ctx.error("values.yml changed after the editor was loaded (another command or editor saved it). Reload the editor and apply your changes again; nothing was saved.");
1456
+ return { created: [], code: 1, conflict: true };
1457
+ }
1458
+ const before = normalizeValues(Bun.YAML.parse(currentText) ?? {});
676
1459
  const values = normalizeValues({ uses: input.uses, profiles: input.profiles });
1460
+ const typed = (value, previous) => typeof value !== "string" ? value : previous !== undefined && String(previous) === value ? previous : parseValue(value);
1461
+ for (const [name, profile] of Object.entries(values.profiles)) {
1462
+ const old = Object.hasOwn(before.profiles, name) ? before.profiles[name] : undefined;
1463
+ for (const [key, value] of Object.entries(profile.defaults)) {
1464
+ profile.defaults[key] = typed(value, old && Object.hasOwn(old.defaults, key) ? old.defaults[key] : undefined);
1465
+ }
1466
+ for (const [key, byWorktree] of Object.entries(profile.envs)) {
1467
+ const oldByWorktree = old && Object.hasOwn(old.envs, key) ? old.envs[key] : undefined;
1468
+ for (const [worktree, value] of Object.entries(byWorktree)) {
1469
+ byWorktree[worktree] = typed(value, oldByWorktree && Object.hasOwn(oldByWorktree, worktree) ? oldByWorktree[worktree] : undefined);
1470
+ }
1471
+ }
1472
+ }
1473
+ let safe = true;
1474
+ for (const [profileName, profile] of Object.entries(values.profiles)) {
1475
+ const check = (key, value, where) => {
1476
+ const problem = dotenvProblem(key, String(value));
1477
+ if (!problem)
1478
+ return;
1479
+ safe = false;
1480
+ ctx.error(`${JSON.stringify(key)} (${where}): ${problem}. Nothing was saved.`);
1481
+ };
1482
+ for (const [key, value] of Object.entries(profile.defaults))
1483
+ check(key, value, `${profileName} defaults`);
1484
+ for (const [key, byWorktree] of Object.entries(profile.envs)) {
1485
+ for (const [worktree, value] of Object.entries(byWorktree))
1486
+ check(key, value, `${profileName} ${worktree}`);
1487
+ }
1488
+ }
1489
+ if (!safe)
1490
+ return { created: [], code: 1 };
677
1491
  const created = materializeProfiles(values);
678
- const path = join2(await findRoot(ctx.cwd), ".envs/values.yml");
679
1492
  await Bun.write(path, stringifyValues(values));
1493
+ await recordHistory(root, before, values, ctx.now, ctx.error);
680
1494
  return { created, code: await push(ctx) };
681
1495
  }
1496
+ function valuesVersion(text) {
1497
+ return createHash("sha256").update(text).digest("hex").slice(0, 12);
1498
+ }
682
1499
  async function run(args, ctx = defaultContext()) {
1500
+ try {
1501
+ return await dispatch(args, ctx);
1502
+ } catch (error) {
1503
+ if (!(error instanceof LockError))
1504
+ throw error;
1505
+ ctx.error(error.message);
1506
+ return 1;
1507
+ }
1508
+ }
1509
+ async function dispatch(args, ctx) {
683
1510
  const [command = "help"] = args;
684
1511
  switch (command) {
685
1512
  case "help":
@@ -699,6 +1526,12 @@ async function run(args, ctx = defaultContext()) {
699
1526
  return await edit(ctx, { ui: args.includes("--ui") });
700
1527
  case "use":
701
1528
  return await use(ctx, args[1]);
1529
+ case "history":
1530
+ return await historyCommand(ctx, args.slice(1));
1531
+ case "restore":
1532
+ return await restore(ctx, args.slice(1).find((arg) => !arg.startsWith("--")), { force: args.includes("--force") });
1533
+ case "config":
1534
+ return await config(ctx, args.slice(1));
702
1535
  default:
703
1536
  ctx.error(`Unknown command: ${command}
704
1537
 
@@ -716,8 +1549,11 @@ Commands:
716
1549
  edit Pull, open .envs/values.yml in VS Code (code -w), then push
717
1550
  With --ui, serve a web editor instead; its Save button pushes
718
1551
  use Select the profile of the current worktree (envs use <profile>), or list them
1552
+ restore Restore values.yml to the state after a history point (envs restore <hash> [--force]), adding a new point
1553
+ history Manage the history (history redact: remove secrets in clear from an existing history)
1554
+ config Read or change .envs/config.yml (config set <key> <value>, config get [key], config delete <key>)
719
1555
  help Show this help message
720
- `, colorsDisabled = () => Boolean(process.env.NO_COLOR), MASK = "********", isMaskedKey = (key) => isSensitiveKey(key) || /(^|_)KEY(_|$)/i.test(key), paint = (code, text) => colorsDisabled() ? text : `\x1B[${code}m${text}\x1B[0m`, green = (text) => paint(32, text), yellow = (text) => paint(33, text), defaultContext = () => ({
1556
+ `, colorsDisabled = () => Boolean(process.env.NO_COLOR), MASK = "********", isMaskedKey = (key) => isSensitiveKey(key) || /(^|_)KEY(_|$)/i.test(key), URL_SECRET, hasUrlSecret = (value) => URL_SECRET.test(value) || /^[a-z][a-z0-9+.-]*:\/\//i.test(value) && sensitiveQuery(value), sensitiveQuery = (value) => [...value.matchAll(/[?&]([^=&\s#]+)=([^&\s#]*)/g)].some(([, name]) => isMaskedKey(name.toUpperCase().replace(/[^A-Z0-9]+/g, "_"))), maskUrlSecrets = (value) => value.replace(URL_SECRET, `$1${MASK}$3`).replace(/([?&])([^=&\s#]+)=([^&\s#]*)/g, (all, sep, name) => isMaskedKey(name.toUpperCase().replace(/[^A-Z0-9]+/g, "_")) ? `${sep}${name}=${MASK}` : all), isSecretEntry = (name, value) => isMaskedKey(name) || hasUrlSecret(value) || hasSecretPattern(value), displayValue = (name, value) => isMaskedKey(name) || hasSecretPattern(value) ? MASK : formatDotenvValue(maskUrlSecrets(value)), paint = (code, text) => colorsDisabled() ? text : `\x1B[${code}m${text}\x1B[0m`, green = (text) => paint(32, text), yellow = (text) => paint(33, text), red = (text) => paint(31, text), defaultContext = () => ({
721
1557
  cwd: process.cwd(),
722
1558
  log: (message) => console.log(message),
723
1559
  error: (message) => console.error(message),
@@ -738,14 +1574,31 @@ Commands:
738
1574
  process.once("SIGINT", () => resolve());
739
1575
  process.once("SIGTERM", () => resolve());
740
1576
  })
741
- }), DEFAULT_PROFILE = "default", hasProfile = (values, name) => Object.hasOwn(values.profiles, name), hasCompleteProfiles = (raw) => Object.values(raw.profiles ?? {}).every((profile) => typeof profile?.defaults === "object" && profile.defaults !== null && typeof profile.envs === "object" && profile.envs !== null), SCHEMA_COMMENT = `# yaml-language-server: $schema=https://jondotsoy.github.io/envs/schema.json
742
- `, EDIT_NOTICE = `# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1577
+ }), hasProfile = (values, name) => Object.hasOwn(values.profiles, name), hasCompleteProfiles = (raw) => Object.values(raw.profiles ?? {}).every((profile) => typeof profile?.defaults === "object" && profile.defaults !== null && typeof profile.envs === "object" && profile.envs !== null), SCHEMA_COMMENT = `# yaml-language-server: $schema=https://jondotsoy.github.io/envs/schema.json
1578
+ `, LockError, heldLocks, processAlive = (pid) => {
1579
+ try {
1580
+ process.kill(pid, 0);
1581
+ return true;
1582
+ } catch (error) {
1583
+ return error.code === "EPERM";
1584
+ }
1585
+ }, CONFIG_USAGE = `Usage: envs config <set <key> <value> | get [key] | delete <key>>`, isHistoryPoint = (value) => {
1586
+ const point = value;
1587
+ return typeof point === "object" && point !== null && typeof point.hash === "string" && (typeof point.prev === "string" || point.prev === null) && typeof point.timestamp === "string" && Array.isArray(point.changes);
1588
+ }, hashOf = (point) => {
1589
+ const { hash: _, ...rest } = point;
1590
+ return createHash("sha256").update(JSON.stringify(rest)).digest("hex").slice(0, 12);
1591
+ }, describeBroken = (broken) => broken.map(({ hash, reason }) => `${hash} (${reason === "content" ? "its content does not match its hash" : "it does not follow the previous point"})`).join(", "), EDIT_NOTICE = `# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
743
1592
  # Close this file when your changes are ready, then they are pushed.
744
1593
  # To cancel the edit, press Ctrl+C in the terminal and then close this file.
745
1594
  # \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
746
1595
  `;
747
1596
  var init_envs = __esm(() => {
748
1597
  init_lint_rules();
1598
+ URL_SECRET = /^([a-z][a-z0-9+.-]*:\/\/[^\/\s:@]*:)([^@\/\s]+)(@)/i;
1599
+ LockError = class LockError extends Error {
1600
+ };
1601
+ heldLocks = new AsyncLocalStorage;
749
1602
  });
750
1603
 
751
1604
  // src/bin/envs.ts