@jondotsoy/envs 0.1.8 → 0.1.9-demo.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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  CLI that keeps the `.env` values of every git worktree in a single file (`.envs/values.yml`). Edit all values together and distribute them to each worktree, or collect them from the existing `.env` files.
4
4
 
5
- Requires [Bun](https://bun.com) and git.
5
+ Requires [Bun](https://bun.com) and git. See [docs/features.md](docs/features.md) for a summary of all features.
6
6
 
7
7
  ![Each worktree keeps its own .env; envs edit and envs push sync the shared values across them](docs/assets/images/worktrees-sync.svg)
8
8
 
@@ -36,14 +36,22 @@ The examples below use `envs`; without a global install, replace it with `bunx @
36
36
 
37
37
  Run the commands inside the repo or any of its worktrees. Inside a worktree, `.envs/` always lives in the main repo.
38
38
 
39
+ ![Web editor started with envs edit --ui](docs/assets/images/edit-ui.png)
40
+
41
+ A walkthrough with a screenshot of each action is in [docs/ui.md](docs/ui.md) (in Spanish).
42
+
39
43
  | Command | What it does |
40
44
  | --- | --- |
41
45
  | `envs edit` | Runs `init` if `.envs/values.yml` does not exist yet, then runs `pull`, opens `values.yml` with `code -w` and, when the editor closes, runs `push`. If the editor fails, it does not `push`. |
42
- | `envs edit --ui` | Same `init` and `pull`, but instead of VS Code it starts a local server (`127.0.0.1`, random port) and opens a React web editor with the values of `values.yml` (worktree profiles, defaults and per-worktree values). The **Save** button in the top right corner writes `values.yml` and runs `push`. Stop the server with Ctrl+C. |
46
+ | `envs edit --ui` | Same `init` and `pull`, but instead of VS Code it starts a local server (`127.0.0.1`, random port) and opens a React web editor with the values of `values.yml` (worktree profiles, defaults and per-worktree values). The **Save** button in the top right corner writes `values.yml` and runs `push`. Its **History** panel lists the history points (newest first); hovering or clicking a point shows what restoring it would change in the editor (red: removed, green: changed or added), and its **Restore** button, after a confirmation, runs `envs restore` for that point. Stop the server with Ctrl+C. |
43
47
  | `envs init` | Creates `.envs/`, `.envs/.gitignore` (containing `*`) and an empty `.envs/values.yml`. Does not overwrite existing files. |
44
48
  | `envs pull` | Reads the `.env` of the main repo and of each worktree and writes it to `values.yml`. Variables that are no longer in a `.env` are removed from `values.yml` for that worktree. |
45
49
  | `envs push` | Writes `values.yml` to the `.env` of each worktree. Updates existing variables in place, appends new ones at the end, and keeps comments and variables that are not in `values.yml`. |
46
50
  | `envs use [profile]` | Sets the profile of the current worktree (`uses.<worktree>`) and pushes that worktree. Without arguments it lists the profile of each worktree. The profile must already exist in `values.yml`. |
51
+ | `envs restore <hash>` | Restores `values.yml` to the state it had after the history point `<hash>` (a unique prefix is enough), adds a new point with the difference and runs `push`. Existing points are never removed. See [docs/history.md](docs/history.md). |
52
+ | `envs config set <key> <value>` | Saves a setting in `.envs/config.yml` (created on demand with mode `600`, along with `.envs/.gitignore` if missing). `true`, `false` and numbers are stored as such; the rest as strings. |
53
+ | `envs config get [key]` | Prints the value of a setting, or every `key=value` when no key is given. Exits with code 1 if the key does not exist. |
54
+ | `envs config delete <key>` | Removes a setting. Exits with code 1 if the key does not exist. |
47
55
  | `envs lint` | Checks security and prints a warning for each problem. Exits with code 1 if there is any (useful in CI). See below. |
48
56
  | `envs help` | Shows the help (also `--help`, `-h`, or no command). |
49
57
 
@@ -94,6 +102,65 @@ It never prints values, only the variable and its location (`envs.DB_PASSWORD.ma
94
102
  | `secret-pattern` | Value with a known secret format (AWS, GitHub, Slack, `sk-…`, private key). |
95
103
  | `url-credentials`, `insecure-url` | Remote URL with an embedded password, or using `http://`, `ws://` or `ftp://`. |
96
104
 
105
+ ## History
106
+
107
+ See [docs/history.md](docs/history.md) for the full documentation (in Spanish).
108
+
109
+ `envs` keeps a record of how `values.yml` evolved. It works with two separate concepts:
110
+
111
+ | Concept | Where | What it is |
112
+ | --- | --- | --- |
113
+ | **Snapshot** | `.envs/values.yml` | The complete current state: every profile, default and per-worktree value. It is the file you edit, and it is the only thing `push` reads. |
114
+ | **Changes** | `.envs/history.ndjson` | An append-only log. Each line is a *point* holding only what changed between two states, never a full copy of the file. |
115
+
116
+ The snapshot answers "what are the values now?"; the changes answer "what changed, when, and from what?". The history never replaces `values.yml`: if you delete `history.ndjson`, `envs` keeps working and the next `pull` starts a new history.
117
+
118
+ ### A point
119
+
120
+ One JSON object per line:
121
+
122
+ ```json
123
+ {"hash":"3f9a1c0b7d2e","prev":"e7c8ebe462f1","timestamp":"2026-01-01T00:02:00.000Z","changes":[{"path":["profiles","default","envs","PORT","feature-x"],"from":3001,"to":3002}]}
124
+ ```
125
+
126
+ - `changes` lists the differences. `path` locates the value inside the snapshot (`uses.<worktree>`, `profiles.<profile>.defaults.<VAR>` or `profiles.<profile>.envs.<VAR>.<worktree>`). `from` is absent when the entry was added and `to` is absent when it was removed.
127
+ - `prev` is the hash of the previous point (`null` for the first one) and `hash` is the first 12 hex characters of the sha256 of `prev`, `timestamp` and `changes`. The file is a hash chain: altering or removing a point breaks every later one.
128
+
129
+ ### When a point is added
130
+
131
+ A point is written only when `values.yml` actually changed.
132
+
133
+ - The **first** `envs pull` (also run by `envs edit`) creates the first point. Its `changes` describe the whole initial snapshot, as if it had been built from nothing.
134
+ - Any later `envs pull` adds a point with what it changed, for example after you edited a worktree's `.env` by hand.
135
+ - `envs edit` adds a point with what you changed in the editor, and so does Save in `envs edit --ui`.
136
+ - `envs use <profile>` adds a point with the profile change.
137
+
138
+ ### Reading it
139
+
140
+ Applying the points in order, from the first one, rebuilds the snapshot as it was after each of them. Because the first point contains the whole initial state, the result after the last point equals `values.yml`. The exception is a manual edit of `values.yml` made outside these commands: it is part of the snapshot but has no point.
141
+
142
+ ### Turning it off
143
+
144
+ The history is active by default. It reads the `history` setting of `.envs/config.yml`: only an explicit `false` turns it off, and `values.yml` keeps being updated as usual.
145
+
146
+ ```sh
147
+ envs config set history false # stop recording
148
+ envs config set history true # record again (or `envs config delete history`)
149
+ ```
150
+
151
+ Changes made while it is off get no point: when you turn it on again, the next point starts from the `values.yml` of that moment.
152
+
153
+ ### In the web editor
154
+
155
+ The **History** panel of `envs edit --ui` lists the points, newest first, with their short hash, time and number of changes. Hovering a point, or clicking it to keep it selected (a click never restores), compares the state after that point with the values in the editor, grouped by worktree profiles and by profile (`defaults`, `envs`):
156
+
157
+ - **red**: the entry exists now and would be removed;
158
+ - **green**: the entry would change (current value → value at that point) or would be added.
159
+
160
+ When they are equal it says "No differences". **Restore** asks for confirmation (and warns that unsaved editor changes will be lost), then restores the point like `envs restore`: a new point is appended, none is removed, and the editor and the panel reload. With the history off the panel shows an empty state and offers no restore.
161
+
162
+ The file is created with mode `600` because it holds variable values. Like the rest of `.envs/`, it is ignored by git.
163
+
97
164
  ## `values.yml` format
98
165
 
99
166
  ```yaml
package/envs.js CHANGED
@@ -239,6 +239,78 @@ var init_lint_rules = __esm(() => {
239
239
  ];
240
240
  });
241
241
 
242
+ // src/history.ts
243
+ function diffValues(before, after) {
244
+ const changes = [];
245
+ const compare = (path, a, b) => {
246
+ if (a !== b)
247
+ changes.push({ path, ...a !== undefined && { from: a }, ...b !== undefined && { to: b } });
248
+ };
249
+ for (const w of keys(before.uses, after.uses))
250
+ compare(["uses", w], before.uses[w], after.uses[w]);
251
+ for (const p of keys(before.profiles, after.profiles)) {
252
+ const a = before.profiles[p];
253
+ const b = after.profiles[p];
254
+ for (const k of keys(a?.defaults, b?.defaults)) {
255
+ compare(["profiles", p, "defaults", k], a?.defaults[k], b?.defaults[k]);
256
+ }
257
+ for (const k of keys(a?.envs, b?.envs)) {
258
+ for (const w of keys(a?.envs[k], b?.envs[k])) {
259
+ compare(["profiles", p, "envs", k, w], a?.envs[k]?.[w], b?.envs[k]?.[w]);
260
+ }
261
+ }
262
+ }
263
+ return changes;
264
+ }
265
+ function applyChanges(state, changes) {
266
+ for (const { path, to } of changes) {
267
+ const [section, ...rest] = path;
268
+ if (section === "uses") {
269
+ const [worktree] = rest;
270
+ if (to === undefined)
271
+ delete state.uses[worktree];
272
+ else
273
+ state.uses[worktree] = String(to);
274
+ continue;
275
+ }
276
+ const [profileName, kind, variable, worktree] = rest;
277
+ const profile = state.profiles[profileName] ??= { defaults: {}, envs: {} };
278
+ if (kind === "defaults") {
279
+ if (to === undefined)
280
+ delete profile.defaults[variable];
281
+ else
282
+ profile.defaults[variable] = to;
283
+ } else {
284
+ const byWorktree = profile.envs[variable] ??= {};
285
+ if (to === undefined)
286
+ delete byWorktree[worktree];
287
+ else
288
+ byWorktree[worktree] = to;
289
+ if (Object.keys(byWorktree).length === 0)
290
+ delete profile.envs[variable];
291
+ }
292
+ }
293
+ }
294
+ function findHistoryPoint(points, hash) {
295
+ const matches = hash === "" ? [] : points.filter((point) => point.hash.startsWith(hash));
296
+ if (matches.length === 1)
297
+ return { point: matches[0] };
298
+ return { error: matches.length === 0 ? "none" : "ambiguous", matches: matches.length };
299
+ }
300
+ function stateAfter(points, target) {
301
+ const state = { uses: {}, profiles: {} };
302
+ for (const point of points) {
303
+ applyChanges(state, point.changes);
304
+ if (point === target)
305
+ break;
306
+ }
307
+ state.profiles[DEFAULT_PROFILE] ??= { defaults: {}, envs: {} };
308
+ for (const name of Object.values(state.uses))
309
+ state.profiles[name] ??= { defaults: {}, envs: {} };
310
+ return state;
311
+ }
312
+ var DEFAULT_PROFILE = "default", keys = (a, b) => [...new Set([...Object.keys(a ?? {}), ...Object.keys(b ?? {})])];
313
+
242
314
  // src/ui/server.ts
243
315
  import { join } from "path";
244
316
  import { readdir } from "fs/promises";
@@ -259,6 +331,21 @@ function parseBody(body) {
259
331
  }
260
332
  return body;
261
333
  }
334
+ function parseRestoreBody(body) {
335
+ if (!isRecord(body) || typeof body.hash !== "string" || !/^[0-9a-f]{12}$/.test(body.hash))
336
+ return;
337
+ return body.hash;
338
+ }
339
+ function capture(ctx) {
340
+ const logs = [];
341
+ const errors = [];
342
+ const captured = {
343
+ ...ctx,
344
+ log: (m) => logs.push(m.replace(ANSI, "")),
345
+ error: (m) => errors.push(m.replace(ANSI, ""))
346
+ };
347
+ return { ctx: captured, logs, errors };
348
+ }
262
349
  async function loadAssets() {
263
350
  const assets = new Map;
264
351
  if (await Bun.file(join(import.meta.dir, "app.tsx")).exists()) {
@@ -288,16 +375,31 @@ async function serveUi(ctx) {
288
375
  }));
289
376
  if (!values)
290
377
  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);
378
+ const { ctx: captured, logs, errors } = capture(ctx);
379
+ const { created, code } = await saveUiState(captured, values);
299
380
  return Response.json({ ok: code === 0, created, logs, errors }, { status: code === 0 ? 200 : 500 });
300
381
  }
382
+ },
383
+ "/api/history": {
384
+ GET: async () => Response.json(await loadUiHistory(ctx))
385
+ },
386
+ "/api/restore": {
387
+ POST: async (req) => {
388
+ const hash = parseRestoreBody(await req.json().catch(() => {
389
+ return;
390
+ }));
391
+ if (!hash)
392
+ return Response.json({ error: "Invalid hash" }, { status: 400 });
393
+ const root = await findRoot(ctx.cwd);
394
+ if (!await historyEnabled(root))
395
+ return Response.json({ error: "The history is off" }, { status: 409 });
396
+ if (!(await readHistoryPoints(root)).some((point) => point.hash === hash)) {
397
+ return Response.json({ error: "Unknown history point" }, { status: 404 });
398
+ }
399
+ const { ctx: captured, logs, errors } = capture(ctx);
400
+ const code = await restore(captured, hash);
401
+ return Response.json({ ok: code === 0, created: [], logs, errors }, { status: code === 0 ? 200 : 500 });
402
+ }
301
403
  }
302
404
  },
303
405
  fetch: (req) => {
@@ -320,9 +422,11 @@ var init_server = __esm(() => {
320
422
  });
321
423
 
322
424
  // src/envs.ts
323
- import { mkdir } from "fs/promises";
425
+ import { chmod, mkdir } from "fs/promises";
324
426
  import { basename, dirname, join as join2 } from "path";
325
427
  import { stat } from "fs/promises";
428
+ import { createHash } from "crypto";
429
+ import { appendFile } from "fs/promises";
326
430
  var {$ } = globalThis.Bun;
327
431
  function normalizeValues(raw) {
328
432
  const profiles = {};
@@ -349,6 +453,25 @@ function materializeProfiles(values) {
349
453
  }
350
454
  return created;
351
455
  }
456
+ async function recordHistory(root, before, after, now = () => new Date) {
457
+ if (!await historyEnabled(root))
458
+ return;
459
+ const changes = diffValues(before, after);
460
+ if (changes.length === 0)
461
+ return;
462
+ const historyPath = join2(root, ".envs/history.ndjson");
463
+ const last = (await Bun.file(historyPath).exists() ? await Bun.file(historyPath).text() : "").trimEnd().split(`
464
+ `).at(-1);
465
+ const prev = last ? JSON.parse(last).hash : null;
466
+ const point = { prev, timestamp: now().toISOString(), changes };
467
+ const hash = createHash("sha256").update(JSON.stringify(point)).digest("hex").slice(0, 12);
468
+ await appendFile(historyPath, JSON.stringify({ hash, ...point }) + `
469
+ `, { mode: 384 });
470
+ return hash;
471
+ }
472
+ async function historyEnabled(root) {
473
+ return (await readConfig(join2(root, ".envs/config.yml"))).history !== false;
474
+ }
352
475
  function resolveEntries(values, worktree, profile, includeProfileEnvs = true) {
353
476
  const entries = {};
354
477
  for (const name of new Set([DEFAULT_PROFILE, profile])) {
@@ -487,6 +610,74 @@ async function init(ctx) {
487
610
  }
488
611
  return 0;
489
612
  }
613
+ async function readConfig(path) {
614
+ if (!await Bun.file(path).exists())
615
+ return {};
616
+ return Bun.YAML.parse(await Bun.file(path).text()) ?? {};
617
+ }
618
+ async function writeConfig(root, path, config) {
619
+ await mkdir(join2(root, ".envs"), { recursive: true });
620
+ const gitignore = join2(root, ".envs/.gitignore");
621
+ if (!await Bun.file(gitignore).exists())
622
+ await Bun.write(gitignore, `*
623
+ `);
624
+ const sorted = Object.fromEntries(Object.entries(config).sort(([a], [b]) => a.localeCompare(b)));
625
+ await Bun.write(path, Object.keys(sorted).length ? Bun.YAML.stringify(sorted, null, 2) + `
626
+ ` : "", { mode: 384 });
627
+ await chmod(path, 384);
628
+ }
629
+ async function config(ctx, args) {
630
+ const [action, key, ...rest] = args;
631
+ const root = await findRoot(ctx.cwd);
632
+ const path = join2(root, ".envs/config.yml");
633
+ const current = await readConfig(path);
634
+ switch (action) {
635
+ case "set": {
636
+ if (!key || rest.length === 0) {
637
+ ctx.error(`${CONFIG_USAGE}
638
+
639
+ Missing key or value for \`config set\`.`);
640
+ return 1;
641
+ }
642
+ current[key] = parseValue(rest.join(" "));
643
+ await writeConfig(root, path, current);
644
+ ctx.log(`${key}=${current[key]}`);
645
+ return 0;
646
+ }
647
+ case "get": {
648
+ if (!key) {
649
+ for (const [k, v] of Object.entries(current))
650
+ ctx.log(`${k}=${v}`);
651
+ return 0;
652
+ }
653
+ if (!(key in current)) {
654
+ ctx.error(`Config key not found: ${key}`);
655
+ return 1;
656
+ }
657
+ ctx.log(String(current[key]));
658
+ return 0;
659
+ }
660
+ case "delete": {
661
+ if (!key) {
662
+ ctx.error(`${CONFIG_USAGE}
663
+
664
+ Missing key for \`config delete\`.`);
665
+ return 1;
666
+ }
667
+ if (!(key in current)) {
668
+ ctx.error(`Config key not found: ${key}`);
669
+ return 1;
670
+ }
671
+ delete current[key];
672
+ await writeConfig(root, path, current);
673
+ ctx.log(`Deleted ${key}`);
674
+ return 0;
675
+ }
676
+ default:
677
+ ctx.error(CONFIG_USAGE);
678
+ return 1;
679
+ }
680
+ }
490
681
  async function push(ctx, only) {
491
682
  const read = await readValues(ctx);
492
683
  if (!read)
@@ -524,6 +715,7 @@ async function pull(ctx) {
524
715
  if (!read)
525
716
  return 1;
526
717
  const values = normalizeValues(read.values);
718
+ const before = structuredClone(values);
527
719
  const worktrees = await listWorktrees(ctx.cwd);
528
720
  materializeProfiles(values);
529
721
  const pulledNames = [];
@@ -554,6 +746,9 @@ async function pull(ctx) {
554
746
  }
555
747
  }
556
748
  await Bun.write(read.path, stringifyValues(values));
749
+ const root = await findRoot(ctx.cwd);
750
+ const initial = !await Bun.file(join2(root, ".envs/history.ndjson")).exists();
751
+ await recordHistory(root, initial ? { uses: {}, profiles: {} } : before, values, ctx.now);
557
752
  if (pulledNames.length > 0) {
558
753
  ctx.log(green(`\u2193 pulling ${pulledNames.join(", ")} - ${pulledKeys.size} variables`));
559
754
  }
@@ -581,12 +776,44 @@ async function use(ctx, profile) {
581
776
  ctx.error("Run `envs use` inside a git worktree.");
582
777
  return 1;
583
778
  }
779
+ const before = structuredClone(values);
584
780
  for (const { name } of worktrees)
585
781
  values.uses[name] ??= DEFAULT_PROFILE;
586
782
  values.uses[current.name] = profile;
587
783
  await Bun.write(read.path, stringifyValues(values));
784
+ await recordHistory(await findRoot(ctx.cwd), before, values, ctx.now);
588
785
  return await push(ctx, current.name);
589
786
  }
