@jondotsoy/envs 0.1.7 → 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
@@ -1,40 +1,27 @@
1
1
  #!/usr/bin/env bun
2
2
  // @bun
3
-
4
- // src/envs.ts
5
- import { mkdir } from "fs/promises";
6
- import { basename, dirname, join } from "path";
7
- import { stat } from "fs/promises";
8
- var {$ } = globalThis.Bun;
3
+ var __esm = (fn, res, err) => () => {
4
+ if (fn)
5
+ try {
6
+ res = fn(fn = 0);
7
+ } catch (e) {
8
+ err = [e];
9
+ }
10
+ if (err)
11
+ throw err[0];
12
+ return res;
13
+ };
9
14
 
10
15
  // src/lint-rules.ts
11
- var SENSITIVE_KEY = /(^|_)(SECRET|PASSWORD|PASSWD|PWD|TOKEN|API_?KEY|PRIVATE_?KEY|ACCESS_?KEY|CREDENTIALS?|AUTH|SALT|SIGNING)(_|$)/i;
12
- var WEAK_VALUES = new Set([
13
- "",
14
- "changeme",
15
- "change-me",
16
- "change_me",
17
- "password",
18
- "passw0rd",
19
- "secret",
20
- "admin",
21
- "root",
22
- "test",
23
- "default",
24
- "example",
25
- "123456",
26
- "12345678",
27
- "qwerty",
28
- "todo",
29
- "xxx"
30
- ]);
31
- var SECRET_PATTERNS = [
32
- [/AKIA[0-9A-Z]{16}/, "an AWS access key id"],
33
- [/gh[pousr]_[A-Za-z0-9]{36,}/, "a GitHub token"],
34
- [/sk-[A-Za-z0-9_-]{20,}/, "an API secret key"],
35
- [/xox[abprs]-[A-Za-z0-9-]{10,}/, "a Slack token"],
36
- [/-----BEGIN [A-Z ]*PRIVATE KEY-----/, "a private key"]
37
- ];
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
+ }
38
25
  function isSensitiveKey(key) {
39
26
  return SENSITIVE_KEY.test(key);
40
27
  }
@@ -74,223 +61,747 @@ function parseUrl(text) {
74
61
  local: /^(localhost|127\.\d+\.\d+\.\d+|::1|0\.0\.0\.0)$/.test(hostname)
75
62
  };
76
63
  }
77
- var valuesFile = ".envs/values.yml";
78
64
  function envFiles(ctx) {
79
65
  return [
80
66
  { location: valuesFile, mode: ctx.valuesMode },
67
+ ...ctx.hasHistory ? [{ location: historyFile, mode: ctx.historyMode }] : [],
81
68
  ...ctx.dotenvs.map((d) => ({ location: `${d.name}/.env`, mode: d.mode }))
82
69
  ];
83
70
  }
