@kaynooo/env-tools 0.5.0 → 0.7.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 +5 -3
- package/dist/ev.js +788 -161
- package/dist/redact.js +411 -108
- package/package.json +1 -1
- package/src/dotenv.ts +16 -4
package/dist/ev.js
CHANGED
|
@@ -276,12 +276,27 @@ function le(i = {}) {
|
|
|
276
276
|
var t = le();
|
|
277
277
|
|
|
278
278
|
// ../kit/src/style.ts
|
|
279
|
+
var MARKS = {
|
|
280
|
+
ok: { symbol: "\u2713", word: "ok:", paint: (colors) => colors.green },
|
|
281
|
+
warn: { symbol: "!", word: "warning:", paint: (colors) => colors.yellow },
|
|
282
|
+
problem: { symbol: "\u2717", word: "error:", paint: (colors) => colors.red },
|
|
283
|
+
note: { symbol: "\xB7", word: "note:", paint: (colors) => colors.dim }
|
|
284
|
+
};
|
|
279
285
|
function takesControlCodes(stream, env = process2.env) {
|
|
280
286
|
return stream.isTTY === true && env.NO_COLOR === undefined && env.NODE_DISABLE_COLORS === undefined && env.TERM !== "dumb";
|
|
281
287
|
}
|
|
282
288
|
function paint(stream, env = process2.env) {
|
|
283
289
|
return le({ stream: { isTTY: takesControlCodes(stream, env) }, env });
|
|
284
290
|
}
|
|
291
|
+
function marked(mark, text, stream, detail, env = process2.env) {
|
|
292
|
+
const colors = paint(stream, env);
|
|
293
|
+
const { symbol, word, paint: tint } = MARKS[mark];
|
|
294
|
+
const head = takesControlCodes(stream, env) ? tint(colors)(symbol) : word;
|
|
295
|
+
return `${head} ${text}${detail === undefined ? "" : ` ${colors.dim(detail)}`}`;
|
|
296
|
+
}
|
|
297
|
+
function ok(text, detail) {
|
|
298
|
+
console.log(marked("ok", text, process2.stdout, detail));
|
|
299
|
+
}
|
|
285
300
|
var STATUSES = {
|
|
286
301
|
passed: { symbol: "\u2713", words: ["passed", "success", "succeeded", "ok"], paint: (colors) => colors.green },
|
|
287
302
|
failed: { symbol: "\u2717", words: ["failed", "failure", "error", "errored"], paint: (colors) => colors.red },
|
|
@@ -343,6 +358,26 @@ function printJson(value) {
|
|
|
343
358
|
console.log(JSON.stringify(value, undefined, 2));
|
|
344
359
|
}
|
|
345
360
|
|
|
361
|
+
// ../kit/src/text.ts
|
|
362
|
+
var ANSI = new RegExp(`${String.fromCharCode(27)}\\[[\\d;?]*[a-z]`, "gi");
|
|
363
|
+
function distance(a, b) {
|
|
364
|
+
let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
365
|
+
for (const [i, left] of [...a].entries()) {
|
|
366
|
+
const current = [i + 1];
|
|
367
|
+
for (const [j, right] of [...b].entries())
|
|
368
|
+
current.push(Math.min((previous[j + 1] ?? 0) + 1, (current[j] ?? 0) + 1, (previous[j] ?? 0) + (left === right ? 0 : 1)));
|
|
369
|
+
previous = current;
|
|
370
|
+
}
|
|
371
|
+
return previous[b.length] ?? 0;
|
|
372
|
+
}
|
|
373
|
+
function closest(name, names) {
|
|
374
|
+
const started = names.find((known) => known.startsWith(name));
|
|
375
|
+
if (started !== undefined)
|
|
376
|
+
return started;
|
|
377
|
+
const [best] = names.map((known) => ({ known, far: distance(name, known) })).sort((a, b) => a.far - b.far);
|
|
378
|
+
return best !== undefined && best.far <= (name.length < 4 ? 1 : 2) ? best.known : undefined;
|
|
379
|
+
}
|
|
380
|
+
|
|
346
381
|
// ../kit/src/args.ts
|
|
347
382
|
function jsonOption(holds) {
|
|
348
383
|
return { type: "boolean", description: holds === undefined ? "print JSON" : `print JSON: ${holds}` };
|
|
@@ -362,8 +397,8 @@ function fail(message, code = 2, hint) {
|
|
|
362
397
|
}
|
|
363
398
|
|
|
364
399
|
class UsageError extends Exit {
|
|
365
|
-
constructor(message) {
|
|
366
|
-
super(message, 2);
|
|
400
|
+
constructor(message, hint) {
|
|
401
|
+
super(message, 2, hint);
|
|
367
402
|
}
|
|
368
403
|
}
|
|
369
404
|
function badUsage(message) {
|
|
@@ -386,6 +421,7 @@ var completion = { active: false };
|
|
|
386
421
|
var OPTIONAL = /\[[^\]]+\]/g;
|
|
387
422
|
var USAGE_PREFIX = /^usage:\s*/i;
|
|
388
423
|
var FULL_STOP = /\.$/;
|
|
424
|
+
var DASHES = /^-+/;
|
|
389
425
|
function usageLine(usage, colors = paint(process4.stdout)) {
|
|
390
426
|
return `${colors.bold("Usage:")} ${usage.replace(USAGE_PREFIX, "").replaceAll(OPTIONAL, (part) => colors.dim(part))}`;
|
|
391
427
|
}
|
|
@@ -422,10 +458,10 @@ function optionRows(options, colors = paint(process4.stdout)) {
|
|
|
422
458
|
const rows = Object.entries(options).map(([name, option]) => {
|
|
423
459
|
const short = option.short === undefined ? " " : `${colors.cyan(`-${option.short}`)}, `;
|
|
424
460
|
const value = option.type === "string" ? ` ${colors.dim("<value>")}` : "";
|
|
425
|
-
return { flag: `${short}${colors.cyan(`--${name}`)}${value}`, width: `-x, --${name}`.length + (option.type === "string" ? " <value>".length : 0), description: option.description };
|
|
461
|
+
return { flag: `${short}${colors.cyan(`--${name}`)}${value}`, width: `-x, --${name}`.length + (option.type === "string" ? " <value>".length : 0), description: option.description ?? "" };
|
|
426
462
|
});
|
|
427
463
|
const width = Math.max(0, ...rows.map((row) => row.width));
|
|
428
|
-
return rows.map((row) => ` ${row.flag}${" ".repeat(width - row.width)} ${colors.dim(row.description)}`);
|
|
464
|
+
return rows.map((row) => row.description === "" ? ` ${row.flag}` : ` ${row.flag}${" ".repeat(width - row.width)} ${colors.dim(row.description)}`);
|
|
429
465
|
}
|
|
430
466
|
var OUTPUT_OPTIONS = {
|
|
431
467
|
color: { type: "string", description: "auto (on a terminal alone), always or never" },
|
|
@@ -457,7 +493,8 @@ function parse(args, usage, options, settings = {}) {
|
|
|
457
493
|
try {
|
|
458
494
|
parsed = parseArgs({ args: joinDashValues(own, all), options: all, allowPositionals: true, allowNegative: settings.allowNegative === true, strict: true });
|
|
459
495
|
} catch (error) {
|
|
460
|
-
|
|
496
|
+
const message = parseError(error);
|
|
497
|
+
throw new UsageError(message, nearestOption(message, all, settings.allowNegative === true));
|
|
461
498
|
}
|
|
462
499
|
const values = parsed.values;
|
|
463
500
|
applyOutput("color" in output ? values.color : undefined, "plain" in output ? values.plain : undefined);
|
|
@@ -500,6 +537,22 @@ function parseError(error) {
|
|
|
500
537
|
const message = upTo(error instanceof Error ? error.message : String(error), ". ");
|
|
501
538
|
return `${message.charAt(0).toLowerCase()}${message.slice(1)}`;
|
|
502
539
|
}
|
|
540
|
+
var UNKNOWN_OPTION = /^unknown option '(-[^']+)'/;
|
|
541
|
+
function nearestOption(message, options, allowNegative) {
|
|
542
|
+
const typed = UNKNOWN_OPTION.exec(message)?.[1];
|
|
543
|
+
if (typed === undefined)
|
|
544
|
+
return;
|
|
545
|
+
const word = typed.replace(DASHES, "");
|
|
546
|
+
if (word.length < 2 && !typed.startsWith("--"))
|
|
547
|
+
return;
|
|
548
|
+
const known = Object.entries(options).flatMap(([name, option]) => [
|
|
549
|
+
...option.short === undefined ? [] : [`-${option.short}`],
|
|
550
|
+
`--${name}`,
|
|
551
|
+
...allowNegative && option.type === "boolean" ? [`--no-${name}`] : []
|
|
552
|
+
]);
|
|
553
|
+
const meant = closest(word, known.map((flag) => flag.replace(DASHES, "")));
|
|
554
|
+
return meant === undefined ? undefined : `did you mean ${known.find((flag) => flag.replace(DASHES, "") === meant)}?`;
|
|
555
|
+
}
|
|
503
556
|
async function readInput(path, { noStdin = "nothing is piped on stdin" } = {}) {
|
|
504
557
|
if (path === "-") {
|
|
505
558
|
if (process4.stdin.isTTY)
|
|
@@ -511,35 +564,26 @@ async function readInput(path, { noStdin = "nothing is piped on stdin" } = {}) {
|
|
|
511
564
|
fail(`no file ${path}`);
|
|
512
565
|
return file.text();
|
|
513
566
|
}
|
|
567
|
+
// ../kit/src/progress.ts
|
|
568
|
+
var ESC = String.fromCharCode(27);
|
|
569
|
+
var ESCAPE_CODE = new RegExp(`${ESC}(?:\\[[\\x30-\\x3F]*[\\x20-\\x2F]*[\\x40-\\x7E]|\\][^\\u0007${ESC}]*(?:\\u0007|${ESC}\\\\))`, "y");
|
|
570
|
+
var RESET_STYLE = `${ESC}[0m`;
|
|
571
|
+
var CLOSE_LINK = `${ESC}]8;;${ESC}\\`;
|
|
514
572
|
// ../kit/src/cli.ts
|
|
515
573
|
import process5 from "process";
|
|
516
|
-
|
|
517
|
-
// ../kit/src/text.ts
|
|
518
|
-
var ANSI = new RegExp(`${String.fromCharCode(27)}\\[[\\d;?]*[a-z]`, "gi");
|
|
519
|
-
function distance(a, b) {
|
|
520
|
-
let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
521
|
-
for (const [i, left] of [...a].entries()) {
|
|
522
|
-
const current = [i + 1];
|
|
523
|
-
for (const [j, right] of [...b].entries())
|
|
524
|
-
current.push(Math.min((previous[j + 1] ?? 0) + 1, (current[j] ?? 0) + 1, (previous[j] ?? 0) + (left === right ? 0 : 1)));
|
|
525
|
-
previous = current;
|
|
526
|
-
}
|
|
527
|
-
return previous[b.length] ?? 0;
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
// ../kit/src/cli.ts
|
|
531
574
|
var LEVEL_OPTIONS = {
|
|
532
575
|
...OUTPUT_OPTIONS,
|
|
533
576
|
help: { type: "boolean", short: "h", description: "show this help" }
|
|
534
577
|
};
|
|
535
578
|
var TOP_OPTIONS = { ...LEVEL_OPTIONS, version: { type: "boolean", short: "v", description: "print the version" } };
|
|
536
|
-
function commandRows(commands, colors) {
|
|
579
|
+
async function commandRows(commands, colors) {
|
|
537
580
|
const width = Math.max(...Object.keys(commands).map((name) => name.length));
|
|
538
581
|
const groups = new Map([["Commands:", []]]);
|
|
539
|
-
|
|
582
|
+
const summaries = await Promise.all(Object.values(commands).map(async ({ summary }) => resolveSummary(summary)));
|
|
583
|
+
for (const [index, [name, { group }]] of Object.entries(commands).entries()) {
|
|
540
584
|
const heading = group === undefined ? "Commands:" : `${group}:`;
|
|
541
585
|
const rows = groups.get(heading) ?? [];
|
|
542
|
-
rows.push(` ${colors.cyan(name.padEnd(width))} ${colors.dim(
|
|
586
|
+
rows.push(` ${colors.cyan(name.padEnd(width))} ${colors.dim(summaries[index])}`);
|
|
543
587
|
groups.set(heading, rows);
|
|
544
588
|
}
|
|
545
589
|
return [...groups].filter(([, rows]) => rows.length > 0).flatMap(([heading, rows], index) => [...index === 0 ? [] : [""], colors.bold(heading), ...rows]);
|
|
@@ -559,28 +603,38 @@ function takeOutputFlags(argv) {
|
|
|
559
603
|
}
|
|
560
604
|
return argv.slice(index);
|
|
561
605
|
}
|
|
562
|
-
function
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
return best !== undefined && best.far <= (name.length < 4 ? 1 : 2) ? best.known : undefined;
|
|
606
|
+
async function resolveSummary(summary) {
|
|
607
|
+
return typeof summary === "string" ? summary : summary();
|
|
608
|
+
}
|
|
609
|
+
function scopeOf(commands, extra, aliases) {
|
|
610
|
+
return { commands: typeof commands === "function" ? {} : commands, lazy: [...typeof commands === "function" ? [commands] : [], ...extra === undefined ? [] : [extra]], aliases };
|
|
568
611
|
}
|
|
569
|
-
async function
|
|
612
|
+
async function expand(scope, words) {
|
|
613
|
+
if (scope.lazy.length === 0)
|
|
614
|
+
return scope;
|
|
615
|
+
const commands = { ...scope.commands };
|
|
616
|
+
for (const added of await Promise.all(scope.lazy.map(async (lazy) => lazy(words)))) {
|
|
617
|
+
for (const [name, command] of Object.entries(added))
|
|
618
|
+
commands[name] ??= command;
|
|
619
|
+
}
|
|
620
|
+
return { ...scope, commands, lazy: [] };
|
|
621
|
+
}
|
|
622
|
+
async function help({ path, header, summary: lazySummary, commands, defaultCommand, examples, replaces }, stream) {
|
|
570
623
|
const colors = paint(stream);
|
|
571
624
|
const prefix = path.join(" ");
|
|
625
|
+
const summary = await resolveSummary(lazySummary);
|
|
572
626
|
if (wantsHelpJson())
|
|
573
627
|
return JSON.stringify(description(`${prefix} <command> [options]: ${summary}`, { examples, replaces }));
|
|
574
628
|
const fallback = defaultCommand === undefined ? undefined : commands[defaultCommand];
|
|
575
629
|
const options = fallback !== undefined && "run" in fallback ? await optionsOf(fallback.run) : undefined;
|
|
576
630
|
const own = Object.entries(options ?? {}).filter(([name]) => !(name in TOP_OPTIONS));
|
|
577
631
|
return [
|
|
578
|
-
header(colors),
|
|
632
|
+
header(colors, summary),
|
|
579
633
|
"",
|
|
580
634
|
usageLine(`${prefix} ${defaultCommand === undefined ? colors.cyan("<command>") : `[${colors.cyan("<command>")}]`} [options]`, colors),
|
|
581
635
|
...replacesRows(replaces, colors),
|
|
582
636
|
"",
|
|
583
|
-
...commandRows(commands, colors),
|
|
637
|
+
...await commandRows(commands, colors),
|
|
584
638
|
...own.length === 0 ? [] : ["", `${colors.bold(`Options of ${defaultCommand}:`)} ${colors.dim("the command run when none is named")}`, ...optionRows(Object.fromEntries(own), colors)],
|
|
585
639
|
"",
|
|
586
640
|
colors.bold("Options:"),
|
|
@@ -626,11 +680,11 @@ async function dispatch(level, typed, { version, redact }) {
|
|
|
626
680
|
}
|
|
627
681
|
const [name, ...args] = argv;
|
|
628
682
|
if (name === undefined && level.path.length > 1) {
|
|
629
|
-
console.error(await help(level, process5.stderr));
|
|
683
|
+
console.error(await help(await expand(level, [...level.path.slice(1), ...argv]), process5.stderr));
|
|
630
684
|
return 2;
|
|
631
685
|
}
|
|
632
686
|
if (name === undefined || name === "help" || name === "--help" || name === "-h") {
|
|
633
|
-
console.log(await help(level, process5.stdout));
|
|
687
|
+
console.log(await help(await expand(level, [...level.path.slice(1), ...argv]), process5.stdout));
|
|
634
688
|
return 0;
|
|
635
689
|
}
|
|
636
690
|
if (level.path.length === 1 && (name === "--version" || name === "-v")) {
|
|
@@ -639,6 +693,10 @@ async function dispatch(level, typed, { version, redact }) {
|
|
|
639
693
|
}
|
|
640
694
|
let command = level.commands[level.aliases?.[name] ?? name];
|
|
641
695
|
let rest = args;
|
|
696
|
+
if (command === undefined && level.lazy.length > 0) {
|
|
697
|
+
level = await expand(level, [...level.path.slice(1), ...argv]);
|
|
698
|
+
command = level.commands[level.aliases?.[name] ?? name];
|
|
699
|
+
}
|
|
642
700
|
if (command === undefined && level.defaultCommand !== undefined) {
|
|
643
701
|
command = level.commands[level.defaultCommand];
|
|
644
702
|
rest = argv;
|
|
@@ -650,7 +708,7 @@ async function dispatch(level, typed, { version, redact }) {
|
|
|
650
708
|
}
|
|
651
709
|
if ("commands" in command) {
|
|
652
710
|
const { summary } = command;
|
|
653
|
-
return dispatch({ path: [...level.path, name], header: (colors) => `${colors.bold(`${prefix} ${name}`)} ${
|
|
711
|
+
return dispatch({ path: [...level.path, name], header: (colors, text) => `${colors.bold(`${prefix} ${name}`)} ${text}`, summary, ...scopeOf(command.commands, command.extraCommands, command.aliases), defaultCommand: undefined, examples: command.examples, replaces: undefined }, rest, { version, redact });
|
|
654
712
|
}
|
|
655
713
|
try {
|
|
656
714
|
return await command.run(rest);
|
|
@@ -675,18 +733,24 @@ async function optionsOf(run) {
|
|
|
675
733
|
async function leafCandidates(run, words) {
|
|
676
734
|
if (!(words.at(-1) ?? "").startsWith("-"))
|
|
677
735
|
return [FILES];
|
|
678
|
-
return Object.entries(await optionsOf(run)).map(([name, option]) => `--${name} ${option.description}`);
|
|
736
|
+
return Object.entries(await optionsOf(run)).map(([name, option]) => option.description === undefined ? `--${name}` : `--${name} ${option.description}`);
|
|
679
737
|
}
|
|
680
|
-
async function candidates(
|
|
738
|
+
async function candidates(scope, defaultCommand, words, before = []) {
|
|
681
739
|
const [word = "", ...rest] = words;
|
|
682
|
-
const
|
|
683
|
-
|
|
684
|
-
|
|
740
|
+
const listing = rest.length === 0 && !word.startsWith("-");
|
|
741
|
+
let { commands, aliases } = scope;
|
|
742
|
+
let command = commands[aliases?.[word] ?? word];
|
|
743
|
+
if (listing || command === undefined) {
|
|
744
|
+
({ commands, aliases } = await expand(scope, [...before, ...words]));
|
|
745
|
+
command = commands[aliases?.[word] ?? word];
|
|
746
|
+
}
|
|
747
|
+
if (listing)
|
|
748
|
+
return Promise.all(Object.entries(commands).map(async ([name, { summary }]) => `${name} ${await resolveSummary(summary)}`));
|
|
685
749
|
if (command === undefined) {
|
|
686
750
|
const fallback = defaultCommand === undefined ? undefined : commands[defaultCommand];
|
|
687
751
|
return fallback !== undefined && "run" in fallback ? leafCandidates(fallback.run, words) : [];
|
|
688
752
|
}
|
|
689
|
-
return "commands" in command ? candidates(command.commands, command.aliases, undefined, rest) : leafCandidates(command.run, rest);
|
|
753
|
+
return "commands" in command ? candidates(scopeOf(command.commands, command.extraCommands, command.aliases), undefined, rest, [...before, word]) : leafCandidates(command.run, rest);
|
|
690
754
|
}
|
|
691
755
|
async function printCandidates(found) {
|
|
692
756
|
for (const line of await found)
|
|
@@ -695,8 +759,8 @@ async function printCandidates(found) {
|
|
|
695
759
|
}
|
|
696
760
|
async function runCli(cli, argv = process5.argv.slice(2)) {
|
|
697
761
|
if (argv[0] === COMPLETE)
|
|
698
|
-
return printCandidates(candidates(cli.commands, cli.aliases, cli.defaultCommand, argv.slice(1)));
|
|
699
|
-
return dispatch({ path: [cli.name], header: (colors) => `${colors.bold(cli.name)} ${colors.dim(cli.version)} ${
|
|
762
|
+
return printCandidates(candidates(scopeOf(cli.commands, cli.extraCommands, cli.aliases), cli.defaultCommand, argv.slice(1)));
|
|
763
|
+
return dispatch({ path: [cli.name], header: (colors, about) => `${colors.bold(cli.name)} ${colors.dim(cli.version)} ${about}`, summary: cli.about, ...scopeOf(cli.commands, cli.extraCommands, cli.aliases), defaultCommand: cli.defaultCommand, examples: cli.examples, replaces: cli.replaces }, argv, { version: cli.version, redact: cli.redact });
|
|
700
764
|
}
|
|
701
765
|
async function runMain({ name, version, run, redact }, argv = process5.argv.slice(2)) {
|
|
702
766
|
if (argv[0] === COMPLETE)
|
|
@@ -711,21 +775,31 @@ async function runMain({ name, version, run, redact }, argv = process5.argv.slic
|
|
|
711
775
|
return reportThrown(name, error, redact, wantsJson(argv));
|
|
712
776
|
}
|
|
713
777
|
}
|
|
778
|
+
// ../kit/src/logs.ts
|
|
779
|
+
var DAY_MS = 24 * 60 * 60 * 1000;
|
|
780
|
+
// ../kit/src/secrets.ts
|
|
781
|
+
var SECRET_PART = /token|secret|passw|credential|private|dsn|apikey|accesskey|key$/i;
|
|
782
|
+
var SECRET_WORDS = new Set(["key", "pass", "pwd", "auth", "salt", "cookie", "session", "jwt", "ssn", "iban", "card", "cvv", "otp"]);
|
|
783
|
+
var WORD_BREAKS = /[^a-zA-Z\d]+|(?<=[a-z\d])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/;
|
|
784
|
+
function isSecretName(name) {
|
|
785
|
+
return SECRET_PART.test(name) || name.split(WORD_BREAKS).some((word) => SECRET_WORDS.has(word.toLowerCase()));
|
|
786
|
+
}
|
|
714
787
|
// package.json
|
|
715
|
-
var version = "0.
|
|
788
|
+
var version = "0.7.0";
|
|
716
789
|
|
|
717
790
|
// src/commands/audit.ts
|
|
718
|
-
import { stat as
|
|
791
|
+
import { stat as stat4 } from "fs/promises";
|
|
719
792
|
import { relative, resolve } from "path";
|
|
720
793
|
import process7 from "process";
|
|
721
794
|
|
|
722
795
|
// src/audit.ts
|
|
723
|
-
import { readdir as readdir2, stat } from "fs/promises";
|
|
724
|
-
import { dirname, join } from "path";
|
|
796
|
+
import { readdir as readdir2, stat as stat2 } from "fs/promises";
|
|
797
|
+
import { dirname as dirname2, join as join2 } from "path";
|
|
725
798
|
var {$ } = globalThis.Bun;
|
|
726
799
|
|
|
727
800
|
// src/files.ts
|
|
728
|
-
import { readdir } from "fs/promises";
|
|
801
|
+
import { chmod, chown, readdir, realpath, rename, rm, stat, writeFile } from "fs/promises";
|
|
802
|
+
import { basename, dirname, join } from "path";
|
|
729
803
|
|
|
730
804
|
// src/dotenv.ts
|
|
731
805
|
var ASSIGNMENT = /^\s*(?:export\s+)?([A-Z_][\w.-]*)\s*=(.*)$/i;
|
|
@@ -733,7 +807,9 @@ var EXPORTED = /^\s*export\s/;
|
|
|
733
807
|
var INLINE_COMMENT = /\s+#.*$/;
|
|
734
808
|
var CARRIAGE_RETURN = /\r$/;
|
|
735
809
|
var ESCAPES = { n: `
|
|
736
|
-
`, r: "\r", t: "\t", '"': '"', "\\": "\\" };
|
|
810
|
+
`, r: "\r", t: "\t", '"': '"', "\\": "\\", $: "$" };
|
|
811
|
+
var LINE_BREAK = /[\r\n]/;
|
|
812
|
+
var RAW_ONLY = /["\\]/;
|
|
737
813
|
var NEEDS_QUOTES = /[\s#"'\\$`]/;
|
|
738
814
|
function closingQuote(text, quote) {
|
|
739
815
|
for (let index = 0;index < text.length; index++) {
|
|
@@ -744,6 +820,9 @@ function closingQuote(text, quote) {
|
|
|
744
820
|
}
|
|
745
821
|
return -1;
|
|
746
822
|
}
|
|
823
|
+
function unescapeDollar(text) {
|
|
824
|
+
return text.replaceAll("\\$", "$");
|
|
825
|
+
}
|
|
747
826
|
function unescapeDouble(text) {
|
|
748
827
|
let out = "";
|
|
749
828
|
for (let index = 0;index < text.length; index++) {
|
|
@@ -772,7 +851,7 @@ function parseDotenv(text) {
|
|
|
772
851
|
const start = rest.trimStart();
|
|
773
852
|
const quote = start[0];
|
|
774
853
|
if (quote !== '"' && quote !== "'" && quote !== "`") {
|
|
775
|
-
lines.push({ raw: line, key, value: start.replace(INLINE_COMMENT, "").trim() });
|
|
854
|
+
lines.push({ raw: line, key, value: unescapeDollar(start.replace(INLINE_COMMENT, "").trim()) });
|
|
776
855
|
continue;
|
|
777
856
|
}
|
|
778
857
|
let body = start.slice(1);
|
|
@@ -787,7 +866,7 @@ ${physical[index] ?? ""}`;
|
|
|
787
866
|
end = closingQuote(body, quote);
|
|
788
867
|
}
|
|
789
868
|
const inner = end === -1 ? body : body.slice(0, end);
|
|
790
|
-
lines.push({ raw, key, value: quote === '"' ? unescapeDouble(inner) : inner });
|
|
869
|
+
lines.push({ raw, key, value: quote === '"' ? unescapeDouble(inner) : unescapeDollar(inner) });
|
|
791
870
|
}
|
|
792
871
|
return { lines };
|
|
793
872
|
}
|
|
@@ -806,8 +885,10 @@ function entries(file) {
|
|
|
806
885
|
function formatAssignment(key, value) {
|
|
807
886
|
if (value === "" || !NEEDS_QUOTES.test(value))
|
|
808
887
|
return `${key}=${value}`;
|
|
888
|
+
if (RAW_ONLY.test(value) && !value.includes("'") && !LINE_BREAK.test(value))
|
|
889
|
+
return `${key}='${value.replaceAll("$", "\\$")}'`;
|
|
809
890
|
const escaped = value.replaceAll("\\", "\\\\").replaceAll('"', "\\\"").replaceAll("\r", "\\r").replaceAll(`
|
|
810
|
-
`, "\\n");
|
|
891
|
+
`, "\\n").replaceAll("$", "\\$");
|
|
811
892
|
return `${key}="${escaped}"`;
|
|
812
893
|
}
|
|
813
894
|
function setLine(file, key, value, raw) {
|
|
@@ -837,51 +918,137 @@ function lastLine(file, key) {
|
|
|
837
918
|
}
|
|
838
919
|
|
|
839
920
|
// src/mask.ts
|
|
840
|
-
var MIN_KNOWN_LENGTH =
|
|
841
|
-
var
|
|
842
|
-
var
|
|
921
|
+
var MIN_KNOWN_LENGTH = 4;
|
|
922
|
+
var KEY_MASK = "*** private key redacted ***";
|
|
923
|
+
var PLAIN_VALUE = /^(?:true|false|null|none|nil|yes|no|on|off|undefined)$/i;
|
|
924
|
+
var PLACEHOLDER = /^[$%{<(*/]/;
|
|
925
|
+
var URL_PASSWORD = /(?<![a-z0-9+.-])[a-z][a-z0-9+.-]{0,31}:\/\/[^\s:/@]*:([^\s'"]{1,1024})@/dgi;
|
|
926
|
+
var PORT = /^\d{1,5}$/;
|
|
843
927
|
var ESCAPE2 = /[.*+?^${}()|[\]\\]/g;
|
|
928
|
+
function isPlaceholder(value) {
|
|
929
|
+
return PLACEHOLDER.test(value) || PLAIN_VALUE.test(value);
|
|
930
|
+
}
|
|
844
931
|
var TOKEN_FORMATS = [
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
932
|
+
{ kind: "GitHub token", pattern: /\b(?:gh[pousr]_|github_pat_)\w{20,}/dg },
|
|
933
|
+
{ kind: "GitLab token", pattern: /\bglpat-[\w-]{20,}/dg },
|
|
934
|
+
{ kind: "API key", pattern: /\bsk-[\w-]{20,}/dg },
|
|
935
|
+
{ kind: "Slack token", pattern: /\bx(?:ox[abprs]|app)-[\w-]{10,}/dg },
|
|
936
|
+
{ kind: "JWT", pattern: /\beyJ[\w-]+\.[\w-]+\.[\w-]+/dg },
|
|
937
|
+
{ kind: "AWS access key", pattern: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/dg },
|
|
938
|
+
{ kind: "npm token", pattern: /\bnpm_[A-Za-z0-9]{30,}/dg },
|
|
939
|
+
{ kind: "Stripe key", pattern: /\b[sr]k_(?:live|test)_[A-Za-z0-9]{10,}/dg },
|
|
940
|
+
{ kind: "Google API key", pattern: /\bAIza[\w-]{35}/dg },
|
|
941
|
+
{ kind: "SendGrid key", pattern: /\bSG\.[\w-]{16,}\.[\w-]{16,}/dg },
|
|
942
|
+
{ kind: "Authorization header", pattern: /\bAuthorization["']?\s*[:=]\s*["']?(?:Bearer|Basic|Token|Digest)\s+([^\s"',;]{8,})/dgi, skip: isPlaceholder },
|
|
943
|
+
{ kind: "Bearer token", pattern: /\bBearer\s+((?=[\w.~+/=-]*\d)[\w.~+/=-]{20,})/dg },
|
|
944
|
+
{ kind: "password", pattern: /(?<![\w-])(?:[\w-]{0,30}[_-])?(?:password|passwd|secret|api[_-]?key)(?:[_-][\w-]{0,30})?=([^\s"'&;,]{4,})/dgi, skip: isPlaceholder },
|
|
945
|
+
{ kind: "password", pattern: /(?<![\w-])["']?(?:[\w-]{0,30}[_-])?(?:password|passwd|secret|api[_-]?key)(?:[_-][\w-]{0,30})?["']?\s*[:=]\s*(["'])((?:\\.|(?!\1)[^\\\n]){4,})\1/dgi, skip: isPlaceholder }
|
|
850
946
|
];
|
|
851
|
-
var PEM_BEGIN = /-----BEGIN [A-Z ]*PRIVATE KEY
|
|
852
|
-
var PEM_END = /-----END [A-Z ]*PRIVATE KEY
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
}
|
|
947
|
+
var PEM_BEGIN = /-----BEGIN [A-Z ]*PRIVATE KEY(?: BLOCK)?-----/;
|
|
948
|
+
var PEM_END = /-----END [A-Z ]*PRIVATE KEY(?: BLOCK)?-----/;
|
|
949
|
+
var PEM_BODY = /(?<=-----BEGIN [A-Z ]*PRIVATE KEY(?: BLOCK)?-----)(?:(?!-----END [A-Z ]*PRIVATE KEY(?: BLOCK)?-----)[\s\S])+/dg;
|
|
950
|
+
var PEM_BEGIN_ALL = /-----BEGIN [A-Z ]*PRIVATE KEY(?: BLOCK)?-----/g;
|
|
951
|
+
var URL_PARTS = /(?<![a-z0-9+.-])[a-z][a-z0-9+.-]{0,31}:\/\/([^\s/?#'"<>`]*)([^\s?#'"<>`]*)(?:\?([^\s#'"<>`]*))?/dgi;
|
|
952
|
+
var PATH_SEGMENT = /[^/]+/g;
|
|
953
|
+
var SECRET_PARAM = /^(?:token|[a-z]+_token|key|api[_-]?key|access[_-]?key|secret|[a-z]+_secret|password|passwd|pwd|sig|signature|auth|authorization)$/i;
|
|
954
|
+
var WEBHOOK_FORMATS = [
|
|
955
|
+
["Discord webhook", /(?:^|\.)discord(?:app)?\.com\/api\/(?:v\d+\/)?webhooks\/\d+\/([\w-]+)/di],
|
|
956
|
+
["Slack webhook", /^hooks\.slack\.com\/(?:services|workflows)\/(?:[\w-]+\/)*([\w-]+)/di],
|
|
957
|
+
["Uptime Kuma push URL", /\/api\/push\/([\w-]+)/di],
|
|
958
|
+
["healthchecks.io ping URL", /^hc-ping\.com\/([\w-]+)/di]
|
|
959
|
+
];
|
|
960
|
+
var OPAQUE_SEGMENT = /^(?:(?=[\w-]{20,}$)(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[\w-]+|(?=[A-Za-z\d]{32,}$)(?=.*[A-Za-z])(?=.*\d).+)$/;
|
|
856
961
|
function hint(value) {
|
|
857
962
|
const shown = Math.min(3, Math.floor(value.length / 4));
|
|
858
963
|
return `${value.slice(0, shown)}***(${value.length})`;
|
|
859
964
|
}
|
|
965
|
+
function forms(value) {
|
|
966
|
+
return [...new Set([value, encodeURIComponent(value), JSON.stringify(value).slice(1, -1)])];
|
|
967
|
+
}
|
|
860
968
|
function knownSecrets(pairs) {
|
|
861
969
|
const keys = new Map;
|
|
862
970
|
for (const [key, value] of pairs) {
|
|
863
|
-
if (value.length
|
|
864
|
-
|
|
971
|
+
if (value.length < MIN_KNOWN_LENGTH || PLAIN_VALUE.test(value))
|
|
972
|
+
continue;
|
|
973
|
+
for (const form of forms(value)) {
|
|
974
|
+
if (!keys.has(form))
|
|
975
|
+
keys.set(form, key);
|
|
976
|
+
}
|
|
865
977
|
}
|
|
866
978
|
if (keys.size === 0)
|
|
867
979
|
return { pattern: undefined, keys };
|
|
868
980
|
const alternatives = [...keys.keys()].sort((a, b) => b.length - a.length).map((value) => value.replace(ESCAPE2, "\\$&"));
|
|
869
981
|
return { pattern: new RegExp(`(?<!\\w)(?:${alternatives.join("|")})(?!\\w)`, "g"), keys };
|
|
870
982
|
}
|
|
983
|
+
function passwordSpans(text) {
|
|
984
|
+
const spans = [];
|
|
985
|
+
for (const url of text.matchAll(URL_PASSWORD)) {
|
|
986
|
+
const [start, end] = url.indices?.[1] ?? [];
|
|
987
|
+
const password = url[1] ?? "";
|
|
988
|
+
const slash = password.indexOf("/");
|
|
989
|
+
if (start !== undefined && end !== undefined && !(slash !== -1 && PORT.test(password.slice(0, slash))))
|
|
990
|
+
spans.push([start, end]);
|
|
991
|
+
}
|
|
992
|
+
return spans;
|
|
993
|
+
}
|
|
871
994
|
function urlPasswords(text) {
|
|
872
|
-
return
|
|
995
|
+
return passwordSpans(text).map(([start, end]) => text.slice(start, end));
|
|
996
|
+
}
|
|
997
|
+
function urlTokens(line) {
|
|
998
|
+
const found = [];
|
|
999
|
+
const add = (kind, start, value) => {
|
|
1000
|
+
found.push({ start, end: start + value.length, kind, mask: hint(value) });
|
|
1001
|
+
};
|
|
1002
|
+
for (const url of line.matchAll(URL_PARTS)) {
|
|
1003
|
+
const [authority, path = "", query] = [url[1] ?? "", url[2] ?? "", url[3]];
|
|
1004
|
+
const authorityStart = url.indices?.[1]?.[0];
|
|
1005
|
+
const pathStart = url.indices?.[2]?.[0];
|
|
1006
|
+
const queryStart = url.indices?.[3]?.[0];
|
|
1007
|
+
if (authorityStart === undefined || pathStart === undefined)
|
|
1008
|
+
continue;
|
|
1009
|
+
const hostAt = authority.lastIndexOf("@") + 1;
|
|
1010
|
+
const target = authority.slice(hostAt) + path;
|
|
1011
|
+
for (const [kind, pattern] of WEBHOOK_FORMATS) {
|
|
1012
|
+
const [start, end] = pattern.exec(target)?.indices?.[1] ?? [];
|
|
1013
|
+
if (start !== undefined && end !== undefined)
|
|
1014
|
+
add(kind, authorityStart + hostAt + start, target.slice(start, end));
|
|
1015
|
+
}
|
|
1016
|
+
for (const segment of path.matchAll(PATH_SEGMENT)) {
|
|
1017
|
+
if (OPAQUE_SEGMENT.test(segment[0]))
|
|
1018
|
+
add("URL token", pathStart + segment.index, segment[0]);
|
|
1019
|
+
}
|
|
1020
|
+
if (query === undefined || queryStart === undefined)
|
|
1021
|
+
continue;
|
|
1022
|
+
let offset = 0;
|
|
1023
|
+
for (const param of query.split("&")) {
|
|
1024
|
+
const equals = param.indexOf("=");
|
|
1025
|
+
if (equals > 0 && equals < param.length - 1 && SECRET_PARAM.test(param.slice(0, equals)))
|
|
1026
|
+
add("URL token", queryStart + offset + equals + 1, param.slice(equals + 1));
|
|
1027
|
+
offset += param.length + 1;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
return found.filter((match) => match.end > match.start);
|
|
873
1031
|
}
|
|
874
1032
|
function findSecrets(line, known) {
|
|
875
1033
|
const found = [];
|
|
876
|
-
for (const
|
|
877
|
-
|
|
1034
|
+
for (const [start, end] of passwordSpans(line))
|
|
1035
|
+
found.push({ start, end, kind: "URL password", mask: "***" });
|
|
1036
|
+
for (const body of line.matchAll(PEM_BODY)) {
|
|
1037
|
+
const [start, end] = body.indices?.[0] ?? [];
|
|
878
1038
|
if (start !== undefined && end !== undefined)
|
|
879
|
-
found.push({ start, end, kind: "
|
|
1039
|
+
found.push({ start, end, kind: "private key", mask: KEY_MASK });
|
|
880
1040
|
}
|
|
881
|
-
for (const
|
|
882
|
-
for (const token of line.matchAll(pattern))
|
|
883
|
-
|
|
1041
|
+
for (const { kind, pattern, skip } of TOKEN_FORMATS) {
|
|
1042
|
+
for (const token of line.matchAll(pattern)) {
|
|
1043
|
+
const [start, end] = token.indices?.at(-1) ?? [];
|
|
1044
|
+
if (start === undefined || end === undefined)
|
|
1045
|
+
continue;
|
|
1046
|
+
const value = line.slice(start, end);
|
|
1047
|
+
if (!skip?.(value))
|
|
1048
|
+
found.push({ start, end, kind, mask: hint(value) });
|
|
1049
|
+
}
|
|
884
1050
|
}
|
|
1051
|
+
found.push(...urlTokens(line));
|
|
885
1052
|
if (known?.pattern !== undefined) {
|
|
886
1053
|
for (const value of line.matchAll(known.pattern))
|
|
887
1054
|
found.push({ start: value.index, end: value.index + value[0].length, kind: `value of ${known.keys.get(value[0])}`, mask: hint(value[0]) });
|
|
@@ -894,6 +1061,13 @@ function findSecrets(line, known) {
|
|
|
894
1061
|
}
|
|
895
1062
|
return kept;
|
|
896
1063
|
}
|
|
1064
|
+
function openKeyBody(line) {
|
|
1065
|
+
const last = [...line.matchAll(PEM_BEGIN_ALL)].at(-1);
|
|
1066
|
+
if (last === undefined)
|
|
1067
|
+
return;
|
|
1068
|
+
const after = line.slice(last.index + last[0].length);
|
|
1069
|
+
return PEM_END.test(after) ? undefined : after.trim();
|
|
1070
|
+
}
|
|
897
1071
|
function maskLine(line, known) {
|
|
898
1072
|
let out = "";
|
|
899
1073
|
let at = 0;
|
|
@@ -903,18 +1077,38 @@ function maskLine(line, known) {
|
|
|
903
1077
|
}
|
|
904
1078
|
return out + line.slice(at);
|
|
905
1079
|
}
|
|
906
|
-
function maskValue(key, value) {
|
|
1080
|
+
function maskValue(key, value, known) {
|
|
907
1081
|
if (value === "")
|
|
908
1082
|
return value;
|
|
909
|
-
return
|
|
1083
|
+
return isSecretName(key) || PEM_BEGIN.test(value) ? hint(value) : maskLine(value, known);
|
|
910
1084
|
}
|
|
911
1085
|
|
|
912
1086
|
// src/files.ts
|
|
913
|
-
var ENV_FILE =
|
|
1087
|
+
var ENV_FILE = /^(?:\.env(?:\..+)?|.+\.env|\.envrc)$/;
|
|
914
1088
|
var TEMPLATE = /\.(?:example|sample|template|dist|defaults)$/;
|
|
1089
|
+
var KNOWN_DEPTH = 3;
|
|
1090
|
+
var SKIPPED = new Set(["node_modules", ".git", "vendor"]);
|
|
1091
|
+
var LOCK_WAIT_MS = 5000;
|
|
1092
|
+
var LOCK_STALE_MS = 30000;
|
|
1093
|
+
async function isFileEntry(entry, path) {
|
|
1094
|
+
if (entry.isFile())
|
|
1095
|
+
return true;
|
|
1096
|
+
if (!entry.isSymbolicLink())
|
|
1097
|
+
return false;
|
|
1098
|
+
try {
|
|
1099
|
+
return (await stat(path)).isFile();
|
|
1100
|
+
} catch {
|
|
1101
|
+
return false;
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
915
1104
|
async function envFiles(folder = process.cwd()) {
|
|
916
1105
|
const found = await readdir(folder, { withFileTypes: true });
|
|
917
|
-
|
|
1106
|
+
const names = [];
|
|
1107
|
+
for (const entry of found) {
|
|
1108
|
+
if (ENV_FILE.test(entry.name) && await isFileEntry(entry, join(folder, entry.name)))
|
|
1109
|
+
names.push(entry.name);
|
|
1110
|
+
}
|
|
1111
|
+
return names.sort();
|
|
918
1112
|
}
|
|
919
1113
|
async function readEnv(path) {
|
|
920
1114
|
const file = Bun.file(path);
|
|
@@ -922,33 +1116,125 @@ async function readEnv(path) {
|
|
|
922
1116
|
fail(`${path} does not exist`);
|
|
923
1117
|
return parseDotenv(await file.text());
|
|
924
1118
|
}
|
|
1119
|
+
async function writeAtomic(path, text) {
|
|
1120
|
+
let target = path;
|
|
1121
|
+
let mode = 384;
|
|
1122
|
+
let owner;
|
|
1123
|
+
try {
|
|
1124
|
+
target = await realpath(path);
|
|
1125
|
+
const current = await stat(target);
|
|
1126
|
+
mode = current.mode & 511;
|
|
1127
|
+
owner = current;
|
|
1128
|
+
} catch (error) {
|
|
1129
|
+
if (error.code !== "ENOENT")
|
|
1130
|
+
throw error;
|
|
1131
|
+
}
|
|
1132
|
+
const temp = join(dirname(target), `.${basename(target)}.${process.pid}.${crypto.randomUUID()}.tmp`);
|
|
1133
|
+
try {
|
|
1134
|
+
await writeFile(temp, text, { mode: 384, flag: "wx" });
|
|
1135
|
+
if (owner !== undefined)
|
|
1136
|
+
await keepOwner(temp, owner);
|
|
1137
|
+
await chmod(temp, mode);
|
|
1138
|
+
await rename(temp, target);
|
|
1139
|
+
} finally {
|
|
1140
|
+
await rm(temp, { force: true });
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
async function keepOwner(path, owner) {
|
|
1144
|
+
try {
|
|
1145
|
+
await chown(path, owner.uid, owner.gid);
|
|
1146
|
+
} catch (error) {
|
|
1147
|
+
if (error.code !== "EPERM")
|
|
1148
|
+
throw error;
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
925
1151
|
async function writeEnv(path, file) {
|
|
926
|
-
await
|
|
1152
|
+
await writeAtomic(path, serializeDotenv(file));
|
|
927
1153
|
}
|
|
928
|
-
async function
|
|
929
|
-
const
|
|
930
|
-
|
|
931
|
-
for (const
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
1154
|
+
async function withLocks(paths, fn) {
|
|
1155
|
+
const held = [];
|
|
1156
|
+
try {
|
|
1157
|
+
for (const path of [...new Set(paths)].sort())
|
|
1158
|
+
held.push(await acquire(path));
|
|
1159
|
+
return await fn();
|
|
1160
|
+
} finally {
|
|
1161
|
+
for (const lock of held)
|
|
1162
|
+
await rm(lock, { force: true });
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
async function acquire(path) {
|
|
1166
|
+
let real = path;
|
|
1167
|
+
try {
|
|
1168
|
+
real = await realpath(path);
|
|
1169
|
+
} catch {}
|
|
1170
|
+
const lock = join(dirname(real), `.${basename(real)}.lock`);
|
|
1171
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
1172
|
+
while (true) {
|
|
1173
|
+
try {
|
|
1174
|
+
await writeFile(lock, String(process.pid), { flag: "wx", mode: 384 });
|
|
1175
|
+
return lock;
|
|
1176
|
+
} catch (error) {
|
|
1177
|
+
if (error.code !== "EEXIST")
|
|
1178
|
+
throw error;
|
|
936
1179
|
}
|
|
1180
|
+
try {
|
|
1181
|
+
if (Date.now() - (await stat(lock)).mtimeMs > LOCK_STALE_MS)
|
|
1182
|
+
await rm(lock, { force: true });
|
|
1183
|
+
} catch {}
|
|
1184
|
+
if (Date.now() > deadline)
|
|
1185
|
+
fail(`${path} is locked by another ev run (${lock})`, 1);
|
|
1186
|
+
await Bun.sleep(25);
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
function secretPairs(file) {
|
|
1190
|
+
const pairs = [];
|
|
1191
|
+
for (const [key, value] of entries(file)) {
|
|
1192
|
+
if (isSecretName(key))
|
|
1193
|
+
pairs.push([key, value]);
|
|
1194
|
+
else
|
|
1195
|
+
pairs.push(...urlPasswords(value).map((password) => [key, password]));
|
|
1196
|
+
}
|
|
1197
|
+
return pairs;
|
|
1198
|
+
}
|
|
1199
|
+
async function envFilesBelow(folder, depth = 0) {
|
|
1200
|
+
const found = [];
|
|
1201
|
+
let children;
|
|
1202
|
+
try {
|
|
1203
|
+
children = await readdir(folder, { withFileTypes: true });
|
|
1204
|
+
} catch {
|
|
1205
|
+
return found;
|
|
1206
|
+
}
|
|
1207
|
+
for (const entry of children) {
|
|
1208
|
+
const path = join(folder, entry.name);
|
|
1209
|
+
if (entry.isDirectory() && depth < KNOWN_DEPTH && !SKIPPED.has(entry.name))
|
|
1210
|
+
found.push(...await envFilesBelow(path, depth + 1));
|
|
1211
|
+
else if (ENV_FILE.test(entry.name) && await isFileEntry(entry, path))
|
|
1212
|
+
found.push(path);
|
|
1213
|
+
}
|
|
1214
|
+
return found;
|
|
1215
|
+
}
|
|
1216
|
+
async function loadKnown(folder = process.cwd(), extra = []) {
|
|
1217
|
+
const pairs = extra.flatMap(secretPairs);
|
|
1218
|
+
for (const path of await envFilesBelow(folder)) {
|
|
1219
|
+
try {
|
|
1220
|
+
pairs.push(...secretPairs(parseDotenv(await Bun.file(path).text())));
|
|
1221
|
+
} catch {}
|
|
937
1222
|
}
|
|
938
1223
|
return knownSecrets(pairs);
|
|
939
1224
|
}
|
|
940
1225
|
|
|
941
1226
|
// src/audit.ts
|
|
942
1227
|
var MAX_DEPTH = 5;
|
|
943
|
-
var
|
|
1228
|
+
var SKIPPED2 = new Set(["node_modules", ".git", "vendor"]);
|
|
944
1229
|
var COMPOSE_FILE = /^(?:docker-)?compose[\w.-]*\.ya?ml$/;
|
|
945
1230
|
var ENTRY = /^\s*(?:-\s*)?["']?([A-Z0-9_]+)["']?\s*[:=]\s*["']?([^"'\s#]+)/;
|
|
946
|
-
var
|
|
1231
|
+
var SETTING_SUFFIX = /_(?:TTL|FILE|PATH|DIR|NAME|TIMEOUT|EXPIRY|EXPIRES|LIFETIME|LENGTH|ENABLED|ALGORITHM|HEADER|PREFIX|COUNT|MAX|MIN)$/i;
|
|
1232
|
+
var ACL_READER = /^(?:user|group):[^:]+:r/;
|
|
947
1233
|
var FLAG = /^(?:[01]|yes|no|true|false)$/i;
|
|
948
1234
|
function isInlineSecret(name, value) {
|
|
949
1235
|
if (value === "" || value.startsWith("$") || value.startsWith("/run/secrets/") || FLAG.test(value))
|
|
950
1236
|
return false;
|
|
951
|
-
return
|
|
1237
|
+
return isSecretName(name) && !SETTING_SUFFIX.test(name) || urlPasswords(value).some((password) => !password.startsWith("$"));
|
|
952
1238
|
}
|
|
953
1239
|
function inlineSecrets(compose) {
|
|
954
1240
|
const names = compose.split(`
|
|
@@ -959,16 +1245,38 @@ function inlineSecrets(compose) {
|
|
|
959
1245
|
return [...new Set(names)];
|
|
960
1246
|
}
|
|
961
1247
|
async function openToOthers(path) {
|
|
962
|
-
if (((await
|
|
1248
|
+
if (((await stat2(path)).mode & 4) === 0)
|
|
963
1249
|
return false;
|
|
964
|
-
for (let dir =
|
|
965
|
-
if (((await
|
|
1250
|
+
for (let dir = dirname2(path);dir !== "/"; dir = dirname2(dir)) {
|
|
1251
|
+
if (((await stat2(dir)).mode & 1) === 0)
|
|
966
1252
|
return false;
|
|
967
1253
|
}
|
|
968
1254
|
return true;
|
|
969
1255
|
}
|
|
1256
|
+
async function sharedWith(path) {
|
|
1257
|
+
const info = await stat2(path);
|
|
1258
|
+
const shared = (info.mode & 32) !== 0 && info.gid !== info.uid && info.gid !== process.getgid?.();
|
|
1259
|
+
if (!shared && (Bun.which("getfacl") === null || !await hasAclReader(path)))
|
|
1260
|
+
return false;
|
|
1261
|
+
for (let dir = dirname2(path);dir !== "/"; dir = dirname2(dir)) {
|
|
1262
|
+
if (((await stat2(dir)).mode & 9) === 0)
|
|
1263
|
+
return false;
|
|
1264
|
+
}
|
|
1265
|
+
return true;
|
|
1266
|
+
}
|
|
1267
|
+
async function hasAclReader(path) {
|
|
1268
|
+
const acl = await $`getfacl --absolute-names --omit-header -- ${path}`.quiet().nothrow();
|
|
1269
|
+
return acl.exitCode === 0 && acl.stdout.toString().split(`
|
|
1270
|
+
`).some((line) => ACL_READER.test(line));
|
|
1271
|
+
}
|
|
970
1272
|
async function tracked(path) {
|
|
971
|
-
|
|
1273
|
+
const run = await $`git ls-files --error-unmatch -- ${path}`.cwd(dirname2(path)).quiet().nothrow();
|
|
1274
|
+
const error = run.stderr.toString();
|
|
1275
|
+
if (run.exitCode === 0)
|
|
1276
|
+
return true;
|
|
1277
|
+
if (run.exitCode === 1 || error.includes("not a git repository"))
|
|
1278
|
+
return false;
|
|
1279
|
+
throw new Error(`git could not tell whether ${path} is tracked: ${error.trim() || `exit ${run.exitCode}`}`);
|
|
972
1280
|
}
|
|
973
1281
|
async function* candidates2(root, unread, depth = 0) {
|
|
974
1282
|
let found;
|
|
@@ -979,10 +1287,10 @@ async function* candidates2(root, unread, depth = 0) {
|
|
|
979
1287
|
return;
|
|
980
1288
|
}
|
|
981
1289
|
for (const entry of found) {
|
|
982
|
-
const path =
|
|
983
|
-
if (entry.isDirectory() && depth < MAX_DEPTH && !
|
|
1290
|
+
const path = join2(root, entry.name);
|
|
1291
|
+
if (entry.isDirectory() && depth < MAX_DEPTH && !SKIPPED2.has(entry.name))
|
|
984
1292
|
yield* candidates2(path, unread, depth + 1);
|
|
985
|
-
else if (
|
|
1293
|
+
else if (!TEMPLATE.test(entry.name) && (ENV_FILE.test(entry.name) || COMPOSE_FILE.test(entry.name)) && await isFileEntry(entry, path))
|
|
986
1294
|
yield path;
|
|
987
1295
|
}
|
|
988
1296
|
}
|
|
@@ -991,25 +1299,27 @@ async function audit(roots) {
|
|
|
991
1299
|
const unread = [];
|
|
992
1300
|
for (const root of roots) {
|
|
993
1301
|
for await (const path of candidates2(root, unread)) {
|
|
994
|
-
const
|
|
1302
|
+
const others = await openToOthers(path);
|
|
1303
|
+
const readable = others || await sharedWith(path);
|
|
1304
|
+
const group = readable && !others ? true : undefined;
|
|
995
1305
|
const name = path.slice(path.lastIndexOf("/") + 1);
|
|
996
1306
|
if (ENV_FILE.test(name)) {
|
|
997
1307
|
const isTracked = await tracked(path);
|
|
998
1308
|
if (readable || isTracked)
|
|
999
|
-
exposed.push({ path, readable, tracked: isTracked });
|
|
1309
|
+
exposed.push({ path, readable, ...group && { group }, tracked: isTracked });
|
|
1000
1310
|
continue;
|
|
1001
1311
|
}
|
|
1002
1312
|
const secrets = readable ? inlineSecrets(await Bun.file(path).text()) : [];
|
|
1003
1313
|
if (secrets.length > 0)
|
|
1004
|
-
exposed.push({ path, readable, tracked: false, secrets });
|
|
1314
|
+
exposed.push({ path, readable, ...group && { group }, tracked: false, secrets });
|
|
1005
1315
|
}
|
|
1006
1316
|
}
|
|
1007
1317
|
return { exposed: exposed.sort((a, b) => a.path.localeCompare(b.path)), unread };
|
|
1008
1318
|
}
|
|
1009
1319
|
|
|
1010
1320
|
// src/fix.ts
|
|
1011
|
-
import { chmod, chown, rm, stat as
|
|
1012
|
-
import { dirname as
|
|
1321
|
+
import { chmod as chmod2, chown as chown2, lstat, rm as rm2, stat as stat3 } from "fs/promises";
|
|
1322
|
+
import { dirname as dirname3, join as join3 } from "path";
|
|
1013
1323
|
var MAP_ENTRY = /^(\s*)(["']?)([A-Z0-9_]+)\2(\s*:)(.*)$/;
|
|
1014
1324
|
var LIST_ENTRY = /^(\s*-[ \t]*)(["']?)([A-Z0-9_]+)=(.*)$/;
|
|
1015
1325
|
var TRAILING = /^\s*(?:#.*)?$/;
|
|
@@ -1070,8 +1380,17 @@ function envLine(key, value) {
|
|
|
1070
1380
|
return value.includes("'") || value.includes(`
|
|
1071
1381
|
`) ? undefined : `${key}='${value}'`;
|
|
1072
1382
|
}
|
|
1383
|
+
async function isLink(path) {
|
|
1384
|
+
try {
|
|
1385
|
+
return (await lstat(path)).isSymbolicLink();
|
|
1386
|
+
} catch (error) {
|
|
1387
|
+
if (error.code === "ENOENT")
|
|
1388
|
+
return false;
|
|
1389
|
+
throw error;
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1073
1392
|
async function resolved(path) {
|
|
1074
|
-
const run = Bun.spawn(["docker", "compose", "-f", path, "config", "--format", "json"], { cwd:
|
|
1393
|
+
const run = Bun.spawn(["docker", "compose", "-f", path, "config", "--format", "json"], { cwd: dirname3(path), stdout: "pipe", stderr: "ignore", stdin: "ignore" });
|
|
1075
1394
|
const [out, code] = await Promise.all([new Response(run.stdout).text(), run.exited]);
|
|
1076
1395
|
return code === 0 ? out : undefined;
|
|
1077
1396
|
}
|
|
@@ -1080,9 +1399,9 @@ function gainedBy(before, after, key) {
|
|
|
1080
1399
|
const had = new Set(services(before).filter(([, service]) => service.environment?.[key] !== undefined).map(([name]) => name));
|
|
1081
1400
|
return services(after).filter(([name, service]) => service.environment?.[key] !== undefined && !had.has(name)).map(([name]) => name);
|
|
1082
1401
|
}
|
|
1083
|
-
async function shutOthersOut(path) {
|
|
1402
|
+
async function shutOthersOut(path, group = false) {
|
|
1084
1403
|
try {
|
|
1085
|
-
await
|
|
1404
|
+
await chmod2(path, (await stat3(path)).mode & (group ? 448 : 504));
|
|
1086
1405
|
return true;
|
|
1087
1406
|
} catch (error) {
|
|
1088
1407
|
if (error.code === "EPERM")
|
|
@@ -1090,13 +1409,14 @@ async function shutOthersOut(path) {
|
|
|
1090
1409
|
throw error;
|
|
1091
1410
|
}
|
|
1092
1411
|
}
|
|
1093
|
-
async function fixCompose(path) {
|
|
1094
|
-
const env =
|
|
1412
|
+
async function fixCompose(path, group = false) {
|
|
1413
|
+
const env = join3(dirname3(path), ".env");
|
|
1095
1414
|
const compose = await Bun.file(path).text();
|
|
1096
1415
|
const envBefore = await Bun.file(env).exists() ? await Bun.file(env).text() : undefined;
|
|
1097
1416
|
const dotenv = parseDotenv(envBefore ?? "");
|
|
1098
1417
|
const known = entries(dotenv);
|
|
1099
1418
|
const fix = { path, env, moved: [], left: [], open: [] };
|
|
1419
|
+
const linked = await isLink(env);
|
|
1100
1420
|
const lines = compose.split(`
|
|
1101
1421
|
`).map((line) => {
|
|
1102
1422
|
const entry = composeEntry(line);
|
|
@@ -1107,6 +1427,10 @@ async function fixCompose(path) {
|
|
|
1107
1427
|
fix.left.push({ key, why: entry.unmovable });
|
|
1108
1428
|
return line;
|
|
1109
1429
|
}
|
|
1430
|
+
if (linked) {
|
|
1431
|
+
fix.left.push({ key, why: "its .env is a symbolic link" });
|
|
1432
|
+
return line;
|
|
1433
|
+
}
|
|
1110
1434
|
if (entry.value.replaceAll("$$", "").includes("$")) {
|
|
1111
1435
|
fix.left.push({ key, why: "its value reads another variable" });
|
|
1112
1436
|
return line;
|
|
@@ -1133,7 +1457,7 @@ async function fixCompose(path) {
|
|
|
1133
1457
|
await move(fix, compose, lines.join(`
|
|
1134
1458
|
`), envBefore, serializeDotenv(dotenv));
|
|
1135
1459
|
for (const file of [path, env]) {
|
|
1136
|
-
if (await Bun.file(file).exists() && !await shutOthersOut(file))
|
|
1460
|
+
if (!(file === env && linked) && await Bun.file(file).exists() && !await shutOthersOut(file, group))
|
|
1137
1461
|
fix.open.push(file);
|
|
1138
1462
|
}
|
|
1139
1463
|
return fix;
|
|
@@ -1143,15 +1467,15 @@ async function move(fix, compose, composeAfter, envBefore, envAfter) {
|
|
|
1143
1467
|
const docker = Bun.which("docker") !== null;
|
|
1144
1468
|
const before = docker ? await resolved(path) : undefined;
|
|
1145
1469
|
if (envBefore === undefined) {
|
|
1146
|
-
await
|
|
1147
|
-
const { uid, gid } = await
|
|
1470
|
+
await writeAtomic(env, envAfter);
|
|
1471
|
+
const { uid, gid } = await stat3(path);
|
|
1148
1472
|
try {
|
|
1149
|
-
await
|
|
1473
|
+
await chown2(env, uid, gid);
|
|
1150
1474
|
} catch {}
|
|
1151
1475
|
} else {
|
|
1152
|
-
await
|
|
1476
|
+
await writeAtomic(env, envAfter);
|
|
1153
1477
|
}
|
|
1154
|
-
await
|
|
1478
|
+
await writeAtomic(path, composeAfter);
|
|
1155
1479
|
if (!docker) {
|
|
1156
1480
|
fix.check = "no docker";
|
|
1157
1481
|
return;
|
|
@@ -1165,11 +1489,11 @@ async function move(fix, compose, composeAfter, envBefore, envAfter) {
|
|
|
1165
1489
|
fix.check = "same";
|
|
1166
1490
|
return;
|
|
1167
1491
|
}
|
|
1168
|
-
await
|
|
1492
|
+
await writeAtomic(path, compose);
|
|
1169
1493
|
if (envBefore === undefined)
|
|
1170
|
-
await
|
|
1494
|
+
await rm2(env);
|
|
1171
1495
|
else
|
|
1172
|
-
await
|
|
1496
|
+
await writeAtomic(env, envBefore);
|
|
1173
1497
|
fix.check = "rolled back";
|
|
1174
1498
|
fix.left.push(...fix.moved.map((key) => {
|
|
1175
1499
|
const gained = after === undefined ? [] : gainedBy(before, after, key);
|
|
@@ -1199,7 +1523,7 @@ var CHECKS = {
|
|
|
1199
1523
|
};
|
|
1200
1524
|
async function isFolder(path) {
|
|
1201
1525
|
try {
|
|
1202
|
-
return (await
|
|
1526
|
+
return (await stat4(path)).isDirectory();
|
|
1203
1527
|
} catch {
|
|
1204
1528
|
return false;
|
|
1205
1529
|
}
|
|
@@ -1213,9 +1537,9 @@ function shown(path) {
|
|
|
1213
1537
|
async function fixAll(exposed, json) {
|
|
1214
1538
|
const fixed = [];
|
|
1215
1539
|
let left = false;
|
|
1216
|
-
for (const { path, readable, tracked, secrets } of exposed) {
|
|
1217
|
-
const fix = secrets === undefined ? undefined : await fixCompose(path);
|
|
1218
|
-
const open = fix?.open ?? (readable && !await shutOthersOut(path) ? [path] : []);
|
|
1540
|
+
for (const { path, readable, group, tracked, secrets } of exposed) {
|
|
1541
|
+
const fix = secrets === undefined ? undefined : await fixCompose(path, group === true);
|
|
1542
|
+
const open = fix?.open ?? (readable && !await shutOthersOut(path, group === true) ? [path] : []);
|
|
1219
1543
|
if (fix !== undefined)
|
|
1220
1544
|
fixed.push(fix);
|
|
1221
1545
|
left ||= tracked || open.length > 0 || (fix?.left.length ?? 0) > 0;
|
|
@@ -1225,7 +1549,7 @@ async function fixAll(exposed, json) {
|
|
|
1225
1549
|
...fix === undefined || fix.moved.length === 0 ? [] : [`moved ${fix.moved.join(", ")} into ${shown(fix.env)}`],
|
|
1226
1550
|
...fix?.left.map(({ key, why }) => `left ${key} in place: ${why}`) ?? [],
|
|
1227
1551
|
...fix?.check === undefined ? [] : [CHECKS[fix.check]],
|
|
1228
|
-
...open.map((file) => `another account owns ${file === path ? "it" : shown(file)}, so it stays readable: sudo chmod
|
|
1552
|
+
...open.map((file) => `another account owns ${file === path ? "it" : shown(file)}, so it stays readable: sudo chmod go-rwx ${quoted2(shown(file))}`),
|
|
1229
1553
|
...readable && !open.includes(path) ? ["now unreadable by other accounts"] : [],
|
|
1230
1554
|
...tracked ? [`tracked by git: git rm --cached ${quoted2(shown(path))}, then ignore it`] : []
|
|
1231
1555
|
];
|
|
@@ -1265,11 +1589,11 @@ async function audit2(args) {
|
|
|
1265
1589
|
return 0;
|
|
1266
1590
|
}
|
|
1267
1591
|
const uid = process7.getuid?.();
|
|
1268
|
-
for (const { path, readable, tracked, secrets } of exposed) {
|
|
1269
|
-
const sudo = uid !== undefined && uid !== 0 && (await
|
|
1592
|
+
for (const { path, readable, group, tracked, secrets } of exposed) {
|
|
1593
|
+
const sudo = uid !== undefined && uid !== 0 && (await stat4(path)).uid !== uid ? "sudo " : "";
|
|
1270
1594
|
const problems = [
|
|
1271
1595
|
...secrets === undefined ? [] : [`writes out ${secrets.join(", ")}: ev audit --fix moves the values into a .env beside it`],
|
|
1272
|
-
...readable ? [`readable by any account: ${sudo}chmod o-rwx ${quoted2(shown(path))}`] : [],
|
|
1596
|
+
...readable ? [group === true ? `readable by its group or an ACL: ${sudo}chmod go-rwx ${quoted2(shown(path))}` : `readable by any account: ${sudo}chmod o-rwx ${quoted2(shown(path))}`] : [],
|
|
1273
1597
|
...tracked ? [`tracked by git: git rm --cached ${quoted2(shown(path))}, then ignore it`] : []
|
|
1274
1598
|
];
|
|
1275
1599
|
console.log(`${shown(path)}
|
|
@@ -1328,6 +1652,7 @@ async function missing(args) {
|
|
|
1328
1652
|
// src/commands/edit.ts
|
|
1329
1653
|
var DEFAULT_FILE = ".env";
|
|
1330
1654
|
var TRAILING_NEWLINE = /\r?\n$/;
|
|
1655
|
+
var KEY_NAME = /^[A-Z_][\w.-]*$/i;
|
|
1331
1656
|
var setUsage = `ev set KEY [file]
|
|
1332
1657
|
|
|
1333
1658
|
Sets KEY in the file (.env by default, created when missing), replacing its line in place or appending one. The value is read from stdin, or from -F <file>, so it never appears in a command line; --empty sets it to an empty value.
|
|
@@ -1343,12 +1668,21 @@ var mvUsage = `ev mv KEY --from <file> --to <file>
|
|
|
1343
1668
|
Moves KEY's line from one file to another, as written. Refuses to replace a key the target already sets unless --force.
|
|
1344
1669
|
|
|
1345
1670
|
Exits 0 when it moved the key, 1 when the source lacks it or the target already sets it, 2 on a usage error.`;
|
|
1671
|
+
var cpUsage = `ev cp KEY --from <file> --to <file> [--as NEWKEY]
|
|
1672
|
+
|
|
1673
|
+
Copies KEY's value from one file to another without printing it, replacing the key's line in the target in place or appending one, and prints the masked result as ev set does. --as stores the copy under another key.
|
|
1674
|
+
|
|
1675
|
+
Exits 0 when it copied the key, 1 when the source lacks it, 2 on a usage error.`;
|
|
1346
1676
|
async function readValue(from) {
|
|
1347
1677
|
const input = await readInput(from ?? "-", { noStdin: "pipe the value on stdin or give -F <file>, it is never taken from the command line" });
|
|
1348
1678
|
if (input === "")
|
|
1349
1679
|
fail("the value is empty, nothing was written: --empty sets an empty value");
|
|
1350
1680
|
return input.replace(TRAILING_NEWLINE, "");
|
|
1351
1681
|
}
|
|
1682
|
+
function checkKey(key) {
|
|
1683
|
+
if (!KEY_NAME.test(key))
|
|
1684
|
+
badUsage(`${JSON.stringify(key)} is not a valid key: letters, digits, _, . and - only, not starting with a digit`);
|
|
1685
|
+
}
|
|
1352
1686
|
async function readOrEmpty(path) {
|
|
1353
1687
|
return await Bun.file(path).exists() ? readEnv(path) : { lines: [] };
|
|
1354
1688
|
}
|
|
@@ -1364,11 +1698,15 @@ async function set(args) {
|
|
|
1364
1698
|
${setUsage}`);
|
|
1365
1699
|
if (parsed.values.empty === true && parsed.values.file !== undefined)
|
|
1366
1700
|
fail("--empty and -F exclude each other");
|
|
1701
|
+
checkKey(key);
|
|
1367
1702
|
const value = parsed.values.empty === true ? "" : await readValue(parsed.values.file);
|
|
1368
|
-
const file = await
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1703
|
+
const file = await withLocks([target], async () => {
|
|
1704
|
+
const current = await readOrEmpty(target);
|
|
1705
|
+
setLine(current, key, value);
|
|
1706
|
+
await writeEnv(target, current);
|
|
1707
|
+
return current;
|
|
1708
|
+
});
|
|
1709
|
+
console.log(`${target}: ${key}=${maskValue(key, value, await loadKnown(process.cwd(), [file])).replaceAll(`
|
|
1372
1710
|
`, "\\n")}`);
|
|
1373
1711
|
return 0;
|
|
1374
1712
|
}
|
|
@@ -1379,13 +1717,34 @@ async function unset(args) {
|
|
|
1379
1717
|
fail(`missing KEY
|
|
1380
1718
|
|
|
1381
1719
|
${unsetUsage}`);
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1720
|
+
await withLocks([target], async () => {
|
|
1721
|
+
const file = await readEnv(target);
|
|
1722
|
+
if (removeKey(file, key) === 0)
|
|
1723
|
+
fail(`${key} is not set in ${target}`, 1);
|
|
1724
|
+
await writeEnv(target, file);
|
|
1725
|
+
});
|
|
1386
1726
|
console.log(`${target}: removed ${key}`);
|
|
1387
1727
|
return 0;
|
|
1388
1728
|
}
|
|
1729
|
+
async function transfer(key, from, to, newKey, replace) {
|
|
1730
|
+
checkKey(key);
|
|
1731
|
+
if (newKey !== undefined)
|
|
1732
|
+
checkKey(newKey);
|
|
1733
|
+
if (from === to && newKey === undefined)
|
|
1734
|
+
fail("--from and --to are the same file");
|
|
1735
|
+
const source = await readEnv(from);
|
|
1736
|
+
const line = lastLine(source, key);
|
|
1737
|
+
if (line === undefined)
|
|
1738
|
+
fail(`${key} is not set in ${from}`, 1);
|
|
1739
|
+
const target = from === to ? source : await readOrEmpty(to);
|
|
1740
|
+
const name = newKey ?? key;
|
|
1741
|
+
if (!replace && entries(target).has(name))
|
|
1742
|
+
fail(`${to} already sets ${name}, --force replaces it`, 1);
|
|
1743
|
+
const value = line.value ?? "";
|
|
1744
|
+
setLine(target, name, value, newKey === undefined && !entries(target).has(name) ? line.raw : undefined);
|
|
1745
|
+
await writeEnv(to, target);
|
|
1746
|
+
return { source, target, value, name };
|
|
1747
|
+
}
|
|
1389
1748
|
async function mv(args) {
|
|
1390
1749
|
const parsed = parse(args, mvUsage, {
|
|
1391
1750
|
from: { type: "string", description: "the file to take KEY from" },
|
|
@@ -1398,28 +1757,281 @@ async function mv(args) {
|
|
|
1398
1757
|
fail(`needs KEY, --from and --to
|
|
1399
1758
|
|
|
1400
1759
|
${mvUsage}`);
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
fail(`${key} is not set in ${from}`, 1);
|
|
1407
|
-
const target = await readOrEmpty(to);
|
|
1408
|
-
if (entries(target).has(key) && force !== true)
|
|
1409
|
-
fail(`${to} already sets ${key}, --force replaces it`, 1);
|
|
1410
|
-
setLine(target, key, line.value ?? "", line.raw);
|
|
1411
|
-
await writeEnv(to, target);
|
|
1412
|
-
removeKey(source, key);
|
|
1413
|
-
await writeEnv(from, source);
|
|
1760
|
+
await withLocks([from, to], async () => {
|
|
1761
|
+
const { source } = await transfer(key, from, to, undefined, force === true);
|
|
1762
|
+
removeKey(source, key);
|
|
1763
|
+
await writeEnv(from, source);
|
|
1764
|
+
});
|
|
1414
1765
|
console.log(`${key}: ${from} -> ${to}`);
|
|
1415
1766
|
return 0;
|
|
1416
1767
|
}
|
|
1768
|
+
async function cp(args) {
|
|
1769
|
+
const parsed = parse(args, cpUsage, {
|
|
1770
|
+
from: { type: "string", description: "the file to take KEY from" },
|
|
1771
|
+
to: { type: "string", description: "the file to put the copy in, created when missing" },
|
|
1772
|
+
as: { type: "string", description: "store the copy under this key instead" }
|
|
1773
|
+
});
|
|
1774
|
+
const [key] = parsed.positionals;
|
|
1775
|
+
const { from, to, as } = parsed.values;
|
|
1776
|
+
if (key === undefined || from === undefined || to === undefined)
|
|
1777
|
+
fail(`needs KEY, --from and --to
|
|
1778
|
+
|
|
1779
|
+
${cpUsage}`);
|
|
1780
|
+
const { source, target, value, name } = await withLocks([from, to], async () => transfer(key, from, to, as, true));
|
|
1781
|
+
const known = await loadKnown(process.cwd(), [source, target]);
|
|
1782
|
+
console.log(`${to}: ${name}=${maskValue(name, value, known).replaceAll(`
|
|
1783
|
+
`, "\\n")}`);
|
|
1784
|
+
return 0;
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
// src/commands/pins.ts
|
|
1788
|
+
import { readdir as readdir4, stat as stat5 } from "fs/promises";
|
|
1789
|
+
import { join as join5, resolve as resolve2 } from "path";
|
|
1790
|
+
|
|
1791
|
+
// src/pins.ts
|
|
1792
|
+
import { readdir as readdir3, readFile } from "fs/promises";
|
|
1793
|
+
import { join as join4, relative as relative2 } from "path";
|
|
1794
|
+
var OPTIONAL2 = /^\s*([A-Z][A-Z0-9_]*)\s*:\s*s\.Optional\(/gm;
|
|
1795
|
+
var REACHING = /(?:URL|URI|TOKEN|KEY|SECRET|PASSWORD|DSN|_HOST)(?:_FILE)?$/;
|
|
1796
|
+
var SELF = /(?:^|_)(?:PUBLIC|SITE|BASE|APP)_URL$/;
|
|
1797
|
+
var RENDERS = /\b(?:testSite|proxyKeyTests)\s*\(/;
|
|
1798
|
+
var NOT_NEEDED = /\/\/\s*ev pins:\s*none reached\b/;
|
|
1799
|
+
var OVERRIDE = /\boverrideEnv\s*\(/g;
|
|
1800
|
+
var KEY = /(?:^|[{,])\s*([A-Z_]\w*)\s*:/gi;
|
|
1801
|
+
var TEST_FILE = /\.test\.[cm]?[jt]sx?$/;
|
|
1802
|
+
var ENV_TEST = /^\.env\.test(?:\.local)?$/;
|
|
1803
|
+
var TEST_ROOTS = ["tests", "test", "__tests__", "e2e", "src"];
|
|
1804
|
+
var SKIPPED3 = new Set(["node_modules", ".git", ".kay", "dist", "vendor"]);
|
|
1805
|
+
var SCHEMA_CANDIDATES = ["src/env.ts", "env.ts", "src/env/index.ts"];
|
|
1806
|
+
var OPENERS = new Set(["(", "[", "{"]);
|
|
1807
|
+
var CLOSERS = new Set([")", "]", "}"]);
|
|
1808
|
+
var QUOTES = new Set(["'", '"', "`"]);
|
|
1809
|
+
async function exists(path) {
|
|
1810
|
+
return Bun.file(path).exists();
|
|
1811
|
+
}
|
|
1812
|
+
async function walk(folder, keep) {
|
|
1813
|
+
const found = [];
|
|
1814
|
+
let entries = [];
|
|
1815
|
+
try {
|
|
1816
|
+
entries = await readdir3(folder, { withFileTypes: true });
|
|
1817
|
+
} catch {
|
|
1818
|
+
return found;
|
|
1819
|
+
}
|
|
1820
|
+
for (const entry of entries) {
|
|
1821
|
+
if (SKIPPED3.has(entry.name))
|
|
1822
|
+
continue;
|
|
1823
|
+
const path = join4(folder, entry.name);
|
|
1824
|
+
if (entry.isDirectory())
|
|
1825
|
+
found.push(...await walk(path, keep));
|
|
1826
|
+
else if (entry.isFile() && keep(entry.name))
|
|
1827
|
+
found.push(path);
|
|
1828
|
+
}
|
|
1829
|
+
return found.sort();
|
|
1830
|
+
}
|
|
1831
|
+
async function testFiles(folder) {
|
|
1832
|
+
const found = [];
|
|
1833
|
+
for (const root of TEST_ROOTS) {
|
|
1834
|
+
found.push(...await walk(join4(folder, root), (name) => TEST_FILE.test(name)));
|
|
1835
|
+
}
|
|
1836
|
+
return found;
|
|
1837
|
+
}
|
|
1838
|
+
function balanced(text, open) {
|
|
1839
|
+
let depth = 0;
|
|
1840
|
+
let quote = "";
|
|
1841
|
+
for (let index = open;index < text.length; index++) {
|
|
1842
|
+
const char = text[index] ?? "";
|
|
1843
|
+
if (quote !== "") {
|
|
1844
|
+
if (char === "\\")
|
|
1845
|
+
index++;
|
|
1846
|
+
else if (char === quote)
|
|
1847
|
+
quote = "";
|
|
1848
|
+
} else if (QUOTES.has(char)) {
|
|
1849
|
+
quote = char;
|
|
1850
|
+
} else if (OPENERS.has(char)) {
|
|
1851
|
+
depth++;
|
|
1852
|
+
} else if (CLOSERS.has(char) && --depth === 0) {
|
|
1853
|
+
return text.slice(open + 1, index);
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
return text.slice(open + 1);
|
|
1857
|
+
}
|
|
1858
|
+
function topLevel(text) {
|
|
1859
|
+
let depth = 0;
|
|
1860
|
+
let quote = "";
|
|
1861
|
+
let out = "";
|
|
1862
|
+
for (let index = 0;index < text.length; index++) {
|
|
1863
|
+
const char = text[index] ?? "";
|
|
1864
|
+
if (quote !== "") {
|
|
1865
|
+
if (char === "\\")
|
|
1866
|
+
index++;
|
|
1867
|
+
else if (char === quote)
|
|
1868
|
+
quote = "";
|
|
1869
|
+
} else if (QUOTES.has(char)) {
|
|
1870
|
+
quote = char;
|
|
1871
|
+
} else if (OPENERS.has(char)) {
|
|
1872
|
+
out += depth === 0 ? char : "";
|
|
1873
|
+
depth++;
|
|
1874
|
+
} else if (CLOSERS.has(char)) {
|
|
1875
|
+
depth--;
|
|
1876
|
+
out += depth === 0 ? char : "";
|
|
1877
|
+
} else if (depth <= 1) {
|
|
1878
|
+
out += char;
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
return out;
|
|
1882
|
+
}
|
|
1883
|
+
function overriddenNames(source) {
|
|
1884
|
+
const names = new Set;
|
|
1885
|
+
for (const match of source.matchAll(OVERRIDE)) {
|
|
1886
|
+
const args = topLevel(balanced(source, match.index + match[0].length - 1));
|
|
1887
|
+
const object = args.slice(args.indexOf(",") + 1);
|
|
1888
|
+
for (const key of object.matchAll(KEY))
|
|
1889
|
+
names.add(key[1] ?? "");
|
|
1890
|
+
}
|
|
1891
|
+
return names;
|
|
1892
|
+
}
|
|
1893
|
+
function optionalNames(source) {
|
|
1894
|
+
const start = source.indexOf("defineEnv(");
|
|
1895
|
+
return start === -1 ? [] : [...source.slice(start).matchAll(OPTIONAL2)].map((match) => match[1] ?? "");
|
|
1896
|
+
}
|
|
1897
|
+
function reachesOutside(name) {
|
|
1898
|
+
return REACHING.test(name) && !SELF.test(name);
|
|
1899
|
+
}
|
|
1900
|
+
async function schemaFile(folder) {
|
|
1901
|
+
for (const candidate of SCHEMA_CANDIDATES) {
|
|
1902
|
+
const path = join4(folder, candidate);
|
|
1903
|
+
if (await exists(path) && (await readFile(path, "utf8")).includes("defineEnv("))
|
|
1904
|
+
return path;
|
|
1905
|
+
}
|
|
1906
|
+
return;
|
|
1907
|
+
}
|
|
1908
|
+
async function testEnvKeys(folder) {
|
|
1909
|
+
const keys = new Set;
|
|
1910
|
+
for (const entry of await readdir3(folder, { withFileTypes: true })) {
|
|
1911
|
+
if (!entry.isFile() || !ENV_TEST.test(entry.name))
|
|
1912
|
+
continue;
|
|
1913
|
+
for (const line of parseDotenv(await readFile(join4(folder, entry.name), "utf8")).lines) {
|
|
1914
|
+
if (line.key !== undefined)
|
|
1915
|
+
keys.add(line.key);
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
return keys;
|
|
1919
|
+
}
|
|
1920
|
+
async function pinningPreload(folder) {
|
|
1921
|
+
const path = join4(folder, "bunfig.toml");
|
|
1922
|
+
if (!await exists(path))
|
|
1923
|
+
return;
|
|
1924
|
+
const config = Bun.TOML.parse(await readFile(path, "utf8"));
|
|
1925
|
+
const preloads = [config.test?.preload, config.preload].flat().filter((entry) => typeof entry === "string" && entry.startsWith("."));
|
|
1926
|
+
for (const preload of preloads) {
|
|
1927
|
+
const file = join4(folder, preload);
|
|
1928
|
+
if (!await exists(file))
|
|
1929
|
+
continue;
|
|
1930
|
+
const source = await readFile(file, "utf8");
|
|
1931
|
+
if (source.includes(".env") && source.includes("process.env"))
|
|
1932
|
+
return preload;
|
|
1933
|
+
}
|
|
1934
|
+
return;
|
|
1935
|
+
}
|
|
1936
|
+
async function checkApp(folder, except = new Set) {
|
|
1937
|
+
const schema = await schemaFile(folder);
|
|
1938
|
+
if (schema === undefined)
|
|
1939
|
+
return;
|
|
1940
|
+
const reaching = optionalNames(await readFile(schema, "utf8")).filter((name) => reachesOutside(name) && !except.has(name));
|
|
1941
|
+
const preload = await pinningPreload(folder);
|
|
1942
|
+
const app = { app: folder, schema: relative2(folder, schema), reaching, unpinned: [], files: [], ...preload !== undefined && { preload } };
|
|
1943
|
+
if (preload !== undefined)
|
|
1944
|
+
return app;
|
|
1945
|
+
const fromEnv = await testEnvKeys(folder);
|
|
1946
|
+
const open = reaching.filter((name) => !fromEnv.has(name));
|
|
1947
|
+
const anywhere = new Set;
|
|
1948
|
+
for (const path of await testFiles(folder)) {
|
|
1949
|
+
const source = await readFile(path, "utf8");
|
|
1950
|
+
const pinned = overriddenNames(source);
|
|
1951
|
+
for (const name of pinned)
|
|
1952
|
+
anywhere.add(name);
|
|
1953
|
+
const unpinned = open.filter((name) => !pinned.has(name));
|
|
1954
|
+
if (RENDERS.test(source) && !NOT_NEEDED.test(source) && unpinned.length > 0)
|
|
1955
|
+
app.files.push({ file: relative2(folder, path), unpinned });
|
|
1956
|
+
}
|
|
1957
|
+
app.unpinned = open.filter((name) => !anywhere.has(name));
|
|
1958
|
+
return app;
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
// src/commands/pins.ts
|
|
1962
|
+
var usage2 = `ev pins [dir] [--except NAME,...]
|
|
1963
|
+
|
|
1964
|
+
Checks that an app's tests pin the env that reaches outside.
|
|
1965
|
+
Reads the app's env schema (the file calling defineEnv, src/env.ts) and lists its optional URL, key, token, secret, password and host variables, then the ones no \`.env.test\` entry, test-level overrideEnv or local bunfig preload dropping what .env sets pins, and each test file that calls testSite or proxyKeyTests without pinning them all.
|
|
1966
|
+
A file whose pages reach none of them says so with a \`// ev pins: none reached, <why>\` comment.
|
|
1967
|
+
The dir is an app, or a monorepo holding apps one level down (the current folder by default).
|
|
1968
|
+
Reads variable names only, never a value.
|
|
1969
|
+
Exits 0 when everything is pinned, 1 when something is not, 2 on a usage error.`;
|
|
1970
|
+
var options2 = {
|
|
1971
|
+
json: jsonOption("each app with its reaching, unpinned variables and flagged test files"),
|
|
1972
|
+
except: { type: "string", description: "comma-separated variables to leave out, as ones that only link back to the app" }
|
|
1973
|
+
};
|
|
1974
|
+
async function isFolder2(path) {
|
|
1975
|
+
try {
|
|
1976
|
+
return (await stat5(path)).isDirectory();
|
|
1977
|
+
} catch {
|
|
1978
|
+
return false;
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
async function apps(dir, except) {
|
|
1982
|
+
const own = await checkApp(dir, except);
|
|
1983
|
+
if (own !== undefined)
|
|
1984
|
+
return [own];
|
|
1985
|
+
const found = [];
|
|
1986
|
+
for (const entry of (await readdir4(dir, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
1987
|
+
if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules")
|
|
1988
|
+
continue;
|
|
1989
|
+
const app = await checkApp(join5(dir, entry.name), except);
|
|
1990
|
+
if (app !== undefined)
|
|
1991
|
+
found.push(app);
|
|
1992
|
+
}
|
|
1993
|
+
return found;
|
|
1994
|
+
}
|
|
1995
|
+
function failing(app) {
|
|
1996
|
+
return app.unpinned.length > 0 || app.files.length > 0;
|
|
1997
|
+
}
|
|
1998
|
+
function name(app, root) {
|
|
1999
|
+
return app.app === root ? app.app.split("/").at(-1) ?? app.app : app.app.slice(root.length + 1);
|
|
2000
|
+
}
|
|
2001
|
+
async function pins(args) {
|
|
2002
|
+
const parsed = parse(args, usage2, options2, { positionals: ["[dir]"] });
|
|
2003
|
+
const json = parsed.values.json === true;
|
|
2004
|
+
const root = resolve2(parsed.positionals[0] ?? ".");
|
|
2005
|
+
const except = new Set((parsed.values.except ?? "").split(",").map((entry) => entry.trim()).filter((entry) => entry !== ""));
|
|
2006
|
+
if (!await isFolder2(root))
|
|
2007
|
+
fail(`${root} is not a folder`);
|
|
2008
|
+
const found = await apps(root, except);
|
|
2009
|
+
if (found.length === 0)
|
|
2010
|
+
fail(`no env schema (a defineEnv call in src/env.ts) in ${root} or its folders`);
|
|
2011
|
+
if (json) {
|
|
2012
|
+
printJson(found.map((app) => ({ ...app, app: name(app, root) })));
|
|
2013
|
+
return found.some(failing) ? 1 : 0;
|
|
2014
|
+
}
|
|
2015
|
+
for (const app of found) {
|
|
2016
|
+
const label = name(app, root);
|
|
2017
|
+
if (!failing(app)) {
|
|
2018
|
+
ok(label, app.preload === undefined ? `${app.reaching.length} reaching variables pinned` : `every variable pinned by ${app.preload}`);
|
|
2019
|
+
continue;
|
|
2020
|
+
}
|
|
2021
|
+
console.log(`${label}: ${app.reaching.length} optional variables reach outside`);
|
|
2022
|
+
if (app.unpinned.length > 0)
|
|
2023
|
+
console.log(` unpinned by .env.test or any overrideEnv: ${app.unpinned.join(", ")}`);
|
|
2024
|
+
for (const { file, unpinned } of app.files)
|
|
2025
|
+
console.log(` ${file} renders a page without pinning: ${unpinned.join(", ")}`);
|
|
2026
|
+
}
|
|
2027
|
+
return found.some(failing) ? 1 : 0;
|
|
2028
|
+
}
|
|
1417
2029
|
|
|
1418
2030
|
// src/commands/read.ts
|
|
1419
2031
|
var {$: $2 } = globalThis.Bun;
|
|
1420
2032
|
var listUsage = `ev list [file]
|
|
1421
2033
|
|
|
1422
|
-
Prints every key of the folder's .env* files, or of one file, with secret values masked: a few leading characters and the length under a key named like TOKEN, SECRET, PASSWORD, KEY, PRIVATE or
|
|
2034
|
+
Prints every key of the folder's .env* files, or of one file, with secret values masked: a few leading characters and the length under a key named like TOKEN, SECRET, PASSWORD, PASS, PWD, KEY, PRIVATE, DSN, AUTH, SALT or COOKIE, any value equal to a secret of the folder's .env* files, and the password and tokens of any URL.
|
|
1423
2035
|
|
|
1424
2036
|
Exits 0 when it printed them, 1 when there is no .env* file, 2 on a usage error.`;
|
|
1425
2037
|
var getUsage = `ev get KEY [file]
|
|
@@ -1427,10 +2039,16 @@ var getUsage = `ev get KEY [file]
|
|
|
1427
2039
|
Prints the masked value of one key, from every .env* file that sets it or from one file.
|
|
1428
2040
|
|
|
1429
2041
|
Exits 0 when the key is set, 1 when it is not set anywhere, 2 on a usage error.`;
|
|
1430
|
-
function display(key, value) {
|
|
1431
|
-
return maskValue(key, value).replaceAll(`
|
|
2042
|
+
function display(key, value, known) {
|
|
2043
|
+
return maskValue(key, value, known).replaceAll(`
|
|
1432
2044
|
`, "\\n").replaceAll("\r", "\\r");
|
|
1433
2045
|
}
|
|
2046
|
+
async function readAll(names) {
|
|
2047
|
+
const files = [];
|
|
2048
|
+
for (const name of names)
|
|
2049
|
+
files.push([name, await readEnv(name)]);
|
|
2050
|
+
return { files, known: await loadKnown(process.cwd(), files.map(([, file]) => file)) };
|
|
2051
|
+
}
|
|
1434
2052
|
async function list(args) {
|
|
1435
2053
|
const parsed = parse(args, listUsage, {});
|
|
1436
2054
|
const [file] = parsed.positionals;
|
|
@@ -1438,9 +2056,10 @@ async function list(args) {
|
|
|
1438
2056
|
if (names.length === 0)
|
|
1439
2057
|
fail("no .env* file in this folder", 1);
|
|
1440
2058
|
const rows = [];
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
2059
|
+
const { files, known } = await readAll(names);
|
|
2060
|
+
for (const [name, file] of files) {
|
|
2061
|
+
for (const [key, value] of entries(file))
|
|
2062
|
+
rows.push([name, key, display(key, value, known)]);
|
|
1444
2063
|
}
|
|
1445
2064
|
if (rows.length > 0)
|
|
1446
2065
|
console.log(table(rows));
|
|
@@ -1455,10 +2074,11 @@ async function get(args) {
|
|
|
1455
2074
|
${getUsage}`);
|
|
1456
2075
|
const names = file === undefined ? await envFiles() : [file];
|
|
1457
2076
|
const rows = [];
|
|
1458
|
-
|
|
1459
|
-
|
|
2077
|
+
const { files, known } = await readAll(names);
|
|
2078
|
+
for (const [name, file] of files) {
|
|
2079
|
+
const value = entries(file).get(key);
|
|
1460
2080
|
if (value !== undefined)
|
|
1461
|
-
rows.push([name, display(key, value)]);
|
|
2081
|
+
rows.push([name, display(key, value, known)]);
|
|
1462
2082
|
}
|
|
1463
2083
|
if (rows.length === 0)
|
|
1464
2084
|
fail(`${key} is not set in ${file ?? "any .env* file"}`, 1);
|
|
@@ -1478,7 +2098,7 @@ Exits 0 when it printed them, 1 when git cannot show the file, 2 on a usage erro
|
|
|
1478
2098
|
var REVISION = /^([^:]+):(.+)$/;
|
|
1479
2099
|
async function grep(args) {
|
|
1480
2100
|
const parsed = parse(args, grepUsage, { "ignore-case": { type: "boolean", short: "i", description: "match the key whatever its case" } });
|
|
1481
|
-
const [source, ...
|
|
2101
|
+
const [source, ...given] = parsed.positionals;
|
|
1482
2102
|
if (source === undefined)
|
|
1483
2103
|
fail(`missing the regex
|
|
1484
2104
|
|
|
@@ -1489,12 +2109,13 @@ ${grepUsage}`);
|
|
|
1489
2109
|
} catch (error) {
|
|
1490
2110
|
fail(`${source} is no valid regular expression: ${error instanceof Error ? error.message : String(error)}`);
|
|
1491
2111
|
}
|
|
1492
|
-
const names =
|
|
2112
|
+
const names = given.length === 0 ? await envFiles() : given;
|
|
1493
2113
|
const rows = [];
|
|
1494
|
-
|
|
1495
|
-
|
|
2114
|
+
const { files, known } = await readAll(names);
|
|
2115
|
+
for (const [name, file] of files) {
|
|
2116
|
+
for (const [key, value] of entries(file)) {
|
|
1496
2117
|
if (pattern.test(key))
|
|
1497
|
-
rows.push([name, key, display(key, value)]);
|
|
2118
|
+
rows.push([name, key, display(key, value, known)]);
|
|
1498
2119
|
}
|
|
1499
2120
|
}
|
|
1500
2121
|
if (rows.length === 0)
|
|
@@ -1510,10 +2131,14 @@ async function show(args) {
|
|
|
1510
2131
|
fail(`give <rev>:<file>
|
|
1511
2132
|
|
|
1512
2133
|
${showUsage}`);
|
|
1513
|
-
|
|
2134
|
+
if (revision.startsWith("-"))
|
|
2135
|
+
fail(`${revision} is not a revision: it reads as a git option`);
|
|
2136
|
+
const shown = await $2`git show --end-of-options ${`${revision}:${path}`}`.quiet().nothrow();
|
|
1514
2137
|
if (shown.exitCode !== 0)
|
|
1515
2138
|
fail(`git show ${revision}:${path} failed: ${shown.stderr.toString().trim()}`, 1);
|
|
1516
|
-
const
|
|
2139
|
+
const file = parseDotenv(shown.stdout.toString());
|
|
2140
|
+
const known = await loadKnown(process.cwd(), [file]);
|
|
2141
|
+
const rows = [...entries(file)].map(([key, value]) => [key, display(key, value, known)]);
|
|
1517
2142
|
if (rows.length > 0)
|
|
1518
2143
|
console.log(table(rows));
|
|
1519
2144
|
return 0;
|
|
@@ -1533,9 +2158,11 @@ process8.exitCode = await runCli({
|
|
|
1533
2158
|
show: { run: show, summary: "an env file at a git revision, values masked" },
|
|
1534
2159
|
set: { run: set, summary: "set a key, the value read from stdin or -F" },
|
|
1535
2160
|
unset: { run: unset, summary: "remove a key from a file" },
|
|
2161
|
+
cp: { run: cp, summary: "copy a key from one file to another, the value never printed" },
|
|
1536
2162
|
mv: { run: mv, summary: "move a key from one file to another" },
|
|
1537
2163
|
diff: { run: diff, summary: "keys set in one file and not the other" },
|
|
1538
2164
|
missing: { run: missing, summary: "keys of .env.example that .env lacks" },
|
|
2165
|
+
pins: { run: pins, summary: "the env vars reaching outside that an app's tests leave to a developer's env" },
|
|
1539
2166
|
audit: { run: audit2, summary: ".env files others can read or git tracks, compose files writing secrets out" }
|
|
1540
2167
|
},
|
|
1541
2168
|
aliases: { ls: "list" }
|