787
+ async function readHistoryPoints(root) {
788
+ const file = Bun.file(join2(root, ".envs/history.ndjson"));
789
+ if (!await file.exists())
790
+ return [];
791
+ return (await file.text()).split(`
792
+ `).filter((line) => line.trim() !== "").map((line) => JSON.parse(line));
793
+ }
794
+ async function restore(ctx, hash) {
795
+ if (!hash) {
796
+ ctx.error("Usage: envs restore <hash>");
797
+ return 1;
798
+ }
799
+ const read = await readValues(ctx);
800
+ if (!read)
801
+ return 1;
802
+ const root = await findRoot(ctx.cwd);
803
+ const points = await readHistoryPoints(root);
804
+ const found = findHistoryPoint(points, hash);
805
+ if ("error" in found) {
806
+ 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.`);
807
+ return 1;
808
+ }
809
+ const target = found.point;
810
+ const restored = stateAfter(points, target);
811
+ const current = normalizeValues(read.values);
812
+ await Bun.write(read.path, stringifyValues(restored));
813
+ const added = await recordHistory(root, current, restored, ctx.now);
814
+ ctx.log(added ? green(`\u21BA restored ${target.hash} - new history point ${added}`) : `Already at ${target.hash}: nothing to restore`);
815
+ return await push(ctx);
816
+ }
590
817
  async function isIgnored(dir, file) {
591
818
  const result = await $`git check-ignore -q ${file}`.cwd(dir).nothrow().quiet();
592
819
  return result.exitCode === 0;
@@ -644,6 +871,7 @@ async function edit(ctx, options = {}) {
644
871
  return await serveUi(ctx);
645
872
  }
646
873
  const original = await Bun.file(valuesPath).text();
874
+ const before = normalizeValues(Bun.YAML.parse(original) ?? {});
647
875
  await Bun.write(valuesPath, EDIT_NOTICE + original);
648
876
  const code = await ctx.openEditor(valuesPath);
649
877
  const edited = await Bun.file(valuesPath).text();
@@ -661,6 +889,7 @@ async function edit(ctx, options = {}) {
661
889
  if (created.length > 0) {
662
890
  ctx.log(green(`+ created ${created.join(", ")} from ${DEFAULT_PROFILE}`));
663
891
  }
892
+ await recordHistory(await findRoot(ctx.cwd), before, values, ctx.now);
664
893
  return await push(ctx);
665
894
  }
666
895
  async function loadUiState(ctx) {
@@ -672,11 +901,31 @@ async function loadUiState(ctx) {
672
901
  const worktrees = main === undefined ? [] : [main, ...others];
673
902
  return { ...normalizeValues(read.values), worktrees };
674
903
  }
904
+ async function loadUiHistory(ctx) {
905
+ const root = await findRoot(ctx.cwd);
906
+ const enabled = await historyEnabled(root);
907
+ return { enabled, points: enabled ? await readHistoryPoints(root) : [] };
908
+ }
675
909
  async function saveUiState(ctx, input) {
676
910
  const values = normalizeValues({ uses: input.uses, profiles: input.profiles });
911
+ for (const profile of Object.values(values.profiles)) {
912
+ for (const [key, value] of Object.entries(profile.defaults)) {
913
+ if (typeof value === "string")
914
+ profile.defaults[key] = parseValue(value);
915
+ }
916
+ for (const byWorktree of Object.values(profile.envs)) {
917
+ for (const [worktree, value] of Object.entries(byWorktree)) {
918
+ if (typeof value === "string")
919
+ byWorktree[worktree] = parseValue(value);
920
+ }
921
+ }
922
+ }
677
923
  const created = materializeProfiles(values);
678
- const path = join2(await findRoot(ctx.cwd), ".envs/values.yml");
924
+ const root = await findRoot(ctx.cwd);
925
+ const path = join2(root, ".envs/values.yml");
926
+ const before = normalizeValues((await Bun.file(path).exists() ? Bun.YAML.parse(await Bun.file(path).text()) : undefined) ?? {});
679
927
  await Bun.write(path, stringifyValues(values));
928
+ await recordHistory(root, before, values, ctx.now);
680
929
  return { created, code: await push(ctx) };
681
930
  }
682
931
  async function run(args, ctx = defaultContext()) {
@@ -699,6 +948,10 @@ async function run(args, ctx = defaultContext()) {
699
948
  return await edit(ctx, { ui: args.includes("--ui") });
700
949
  case "use":
701
950
  return await use(ctx, args[1]);
951
+ case "restore":
952
+ return await restore(ctx, args[1]);
953
+ case "config":
954
+ return await config(ctx, args.slice(1));
702
955
  default:
703
956
  ctx.error(`Unknown command: ${command}
704
957
 
@@ -716,6 +969,8 @@ Commands:
716
969
  edit Pull, open .envs/values.yml in VS Code (code -w), then push
717
970
  With --ui, serve a web editor instead; its Save button pushes
718
971
  use Select the profile of the current worktree (envs use <profile>), or list them
972
+ restore Restore values.yml to the state after a history point (envs restore <hash>), adding a new point
973
+ config Read or change .envs/config.yml (config set <key> <value>, config get [key], config delete <key>)
719
974
  help Show this help message
720
975
  `, 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 = () => ({
721
976
  cwd: process.cwd(),
@@ -738,8 +993,8 @@ Commands:
738
993
  process.once("SIGINT", () => resolve());
739
994
  process.once("SIGTERM", () => resolve());
740
995
  })
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
996
+ }), 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
997
+ `, CONFIG_USAGE = `Usage: envs config <set <key> <value> | get [key] | delete <key>>`, 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
998
  # Close this file when your changes are ready, then they are pushed.
744
999
  # To cancel the edit, press Ctrl+C in the terminal and then close this file.
745
1000
  # \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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jondotsoy/envs",
3
- "version": "0.1.8",
3
+ "version": "0.1.9-demo.0",
4
4
  "license": "MIT",
5
5
  "author": {
6
6
  "name": "Jonathan Delgado",