@jondotsoy/envs 0.1.8 → 0.1.9-alpha.0

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