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