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