@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 +69 -2
- package/envs.js +267 -12
- package/package.json +1 -1
- package/ui/chunk-qxekjfck.js +239 -0
- package/ui/chunk-x6cg8vz7.css +1 -0
- package/ui/index.html +1 -1
- package/ui/chunk-293xt424.js +0 -239
- package/ui/chunk-nqsgf5a0.css +0 -1
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
|

|
|
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
|
+

|
|
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
|
|
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
|
|
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
|
-
}),
|
|
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
|