84
- var rules = [
85
- {
86
- id: "tracked-values",
87
- description: "values.yml is tracked by git.",
88
- async check(ctx) {
89
- if (!await ctx.isTracked(ctx.root, valuesFile))
90
- return [];
91
- return [
92
- {
93
- location: valuesFile,
94
- message: "values.yml is tracked by git, so its secrets are in the history. Run `git rm --cached .envs/values.yml`."
95
- }
96
- ];
97
- }
98
- },
99
- {
100
- id: "unignored-values",
101
- description: "values.yml is neither tracked nor ignored by git.",
102
- async check(ctx) {
103
- if (await ctx.isTracked(ctx.root, valuesFile))
104
- return [];
105
- if (await ctx.isIgnored(ctx.root, valuesFile))
106
- return [];
107
- return [
108
- {
109
- location: valuesFile,
110
- message: "values.yml is not ignored by git and could be committed by accident. Add `*` to .envs/.gitignore."
71
+ var SENSITIVE_KEY, WEAK_VALUES, SECRET_PATTERNS, valuesFile = ".envs/values.yml", historyFile = ".envs/history.ndjson", rules;
72
+ var init_lint_rules = __esm(() => {
73
+ SENSITIVE_KEY = /(^|_)(SECRET|PASSWORD|PASSWD|PWD|TOKEN|API_?KEY|PRIVATE_?KEY|ACCESS_?KEY|CREDENTIALS?|AUTH|SALT|SIGNING)(_|$)/i;
74
+ WEAK_VALUES = new Set([
75
+ "",
76
+ "changeme",
77
+ "change-me",
78
+ "change_me",
79
+ "password",
80
+ "passw0rd",
81
+ "secret",
82
+ "admin",
83
+ "root",
84
+ "test",
85
+ "default",
86
+ "example",
87
+ "123456",
88
+ "12345678",
89
+ "qwerty",
90
+ "todo",
91
+ "xxx"
92
+ ]);
93
+ SECRET_PATTERNS = [
94
+ [/AKIA[0-9A-Z]{16}/, "an AWS access key id"],
95
+ [/gh[pousr]_[A-Za-z0-9]{36,}/, "a GitHub token"],
96
+ [/sk-[A-Za-z0-9_-]{20,}/, "an API secret key"],
97
+ [/xox[abprs]-[A-Za-z0-9-]{10,}/, "a Slack token"],
98
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----/, "a private key"]
99
+ ];
100
+ rules = [
101
+ {
102
+ id: "tracked-values",
103
+ description: "values.yml is tracked by git.",
104
+ async check(ctx) {
105
+ if (!await ctx.isTracked(ctx.root, valuesFile))
106
+ return [];
107
+ return [
108
+ {
109
+ location: valuesFile,
110
+ message: "values.yml is tracked by git, so its secrets are in the history. Run `git rm --cached .envs/values.yml`."
111
+ }
112
+ ];
113
+ }
114
+ },
115
+ {
116
+ id: "unignored-values",
117
+ description: "values.yml is neither tracked nor ignored by git.",
118
+ async check(ctx) {
119
+ if (await ctx.isTracked(ctx.root, valuesFile))
120
+ return [];
121
+ if (await ctx.isIgnored(ctx.root, valuesFile))
122
+ return [];
123
+ return [
124
+ {
125
+ location: valuesFile,
126
+ message: "values.yml is not ignored by git and could be committed by accident. Add `*` to .envs/.gitignore."
127
+ }
128
+ ];
129
+ }
130
+ },
131
+ {
132
+ id: "open-permissions",
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
+ }))
141
+ },
142
+ {
143
+ id: "executable-files",
144
+ description: "values.yml or a worktree's .env has execute permission.",
145
+ check: (ctx) => envFiles(ctx).filter((f) => f.mode !== undefined && (f.mode & 73) !== 0).map((f) => ({
146
+ location: f.location,
147
+ message: `The file is executable (mode ${(f.mode & 511).toString(8)}); env files are data and must not be. Run \`chmod -x\` on it.`
148
+ }))
149
+ },
150
+ {
151
+ id: "writable-files",
152
+ description: "values.yml or a worktree's .env is writable by other users.",
153
+ check: (ctx) => envFiles(ctx).filter((f) => f.mode !== undefined && (f.mode & 18) !== 0).map((f) => ({
154
+ location: f.location,
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.`
156
+ }))
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
+ },
190
+ {
191
+ id: "tracked-dotenv",
192
+ description: "A worktree's .env is tracked by git.",
193
+ async check(ctx) {
194
+ const found = [];
195
+ for (const { name, path } of ctx.dotenvs) {
196
+ if (!await ctx.isTracked(path, ".env"))
197
+ continue;
198
+ found.push({
199
+ location: `${name}/.env`,
200
+ message: "The .env is tracked by git. Run `git rm --cached .env` and add it to .gitignore."
201
+ });
111
202
  }
112
- ];
113
- }
114
- },
115
- {
116
- id: "open-permissions",
117
- description: "values.yml is readable by other users.",
118
- check({ valuesMode }) {
119
- if (valuesMode === undefined || (valuesMode & 63) === 0)
120
- return [];
121
- return [
122
- {
123
- location: valuesFile,
124
- message: `values.yml is readable by other users (mode ${(valuesMode & 511).toString(8)}). Run \`chmod 600 .envs/values.yml\`.`
203
+ return found;
204
+ }
205
+ },
206
+ {
207
+ id: "unignored-dotenv",
208
+ description: "A worktree's .env is neither tracked nor ignored by git.",
209
+ async check(ctx) {
210
+ const found = [];
211
+ for (const { name, path } of ctx.dotenvs) {
212
+ if (await ctx.isTracked(path, ".env"))
213
+ continue;
214
+ if (await ctx.isIgnored(path, ".env"))
215
+ continue;
216
+ found.push({
217
+ location: `${name}/.env`,
218
+ message: "The .env is not ignored by git. Add `.env` to .gitignore."
219
+ });
125
220
  }
126
- ];
127
- }
128
- },
129
- {
130
- id: "executable-files",
131
- description: "values.yml or a worktree's .env has execute permission.",
132
- check: (ctx) => envFiles(ctx).filter((f) => f.mode !== undefined && (f.mode & 73) !== 0).map((f) => ({
133
- location: f.location,
134
- message: `The file is executable (mode ${(f.mode & 511).toString(8)}); env files are data and must not be. Run \`chmod -x\` on it.`
135
- }))
136
- },
137
- {
138
- id: "writable-files",
139
- description: "values.yml or a worktree's .env is writable by other users.",
140
- check: (ctx) => envFiles(ctx).filter((f) => f.mode !== undefined && (f.mode & 18) !== 0).map((f) => ({
141
- location: f.location,
142
- 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.`
143
- }))
144
- },
145
- {
146
- id: "tracked-dotenv",
147
- description: "A worktree's .env is tracked by git.",
148
- async check(ctx) {
149
- const found = [];
150
- for (const { name, path } of ctx.dotenvs) {
151
- if (!await ctx.isTracked(path, ".env"))
152
- continue;
153
- found.push({
154
- location: `${name}/.env`,
155
- message: "The .env is tracked by git. Run `git rm --cached .env` and add it to .gitignore."
156
- });
221
+ return found;
157
222
  }
158
- return found;
159
- }
160
- },
161
- {
162
- id: "unignored-dotenv",
163
- description: "A worktree's .env is neither tracked nor ignored by git.",
164
- async check(ctx) {
165
- const found = [];
166
- for (const { name, path } of ctx.dotenvs) {
167
- if (await ctx.isTracked(path, ".env"))
168
- continue;
169
- if (await ctx.isIgnored(path, ".env"))
170
- continue;
171
- found.push({
172
- location: `${name}/.env`,
173
- message: "The .env is not ignored by git. Add `.env` to .gitignore."
223
+ },
224
+ {
225
+ id: "weak-secret",
226
+ description: "A sensitive variable is empty or has an easily guessed value.",
227
+ check: (ctx) => entries(ctx).filter((e) => isSensitiveKey(e.key) && WEAK_VALUES.has(e.text.trim().toLowerCase())).map((e) => ({
228
+ location: e.location,
229
+ message: `${e.key} has an empty or easily guessed value.`
230
+ }))
231
+ },
232
+ {
233
+ id: "shared-secret",
234
+ description: "A sensitive variable is in defaults, so it goes to every worktree using that profile.",
235
+ check: (ctx) => entries(ctx).filter((e) => e.shared && isSensitiveKey(e.key)).map((e) => ({
236
+ location: e.location,
237
+ message: `${e.key} looks sensitive but is written to every worktree; set it per worktree under envs instead.`
238
+ }))
239
+ },
240
+ {
241
+ id: "secret-pattern",
242
+ description: "A value has the format of a well-known secret.",
243
+ check: (ctx) => entries(ctx).flatMap((e) => SECRET_PATTERNS.filter(([pattern]) => pattern.test(e.text)).map(([, name]) => ({
244
+ location: e.location,
245
+ message: `${e.key} looks like ${name}.`
246
+ })))
247
+ },
248
+ {
249
+ id: "url-credentials",
250
+ description: "A remote URL embeds a password.",
251
+ check: (ctx) => entries(ctx).filter((e) => {
252
+ const url = parseUrl(e.text);
253
+ return url?.hasPassword && !url.local;
254
+ }).map((e) => ({
255
+ location: e.location,
256
+ message: `${e.key} embeds a password in its URL.`
257
+ }))
258
+ },
259
+ {
260
+ id: "insecure-url",
261
+ description: "A remote URL uses an unencrypted protocol.",
262
+ check: (ctx) => entries(ctx).filter((e) => {
263
+ const url = parseUrl(e.text);
264
+ return url && !url.local && ["http", "ws", "ftp"].includes(url.scheme);
265
+ }).map((e) => ({
266
+ location: e.location,
267
+ message: `${e.key} uses ${parseUrl(e.text).scheme}:// instead of an encrypted protocol.`
268
+ }))
269
+ },
270
+ {
271
+ id: "unknown-profile",
272
+ description: "A worktree uses a profile that is not defined.",
273
+ check: ({ values }) => Object.entries(values.uses ?? {}).filter(([, profile]) => profile !== "default" && !Object.hasOwn(values.profiles ?? {}, profile)).map(([worktree, profile]) => ({
274
+ location: `uses.${worktree}`,
275
+ message: `Profile "${profile}" is not defined under profiles; \`envs edit\` creates it from default.`
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\`.`
174
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;
175
312
  }
176
- return found;
177
313
  }
178
- },
179
- {
180
- id: "weak-secret",
181
- description: "A sensitive variable is empty or has an easily guessed value.",
182
- check: (ctx) => entries(ctx).filter((e) => isSensitiveKey(e.key) && WEAK_VALUES.has(e.text.trim().toLowerCase())).map((e) => ({
183
- location: e.location,
184
- message: `${e.key} has an empty or easily guessed value.`
185
- }))
186
- },
187
- {
188
- id: "shared-secret",
189
- description: "A sensitive variable is in defaults, so it goes to every worktree using that profile.",
190
- check: (ctx) => entries(ctx).filter((e) => e.shared && isSensitiveKey(e.key)).map((e) => ({
191
- location: e.location,
192
- message: `${e.key} looks sensitive but is written to every worktree; set it per worktree under envs instead.`
193
- }))
194
- },
195
- {
196
- id: "secret-pattern",
197
- description: "A value has the format of a well-known secret.",
198
- check: (ctx) => entries(ctx).flatMap((e) => SECRET_PATTERNS.filter(([pattern]) => pattern.test(e.text)).map(([, name]) => ({
199
- location: e.location,
200
- message: `${e.key} looks like ${name}.`
201
- })))
202
- },
203
- {
204
- id: "url-credentials",
205
- description: "A remote URL embeds a password.",
206
- check: (ctx) => entries(ctx).filter((e) => {
207
- const url = parseUrl(e.text);
208
- return url?.hasPassword && !url.local;
209
- }).map((e) => ({
210
- location: e.location,
211
- message: `${e.key} embeds a password in its URL.`
212
- }))
213
- },
214
- {
215
- id: "insecure-url",
216
- description: "A remote URL uses an unencrypted protocol.",
217
- check: (ctx) => entries(ctx).filter((e) => {
218
- const url = parseUrl(e.text);
219
- return url && !url.local && ["http", "ws", "ftp"].includes(url.scheme);
220
- }).map((e) => ({
221
- location: e.location,
222
- message: `${e.key} uses ${parseUrl(e.text).scheme}:// instead of an encrypted protocol.`
223
- }))
224
- },
225
- {
226
- id: "unknown-profile",
227
- description: "A worktree uses a profile that is not defined.",
228
- check: ({ values }) => Object.entries(values.uses ?? {}).filter(([, profile]) => profile !== "default" && !Object.hasOwn(values.profiles ?? {}, profile)).map(([worktree, profile]) => ({
229
- location: `uses.${worktree}`,
230
- message: `Profile "${profile}" is not defined under profiles; \`envs edit\` creates it from default.`
231
- }))
232
- }
233
- ];
314
+ ];
315
+ });
234
316
 
235
- // src/envs.ts
236
- var HELP = `Usage: envs <command>
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 ?? {})])];
237
429
 
238
- Commands:
239
- init Create the .envs/ directory (.gitignore, values.yml)
240
- pull Read each worktree's .env and write it into .envs/values.yml
241
- push Write defaults and .envs/values.yml into the .env of each worktree
242
- lint Warn about insecure values and unprotected secret files
243
- edit Pull, open .envs/values.yml in VS Code (code -w), then push
244
- use Select the profile of the current worktree (envs use <profile>), or list them
245
- help Show this help message
246
- `;
247
- var colorsDisabled = () => Boolean(process.env.NO_COLOR);
248
- var MASK = "********";
249
- var isMaskedKey = (key) => isSensitiveKey(key) || /(^|_)KEY(_|$)/i.test(key);
250
- var paint = (code, text) => colorsDisabled() ? text : `\x1B[${code}m${text}\x1B[0m`;
251
- var green = (text) => paint(32, text);
252
- var yellow = (text) => paint(33, text);
253
- var defaultContext = () => ({
254
- cwd: process.cwd(),
255
- log: (message) => console.log(message),
256
- error: (message) => console.error(message),
257
- openEditor: async (path) => {
258
- const proc = Bun.spawn(["code", "-w", path], {
259
- env: { ...process.env },
260
- stdio: ["inherit", "inherit", "inherit"]
261
- });
262
- return await proc.exited;
430
+ // src/ui/server.ts
431
+ import { join } from "path";
432
+ import { randomBytes, timingSafeEqual } from "crypto";
433
+ import { readdir } from "fs/promises";
434
+ function parseBody(body) {
435
+ if (!isRecord(body) || !isRecord(body.uses) || !isRecord(body.profiles))
436
+ return;
437
+ if (!Object.values(body.uses).every((v) => typeof v === "string"))
438
+ return;
439
+ for (const profile of Object.values(body.profiles)) {
440
+ if (!isRecord(profile) || !isRecord(profile.defaults) || !isRecord(profile.envs))
441
+ return;
442
+ if (!Object.values(profile.defaults).every(isScalar))
443
+ return;
444
+ for (const byWorktree of Object.values(profile.envs)) {
445
+ if (!isRecord(byWorktree) || !Object.values(byWorktree).every(isScalar))
446
+ return;
447
+ }
448
+ }
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 };
467
+ }
468
+ async function loadAssets() {
469
+ const assets = new Map;
470
+ if (await Bun.file(join(import.meta.dir, "app.tsx")).exists()) {
471
+ const result = await Bun.build({ entrypoints: [join(import.meta.dir, "index.html")], target: "browser" });
472
+ if (!result.success)
473
+ throw new AggregateError(result.logs, "Web editor build failed");
474
+ for (const out of result.outputs)
475
+ assets.set(out.path.replace(/^\.\//, ""), out);
476
+ } else {
477
+ const dir = join(import.meta.dir, "ui");
478
+ for (const name of await readdir(dir))
479
+ assets.set(name, Bun.file(join(dir, name)));
263
480
  }
481
+ return assets;
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
+ }
496
+ async function serveUi(ctx) {
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
+ };
527
+ const server = Bun.serve({
528
+ hostname: "127.0.0.1",
529
+ port: 0,
530
+ maxRequestBodySize: MAX_BODY_BYTES,
531
+ routes: {
532
+ "/api/values": {
533
+ GET: guard(async () => Response.json(await loadUiState(ctx))),
534
+ PUT: guard(async (req) => {
535
+ const parsed = parseBody(await req.json().catch(() => {
536
+ return;
537
+ }));
538
+ if (!parsed)
539
+ return Response.json({ error: "Invalid values" }, { status: 400 });
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
+ })
565
+ }
566
+ },
567
+ fetch: guard((req) => {
568
+ const path = new URL(req.url).pathname.slice(1) || "index.html";
569
+ const asset = assets.get(path);
570
+ return asset ? new Response(asset) : new Response("Not found", { status: 404 });
571
+ })
572
+ });
573
+ const url = `http://127.0.0.1:${server.port}/?token=${token}`;
574
+ ctx.log(`Web editor running at ${url} (Ctrl+C to stop)`);
575
+ await ctx.openUrl?.(url);
576
+ await (ctx.waitForExit ?? (() => new Promise(() => {})))();
577
+ await server.stop(true);
578
+ return 0;
579
+ }
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;
581
+ var init_server = __esm(() => {
582
+ init_envs();
583
+ ANSI = /\x1b\[[0-9;]*m/g;
584
+ MAX_BODY_BYTES = 5 * 1024 * 1024;
264
585
  });
265
- var DEFAULT_PROFILE = "default";
266
- var hasProfile = (values, name) => Object.hasOwn(values.profiles, name);
586
+
587
+ // src/envs.ts
588
+ import { AsyncLocalStorage } from "async_hooks";
589
+ import { chmod, mkdir, open, rename, unlink, writeFile } from "fs/promises";
590
+ import { basename, dirname, join as join2 } from "path";
591
+ import { stat } from "fs/promises";
592
+ import { createHash, createHmac, randomBytes as randomBytes2 } from "crypto";
593
+ import { appendFile, truncate } from "fs/promises";
594
+ var {$ } = globalThis.Bun;
267
595
  function normalizeValues(raw) {
268
- const profiles = {};
596
+ const profiles = dict();
269
597
  const add = (name, profile) => {
270
- const target = profiles[name] ??= { defaults: {}, envs: {} };
598
+ const target = profiles[name] ??= { defaults: dict(), envs: dict() };
271
599
  Object.assign(target.defaults, profile?.defaults);
272
600
  for (const [key, byWorktree] of Object.entries(profile?.envs ?? {})) {
273
- Object.assign(target.envs[key] ??= {}, byWorktree);
601
+ Object.assign(target.envs[key] ??= dict(), byWorktree);
274
602
  }
275
603
  };
276
604
  add(DEFAULT_PROFILE, { defaults: raw.defaults, envs: raw.envs });
277
605
  for (const [name, profile] of Object.entries(raw.profiles ?? {})) {
278
606
  add(name, profile);
279
607
  }
280
- 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 };
281
612
  }
282
613
  function materializeProfiles(values) {
283
614
  const created = [];
284
615
  for (const name of Object.values(values.uses).map(String)) {
285
616
  if (hasProfile(values, name))
286
617
  continue;
287
- values.profiles[name] = structuredClone(values.profiles[DEFAULT_PROFILE]);
618
+ values.profiles[name] = cloneProfile(values.profiles[DEFAULT_PROFILE]);
288
619
  created.push(name);
289
620
  }
290
621
  return created;
291
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
+ }
292
803
  function resolveEntries(values, worktree, profile, includeProfileEnvs = true) {
293
- const entries = {};
804
+ const entries = dict();
294
805
  for (const name of new Set([DEFAULT_PROFILE, profile])) {
295
806
  const layer = values.profiles[name];
296
807
  for (const [key, value] of Object.entries(layer.defaults)) {
@@ -299,14 +810,13 @@ function resolveEntries(values, worktree, profile, includeProfileEnvs = true) {
299
810
  if (name === profile && !includeProfileEnvs)
300
811
  continue;
301
812
  for (const [key, byWorktree] of Object.entries(layer.envs)) {
302
- if (byWorktree && worktree in byWorktree) {
813
+ if (byWorktree && Object.hasOwn(byWorktree, worktree)) {
303
814
  entries[key] = String(byWorktree[worktree]);
304
815
  }
305
816
  }
306
817
  }
307
818
  return entries;
308
819
  }
309
- var hasCompleteProfiles = (raw) => Object.values(raw.profiles ?? {}).every((profile) => typeof profile?.defaults === "object" && profile.defaults !== null && typeof profile.envs === "object" && profile.envs !== null);
310
820
  function stringifyValues(values) {
311
821
  const profiles = Object.fromEntries(Object.entries(values.profiles).map(([name, { defaults, envs }]) => {
312
822
  const kept = Object.fromEntries(Object.entries(envs).filter(([, byWorktree]) => Object.keys(byWorktree).length > 0));
@@ -315,8 +825,6 @@ function stringifyValues(values) {
315
825
  const out = Object.keys(values.uses).length > 0 ? { uses: values.uses, profiles } : { profiles };
316
826
  return SCHEMA_COMMENT + Bun.YAML.stringify(out, null, 2);
317
827
  }
318
- var SCHEMA_COMMENT = `# yaml-language-server: $schema=https://jondotsoy.github.io/envs/schema.json
319
- `;
320
828
  function checkProfiles(ctx, values, worktrees) {
321
829
  let ok = true;
322
830
  for (const { name } of worktrees) {
@@ -350,7 +858,7 @@ async function listWorktrees(cwd) {
350
858
  });
351
859
  }
352
860
  function parseDotenv(text) {
353
- const result = {};
861
+ const result = dict();
354
862
  for (const raw of text.split(/\r?\n/)) {
355
863
  const line = raw.trim();
356
864
  if (!line || line.startsWith("#"))
@@ -386,27 +894,107 @@ function formatDotenvValue(value) {
386
894
  return value;
387
895
  return value.includes('"') ? `'${value}'` : `"${value}"`;
388
896
  }
389
- function updateDotenv(text, entries) {
897
+ function updateDotenv(text, entries, remove = new Set) {
390
898
  const pending = new Map(Object.entries(entries));
391
899
  const lines = text === "" ? [] : text.replace(/\n$/, "").split(`
392
900
  `);
393
- const updated = lines.map((line) => {
901
+ const updated = [];
902
+ for (const line of lines) {
394
903
  const key = line.trim().match(/^(?:export\s+)?([\w.-]+)\s*=/)?.[1];
395
- if (key === undefined || !pending.has(key))
396
- 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
+ }
397
910
  const value = pending.get(key);
398
911
  pending.delete(key);
399
- return `${key}=${formatDotenvValue(value)}`;
400
- });
912
+ updated.push(`${key}=${formatDotenvValue(value)}`);
913
+ }
401
914
  for (const [key, value] of pending) {
402
915
  updated.push(`${key}=${formatDotenvValue(value)}`);
403
916
  }
404
- return updated.join(`
917
+ return updated.length === 0 ? "" : updated.join(`
405
918
  `) + `
406
919
  `;
407
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
+ }
408
996
  async function readValues(ctx) {
409
- const path = join(await findRoot(ctx.cwd), ".envs/values.yml");
997
+ const path = join2(await findRoot(ctx.cwd), ".envs/values.yml");
410
998
  if (!await Bun.file(path).exists()) {
411
999
  ctx.error(`${path} not found. Run \`envs init\` first.`);
412
1000
  return;
@@ -418,9 +1006,9 @@ async function init(ctx) {
418
1006
  const root = await findRoot(ctx.cwd);
419
1007
  const files = { ".envs/.gitignore": `*
420
1008
  `, ".envs/values.yml": SCHEMA_COMMENT };
421
- await mkdir(join(root, ".envs"), { recursive: true });
1009
+ await mkdir(join2(root, ".envs"), { recursive: true, mode: 448 });
422
1010
  for (const [name, content] of Object.entries(files)) {
423
- const path = join(root, name);
1011
+ const path = join2(root, name);
424
1012
  if (await Bun.file(path).exists()) {
425
1013
  ctx.log(`${path} already exists`);
426
1014
  } else {
@@ -428,9 +1016,90 @@ async function init(ctx) {
428
1016
  ctx.log(`Created ${path}`);
429
1017
  }
430
1018
  }
1019
+ await hardenPermissions(root);
431
1020
  return 0;
432
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
+ }
433
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) {
434
1103
  const read = await readValues(ctx);
435
1104
  if (!read)
436
1105
  return 1;
@@ -438,52 +1107,83 @@ async function push(ctx, only) {
438
1107
  const worktrees = (await listWorktrees(ctx.cwd)).filter(({ name }) => only === undefined || name === only);
439
1108
  if (!checkProfiles(ctx, values, worktrees))
440
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);
441
1124
  const changes = new Map;
1125
+ const removals = new Map;
442
1126
  for (const { name, path } of worktrees) {
443
1127
  const profile = values.uses[name] ?? DEFAULT_PROFILE;
444
1128
  const entries = resolveEntries(values, name, profile);
445
- if (Object.keys(entries).length === 0)
446
- continue;
447
1129
  const label = profile === DEFAULT_PROFILE ? name : `${name} [${profile}]`;
448
- const dotenv = Bun.file(join(path, ".env"));
1130
+ const dotenv = Bun.file(join2(path, ".env"));
449
1131
  const current = await dotenv.exists() ? await dotenv.text() : "";
450
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;
451
1136
  for (const [key, value] of Object.entries(entries)) {
452
1137
  if (existing[key] === value)
453
1138
  continue;
454
- const shown = isMaskedKey(key) ? MASK : formatDotenvValue(value);
1139
+ const shown = displayValue(key, value);
455
1140
  const line = `${key}=${shown}`;
456
1141
  changes.set(line, [...changes.get(line) ?? [], label]);
457
1142
  }
458
- 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 });
459
1146
  }
460
1147
  for (const [line, names] of changes) {
461
1148
  ctx.log(yellow(`\u21BB ${line} \u2192 ${names.join(", ")}`));
462
1149
  }
1150
+ for (const [key, names] of removals) {
1151
+ ctx.log(red(`\u2715 ${key} removed from ${names.join(", ")}`));
1152
+ }
463
1153
  return 0;
464
1154
  }
465
- async function pull(ctx) {
1155
+ async function pullLocked(ctx) {
466
1156
  const read = await readValues(ctx);
467
1157
  if (!read)
468
1158
  return 1;
469
1159
  const values = normalizeValues(read.values);
1160
+ const before = cloneState(values);
470
1161
  const worktrees = await listWorktrees(ctx.cwd);
471
1162
  materializeProfiles(values);
1163
+ const missingDefaults = new Map;
472
1164
  const pulledNames = [];
473
1165
  const pulledKeys = new Set;
474
1166
  for (const { name, path } of worktrees) {
475
1167
  const profile = values.uses[name] ??= DEFAULT_PROFILE;
476
- const dotenv = Bun.file(join(path, ".env"));
1168
+ const dotenv = Bun.file(join2(path, ".env"));
477
1169
  if (!await dotenv.exists())
478
1170
  continue;
479
1171
  const parsed = parseDotenv(await dotenv.text());
480
1172
  pulledNames.push(name);
481
1173
  for (const key of Object.keys(parsed))
482
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
+ }
483
1181
  const { envs } = values.profiles[profile];
484
- for (const [key, byWorktree] of Object.entries(envs)) {
485
- if (!(key in parsed))
486
- 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
+ }
487
1187
  }
488
1188
  const base = resolveEntries(values, name, profile, false);
489
1189
  for (const [key, value] of Object.entries(parsed)) {
@@ -492,17 +1192,22 @@ async function pull(ctx) {
492
1192
  if (envs[key])
493
1193
  delete envs[key][name];
494
1194
  } else {
495
- (envs[key] ??= {})[name] = parsedValue;
1195
+ (envs[key] ??= dict())[name] = parsedValue;
496
1196
  }
497
1197
  }
498
1198
  }
499
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
+ }
500
1205
  if (pulledNames.length > 0) {
501
1206
  ctx.log(green(`\u2193 pulling ${pulledNames.join(", ")} - ${pulledKeys.size} variables`));
502
1207
  }
503
1208
  return 0;
504
1209
  }
505
- async function use(ctx, profile) {
1210
+ async function useLocked(ctx, profile) {
506
1211
  const read = await readValues(ctx);
507
1212
  if (!read)
508
1213
  return 1;
@@ -524,12 +1229,103 @@ async function use(ctx, profile) {
524
1229
  ctx.error("Run `envs use` inside a git worktree.");
525
1230
  return 1;
526
1231
  }
1232
+ const before = cloneState(values);
527
1233
  for (const { name } of worktrees)
528
1234
  values.uses[name] ??= DEFAULT_PROFILE;
529
1235
  values.uses[current.name] = profile;
530
1236
  await Bun.write(read.path, stringifyValues(values));
1237
+ await recordHistory(await findRoot(ctx.cwd), before, values, ctx.now, ctx.error);
531
1238
  return await push(ctx, current.name);
532
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
+ }
533
1329
  async function isIgnored(dir, file) {
534
1330
  const result = await $`git check-ignore -q ${file}`.cwd(dir).nothrow().quiet();
535
1331
  return result.exitCode === 0;
@@ -547,15 +1343,20 @@ async function lint(ctx) {
547
1343
  const modeOf = async (path) => process.platform === "win32" ? undefined : (await stat(path)).mode;
548
1344
  const dotenvs = [];
549
1345
  for (const worktree of worktrees) {
550
- const path = join(worktree.path, ".env");
1346
+ const path = join2(worktree.path, ".env");
551
1347
  if (await Bun.file(path).exists()) {
552
1348
  dotenvs.push({ ...worktree, mode: await modeOf(path) });
553
1349
  }
554
1350
  }
1351
+ const historyPath = join2(root, ".envs/history.ndjson");
1352
+ const hasHistory = await Bun.file(historyPath).exists();
555
1353
  const lintContext = {
556
1354
  values: read.values,
557
1355
  root,
558
1356
  valuesMode: await modeOf(read.path),
1357
+ hasHistory,
1358
+ historyMode: hasHistory ? await modeOf(historyPath) : undefined,
1359
+ worktrees: worktrees.map(({ name }) => name),
559
1360
  dotenvs,
560
1361
  isTracked,
561
1362
  isIgnored
@@ -575,39 +1376,128 @@ async function lint(ctx) {
575
1376
  ${count} warning(s) found.`);
576
1377
  return 1;
577
1378
  }
578
- async function edit(ctx) {
579
- const valuesPath = join(await findRoot(ctx.cwd), ".envs/values.yml");
1379
+ async function edit(ctx, options = {}) {
1380
+ const valuesPath = join2(await findRoot(ctx.cwd), ".envs/values.yml");
580
1381
  if (!await Bun.file(valuesPath).exists())
581
1382
  await init(ctx);
582
1383
  const pulled = await pull(ctx);
583
1384
  if (pulled !== 0)
584
1385
  return pulled;
1386
+ if (options.ui) {
1387
+ await Promise.resolve().then(() => init_server());
1388
+ return await serveUi(ctx);
1389
+ }
585
1390
  const original = await Bun.file(valuesPath).text();
1391
+ const before = normalizeValues(Bun.YAML.parse(original) ?? {});
586
1392
  await Bun.write(valuesPath, EDIT_NOTICE + original);
587
1393
  const code = await ctx.openEditor(valuesPath);
588
1394
  const edited = await Bun.file(valuesPath).text();
1395
+ if (code !== 0) {
1396
+ await Bun.write(valuesPath, original);
1397
+ return code;
1398
+ }
589
1399
  if (edited.startsWith(EDIT_NOTICE)) {
590
1400
  await Bun.write(valuesPath, edited.slice(EDIT_NOTICE.length));
591
1401
  }
592
- if (code !== 0)
593
- return code;
594
- const raw = Bun.YAML.parse(await Bun.file(valuesPath).text()) ?? {};
595
- const values = normalizeValues(raw);
596
- const created = materializeProfiles(values);
597
- if (created.length > 0 || !hasCompleteProfiles(raw)) {
598
- await Bun.write(valuesPath, stringifyValues(values));
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
+ });
1415
+ }
1416
+ async function loadUiState(ctx) {
1417
+ const read = await readValues(ctx);
1418
+ if (!read)
1419
+ throw new Error("Run `envs init` first.");
1420
+ const [main, ...others] = (await listWorktrees(ctx.cwd)).map(({ name }) => name);
1421
+ others.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
1422
+ const worktrees = main === undefined ? [] : [main, ...others];
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));
1440
+ }
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 };
599
1448
  }
600
- if (created.length > 0) {
601
- ctx.log(green(`+ created ${created.join(", ")} from ${DEFAULT_PROFILE}`));
1449
+ const before = normalizeValues(Bun.YAML.parse(currentText) ?? {});
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
+ }
602
1463
  }
603
- return await push(ctx);
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 };
1482
+ const created = materializeProfiles(values);
1483
+ await Bun.write(path, stringifyValues(values));
1484
+ await recordHistory(root, before, values, ctx.now, ctx.error);
1485
+ return { created, code: await push(ctx) };
1486
+ }
1487
+ function valuesVersion(text) {
1488
+ return createHash("sha256").update(text).digest("hex").slice(0, 12);
604
1489
  }
605
- var 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
606
- # Close this file when your changes are ready, then they are pushed.
607
- # To cancel the edit, press Ctrl+C in the terminal and then close this file.
608
- # \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
609
- `;
610
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) {
611
1501
  const [command = "help"] = args;
612
1502
  switch (command) {
613
1503
  case "help":
@@ -624,9 +1514,15 @@ async function run(args, ctx = defaultContext()) {
624
1514
  case "lint":
625
1515
  return await lint(ctx);
626
1516
  case "edit":
627
- return await edit(ctx);
1517
+ return await edit(ctx, { ui: args.includes("--ui") });
628
1518
  case "use":
629
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));
630
1526
  default:
631
1527
  ctx.error(`Unknown command: ${command}
632
1528
 
@@ -634,6 +1530,68 @@ ${HELP}`);
634
1530
  return 1;
635
1531
  }
636
1532
  }
1533
+ var HELP = `Usage: envs <command>
1534
+
1535
+ Commands:
1536
+ init Create the .envs/ directory (.gitignore, values.yml)
1537
+ pull Read each worktree's .env and write it into .envs/values.yml
1538
+ push Write defaults and .envs/values.yml into the .env of each worktree
1539
+ lint Warn about insecure values and unprotected secret files
1540
+ edit Pull, open .envs/values.yml in VS Code (code -w), then push
1541
+ With --ui, serve a web editor instead; its Save button pushes
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>)
1546
+ help Show this help message
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 = () => ({
1548
+ cwd: process.cwd(),
1549
+ log: (message) => console.log(message),
1550
+ error: (message) => console.error(message),
1551
+ openEditor: async (path) => {
1552
+ const proc = Bun.spawn(["code", "-w", path], {
1553
+ env: { ...process.env },
1554
+ stdio: ["inherit", "inherit", "inherit"]
1555
+ });
1556
+ return await proc.exited;
1557
+ },
1558
+ openUrl: async (url) => {
1559
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
1560
+ try {
1561
+ await Bun.spawn([opener, url], { stdio: ["ignore", "ignore", "ignore"] }).exited;
1562
+ } catch {}
1563
+ },
1564
+ waitForExit: () => new Promise((resolve) => {
1565
+ process.once("SIGINT", () => resolve());
1566
+ process.once("SIGTERM", () => resolve());
1567
+ })
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
1583
+ # Close this file when your changes are ready, then they are pushed.
1584
+ # To cancel the edit, press Ctrl+C in the terminal and then close this file.
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
1586
+ `;
1587
+ var init_envs = __esm(() => {
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;
1593
+ });
637
1594
 
638
1595
  // src/bin/envs.ts
1596
+ init_envs();
639
1597
  process.exit(await run(Bun.argv.slice(2)));