@kaynooo/env-tools 0.6.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 +3 -2
- package/dist/ev.js +601 -106
- package/dist/redact.js +274 -74
- 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 },
|
|
@@ -549,6 +564,11 @@ async function readInput(path, { noStdin = "nothing is piped on stdin" } = {}) {
|
|
|
549
564
|
fail(`no file ${path}`);
|
|
550
565
|
return file.text();
|
|
551
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}\\`;
|
|
552
572
|
// ../kit/src/cli.ts
|
|
553
573
|
import process5 from "process";
|
|
554
574
|
var LEVEL_OPTIONS = {
|
|
@@ -755,8 +775,17 @@ async function runMain({ name, version, run, redact }, argv = process5.argv.slic
|
|
|
755
775
|
return reportThrown(name, error, redact, wantsJson(argv));
|
|
756
776
|
}
|
|
757
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
|
+
}
|
|
758
787
|
// package.json
|
|
759
|
-
var version = "0.
|
|
788
|
+
var version = "0.7.0";
|
|
760
789
|
|
|
761
790
|
// src/commands/audit.ts
|
|
762
791
|
import { stat as stat4 } from "fs/promises";
|
|
@@ -769,7 +798,7 @@ import { dirname as dirname2, join as join2 } from "path";
|
|
|
769
798
|
var {$ } = globalThis.Bun;
|
|
770
799
|
|
|
771
800
|
// src/files.ts
|
|
772
|
-
import { chmod, readdir, realpath, rename, rm, stat, writeFile } from "fs/promises";
|
|
801
|
+
import { chmod, chown, readdir, realpath, rename, rm, stat, writeFile } from "fs/promises";
|
|
773
802
|
import { basename, dirname, join } from "path";
|
|
774
803
|
|
|
775
804
|
// src/dotenv.ts
|
|
@@ -778,7 +807,9 @@ var EXPORTED = /^\s*export\s/;
|
|
|
778
807
|
var INLINE_COMMENT = /\s+#.*$/;
|
|
779
808
|
var CARRIAGE_RETURN = /\r$/;
|
|
780
809
|
var ESCAPES = { n: `
|
|
781
|
-
`, r: "\r", t: "\t", '"': '"', "\\": "\\" };
|
|
810
|
+
`, r: "\r", t: "\t", '"': '"', "\\": "\\", $: "$" };
|
|
811
|
+
var LINE_BREAK = /[\r\n]/;
|
|
812
|
+
var RAW_ONLY = /["\\]/;
|
|
782
813
|
var NEEDS_QUOTES = /[\s#"'\\$`]/;
|
|
783
814
|
function closingQuote(text, quote) {
|
|
784
815
|
for (let index = 0;index < text.length; index++) {
|
|
@@ -789,6 +820,9 @@ function closingQuote(text, quote) {
|
|
|
789
820
|
}
|
|
790
821
|
return -1;
|
|
791
822
|
}
|
|
823
|
+
function unescapeDollar(text) {
|
|
824
|
+
return text.replaceAll("\\$", "$");
|
|
825
|
+
}
|
|
792
826
|
function unescapeDouble(text) {
|
|
793
827
|
let out = "";
|
|
794
828
|
for (let index = 0;index < text.length; index++) {
|
|
@@ -817,7 +851,7 @@ function parseDotenv(text) {
|
|
|
817
851
|
const start = rest.trimStart();
|
|
818
852
|
const quote = start[0];
|
|
819
853
|
if (quote !== '"' && quote !== "'" && quote !== "`") {
|
|
820
|
-
lines.push({ raw: line, key, value: start.replace(INLINE_COMMENT, "").trim() });
|
|
854
|
+
lines.push({ raw: line, key, value: unescapeDollar(start.replace(INLINE_COMMENT, "").trim()) });
|
|
821
855
|
continue;
|
|
822
856
|
}
|
|
823
857
|
let body = start.slice(1);
|
|
@@ -832,7 +866,7 @@ ${physical[index] ?? ""}`;
|
|
|
832
866
|
end = closingQuote(body, quote);
|
|
833
867
|
}
|
|
834
868
|
const inner = end === -1 ? body : body.slice(0, end);
|
|
835
|
-
lines.push({ raw, key, value: quote === '"' ? unescapeDouble(inner) : inner });
|
|
869
|
+
lines.push({ raw, key, value: quote === '"' ? unescapeDouble(inner) : unescapeDollar(inner) });
|
|
836
870
|
}
|
|
837
871
|
return { lines };
|
|
838
872
|
}
|
|
@@ -851,8 +885,10 @@ function entries(file) {
|
|
|
851
885
|
function formatAssignment(key, value) {
|
|
852
886
|
if (value === "" || !NEEDS_QUOTES.test(value))
|
|
853
887
|
return `${key}=${value}`;
|
|
888
|
+
if (RAW_ONLY.test(value) && !value.includes("'") && !LINE_BREAK.test(value))
|
|
889
|
+
return `${key}='${value.replaceAll("$", "\\$")}'`;
|
|
854
890
|
const escaped = value.replaceAll("\\", "\\\\").replaceAll('"', "\\\"").replaceAll("\r", "\\r").replaceAll(`
|
|
855
|
-
`, "\\n");
|
|
891
|
+
`, "\\n").replaceAll("$", "\\$");
|
|
856
892
|
return `${key}="${escaped}"`;
|
|
857
893
|
}
|
|
858
894
|
function setLine(file, key, value, raw) {
|
|
@@ -882,20 +918,38 @@ function lastLine(file, key) {
|
|
|
882
918
|
}
|
|
883
919
|
|
|
884
920
|
// src/mask.ts
|
|
885
|
-
var MIN_KNOWN_LENGTH =
|
|
886
|
-
var
|
|
887
|
-
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}$/;
|
|
888
927
|
var ESCAPE2 = /[.*+?^${}()|[\]\\]/g;
|
|
928
|
+
function isPlaceholder(value) {
|
|
929
|
+
return PLACEHOLDER.test(value) || PLAIN_VALUE.test(value);
|
|
930
|
+
}
|
|
889
931
|
var TOKEN_FORMATS = [
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
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 }
|
|
895
946
|
];
|
|
896
|
-
var
|
|
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;
|
|
897
952
|
var PATH_SEGMENT = /[^/]+/g;
|
|
898
|
-
var QUERY_PARAM = /([^&=]+)=([^&]*)/g;
|
|
899
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;
|
|
900
954
|
var WEBHOOK_FORMATS = [
|
|
901
955
|
["Discord webhook", /(?:^|\.)discord(?:app)?\.com\/api\/(?:v\d+\/)?webhooks\/\d+\/([\w-]+)/di],
|
|
@@ -904,28 +958,41 @@ var WEBHOOK_FORMATS = [
|
|
|
904
958
|
["healthchecks.io ping URL", /^hc-ping\.com\/([\w-]+)/di]
|
|
905
959
|
];
|
|
906
960
|
var OPAQUE_SEGMENT = /^(?:(?=[\w-]{20,}$)(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[\w-]+|(?=[A-Za-z\d]{32,}$)(?=.*[A-Za-z])(?=.*\d).+)$/;
|
|
907
|
-
var PEM_BEGIN = /-----BEGIN [A-Z ]*PRIVATE KEY-----/;
|
|
908
|
-
var PEM_END = /-----END [A-Z ]*PRIVATE KEY-----/;
|
|
909
|
-
function isSecretKey(key) {
|
|
910
|
-
return SECRET_KEY.test(key);
|
|
911
|
-
}
|
|
912
961
|
function hint(value) {
|
|
913
962
|
const shown = Math.min(3, Math.floor(value.length / 4));
|
|
914
963
|
return `${value.slice(0, shown)}***(${value.length})`;
|
|
915
964
|
}
|
|
965
|
+
function forms(value) {
|
|
966
|
+
return [...new Set([value, encodeURIComponent(value), JSON.stringify(value).slice(1, -1)])];
|
|
967
|
+
}
|
|
916
968
|
function knownSecrets(pairs) {
|
|
917
969
|
const keys = new Map;
|
|
918
970
|
for (const [key, value] of pairs) {
|
|
919
|
-
if (value.length
|
|
920
|
-
|
|
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
|
+
}
|
|
921
977
|
}
|
|
922
978
|
if (keys.size === 0)
|
|
923
979
|
return { pattern: undefined, keys };
|
|
924
980
|
const alternatives = [...keys.keys()].sort((a, b) => b.length - a.length).map((value) => value.replace(ESCAPE2, "\\$&"));
|
|
925
981
|
return { pattern: new RegExp(`(?<!\\w)(?:${alternatives.join("|")})(?!\\w)`, "g"), keys };
|
|
926
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
|
+
}
|
|
927
994
|
function urlPasswords(text) {
|
|
928
|
-
return
|
|
995
|
+
return passwordSpans(text).map(([start, end]) => text.slice(start, end));
|
|
929
996
|
}
|
|
930
997
|
function urlTokens(line) {
|
|
931
998
|
const found = [];
|
|
@@ -952,24 +1019,34 @@ function urlTokens(line) {
|
|
|
952
1019
|
}
|
|
953
1020
|
if (query === undefined || queryStart === undefined)
|
|
954
1021
|
continue;
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
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;
|
|
959
1028
|
}
|
|
960
1029
|
}
|
|
961
1030
|
return found.filter((match) => match.end > match.start);
|
|
962
1031
|
}
|
|
963
1032
|
function findSecrets(line, known) {
|
|
964
1033
|
const found = [];
|
|
965
|
-
for (const
|
|
966
|
-
|
|
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] ?? [];
|
|
967
1038
|
if (start !== undefined && end !== undefined)
|
|
968
|
-
found.push({ start, end, kind: "
|
|
1039
|
+
found.push({ start, end, kind: "private key", mask: KEY_MASK });
|
|
969
1040
|
}
|
|
970
|
-
for (const
|
|
971
|
-
for (const token of line.matchAll(pattern))
|
|
972
|
-
|
|
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
|
+
}
|
|
973
1050
|
}
|
|
974
1051
|
found.push(...urlTokens(line));
|
|
975
1052
|
if (known?.pattern !== undefined) {
|
|
@@ -984,6 +1061,13 @@ function findSecrets(line, known) {
|
|
|
984
1061
|
}
|
|
985
1062
|
return kept;
|
|
986
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
|
+
}
|
|
987
1071
|
function maskLine(line, known) {
|
|
988
1072
|
let out = "";
|
|
989
1073
|
let at = 0;
|
|
@@ -993,18 +1077,38 @@ function maskLine(line, known) {
|
|
|
993
1077
|
}
|
|
994
1078
|
return out + line.slice(at);
|
|
995
1079
|
}
|
|
996
|
-
function maskValue(key, value) {
|
|
1080
|
+
function maskValue(key, value, known) {
|
|
997
1081
|
if (value === "")
|
|
998
1082
|
return value;
|
|
999
|
-
return
|
|
1083
|
+
return isSecretName(key) || PEM_BEGIN.test(value) ? hint(value) : maskLine(value, known);
|
|
1000
1084
|
}
|
|
1001
1085
|
|
|
1002
1086
|
// src/files.ts
|
|
1003
|
-
var ENV_FILE =
|
|
1087
|
+
var ENV_FILE = /^(?:\.env(?:\..+)?|.+\.env|\.envrc)$/;
|
|
1004
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
|
+
}
|
|
1005
1104
|
async function envFiles(folder = process.cwd()) {
|
|
1006
1105
|
const found = await readdir(folder, { withFileTypes: true });
|
|
1007
|
-
|
|
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();
|
|
1008
1112
|
}
|
|
1009
1113
|
async function readEnv(path) {
|
|
1010
1114
|
const file = Bun.file(path);
|
|
@@ -1012,46 +1116,125 @@ async function readEnv(path) {
|
|
|
1012
1116
|
fail(`${path} does not exist`);
|
|
1013
1117
|
return parseDotenv(await file.text());
|
|
1014
1118
|
}
|
|
1015
|
-
async function
|
|
1119
|
+
async function writeAtomic(path, text) {
|
|
1016
1120
|
let target = path;
|
|
1017
1121
|
let mode = 384;
|
|
1122
|
+
let owner;
|
|
1018
1123
|
try {
|
|
1019
1124
|
target = await realpath(path);
|
|
1020
|
-
|
|
1021
|
-
|
|
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
|
+
}
|
|
1022
1132
|
const temp = join(dirname(target), `.${basename(target)}.${process.pid}.${crypto.randomUUID()}.tmp`);
|
|
1023
1133
|
try {
|
|
1024
|
-
await writeFile(temp,
|
|
1134
|
+
await writeFile(temp, text, { mode: 384, flag: "wx" });
|
|
1135
|
+
if (owner !== undefined)
|
|
1136
|
+
await keepOwner(temp, owner);
|
|
1025
1137
|
await chmod(temp, mode);
|
|
1026
1138
|
await rename(temp, target);
|
|
1027
1139
|
} finally {
|
|
1028
1140
|
await rm(temp, { force: true });
|
|
1029
1141
|
}
|
|
1030
1142
|
}
|
|
1031
|
-
async function
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
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
|
+
}
|
|
1151
|
+
async function writeEnv(path, file) {
|
|
1152
|
+
await writeAtomic(path, serializeDotenv(file));
|
|
1153
|
+
}
|
|
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;
|
|
1039
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 {}
|
|
1040
1222
|
}
|
|
1041
1223
|
return knownSecrets(pairs);
|
|
1042
1224
|
}
|
|
1043
1225
|
|
|
1044
1226
|
// src/audit.ts
|
|
1045
1227
|
var MAX_DEPTH = 5;
|
|
1046
|
-
var
|
|
1228
|
+
var SKIPPED2 = new Set(["node_modules", ".git", "vendor"]);
|
|
1047
1229
|
var COMPOSE_FILE = /^(?:docker-)?compose[\w.-]*\.ya?ml$/;
|
|
1048
1230
|
var ENTRY = /^\s*(?:-\s*)?["']?([A-Z0-9_]+)["']?\s*[:=]\s*["']?([^"'\s#]+)/;
|
|
1049
|
-
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/;
|
|
1050
1233
|
var FLAG = /^(?:[01]|yes|no|true|false)$/i;
|
|
1051
1234
|
function isInlineSecret(name, value) {
|
|
1052
1235
|
if (value === "" || value.startsWith("$") || value.startsWith("/run/secrets/") || FLAG.test(value))
|
|
1053
1236
|
return false;
|
|
1054
|
-
return
|
|
1237
|
+
return isSecretName(name) && !SETTING_SUFFIX.test(name) || urlPasswords(value).some((password) => !password.startsWith("$"));
|
|
1055
1238
|
}
|
|
1056
1239
|
function inlineSecrets(compose) {
|
|
1057
1240
|
const names = compose.split(`
|
|
@@ -1070,8 +1253,30 @@ async function openToOthers(path) {
|
|
|
1070
1253
|
}
|
|
1071
1254
|
return true;
|
|
1072
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
|
+
}
|
|
1073
1272
|
async function tracked(path) {
|
|
1074
|
-
|
|
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}`}`);
|
|
1075
1280
|
}
|
|
1076
1281
|
async function* candidates2(root, unread, depth = 0) {
|
|
1077
1282
|
let found;
|
|
@@ -1083,9 +1288,9 @@ async function* candidates2(root, unread, depth = 0) {
|
|
|
1083
1288
|
}
|
|
1084
1289
|
for (const entry of found) {
|
|
1085
1290
|
const path = join2(root, entry.name);
|
|
1086
|
-
if (entry.isDirectory() && depth < MAX_DEPTH && !
|
|
1291
|
+
if (entry.isDirectory() && depth < MAX_DEPTH && !SKIPPED2.has(entry.name))
|
|
1087
1292
|
yield* candidates2(path, unread, depth + 1);
|
|
1088
|
-
else if (
|
|
1293
|
+
else if (!TEMPLATE.test(entry.name) && (ENV_FILE.test(entry.name) || COMPOSE_FILE.test(entry.name)) && await isFileEntry(entry, path))
|
|
1089
1294
|
yield path;
|
|
1090
1295
|
}
|
|
1091
1296
|
}
|
|
@@ -1094,24 +1299,26 @@ async function audit(roots) {
|
|
|
1094
1299
|
const unread = [];
|
|
1095
1300
|
for (const root of roots) {
|
|
1096
1301
|
for await (const path of candidates2(root, unread)) {
|
|
1097
|
-
const
|
|
1302
|
+
const others = await openToOthers(path);
|
|
1303
|
+
const readable = others || await sharedWith(path);
|
|
1304
|
+
const group = readable && !others ? true : undefined;
|
|
1098
1305
|
const name = path.slice(path.lastIndexOf("/") + 1);
|
|
1099
1306
|
if (ENV_FILE.test(name)) {
|
|
1100
1307
|
const isTracked = await tracked(path);
|
|
1101
1308
|
if (readable || isTracked)
|
|
1102
|
-
exposed.push({ path, readable, tracked: isTracked });
|
|
1309
|
+
exposed.push({ path, readable, ...group && { group }, tracked: isTracked });
|
|
1103
1310
|
continue;
|
|
1104
1311
|
}
|
|
1105
1312
|
const secrets = readable ? inlineSecrets(await Bun.file(path).text()) : [];
|
|
1106
1313
|
if (secrets.length > 0)
|
|
1107
|
-
exposed.push({ path, readable, tracked: false, secrets });
|
|
1314
|
+
exposed.push({ path, readable, ...group && { group }, tracked: false, secrets });
|
|
1108
1315
|
}
|
|
1109
1316
|
}
|
|
1110
1317
|
return { exposed: exposed.sort((a, b) => a.path.localeCompare(b.path)), unread };
|
|
1111
1318
|
}
|
|
1112
1319
|
|
|
1113
1320
|
// src/fix.ts
|
|
1114
|
-
import { chmod as chmod2, chown, rm as rm2, stat as stat3
|
|
1321
|
+
import { chmod as chmod2, chown as chown2, lstat, rm as rm2, stat as stat3 } from "fs/promises";
|
|
1115
1322
|
import { dirname as dirname3, join as join3 } from "path";
|
|
1116
1323
|
var MAP_ENTRY = /^(\s*)(["']?)([A-Z0-9_]+)\2(\s*:)(.*)$/;
|
|
1117
1324
|
var LIST_ENTRY = /^(\s*-[ \t]*)(["']?)([A-Z0-9_]+)=(.*)$/;
|
|
@@ -1173,6 +1380,15 @@ function envLine(key, value) {
|
|
|
1173
1380
|
return value.includes("'") || value.includes(`
|
|
1174
1381
|
`) ? undefined : `${key}='${value}'`;
|
|
1175
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
|
+
}
|
|
1176
1392
|
async function resolved(path) {
|
|
1177
1393
|
const run = Bun.spawn(["docker", "compose", "-f", path, "config", "--format", "json"], { cwd: dirname3(path), stdout: "pipe", stderr: "ignore", stdin: "ignore" });
|
|
1178
1394
|
const [out, code] = await Promise.all([new Response(run.stdout).text(), run.exited]);
|
|
@@ -1183,9 +1399,9 @@ function gainedBy(before, after, key) {
|
|
|
1183
1399
|
const had = new Set(services(before).filter(([, service]) => service.environment?.[key] !== undefined).map(([name]) => name));
|
|
1184
1400
|
return services(after).filter(([name, service]) => service.environment?.[key] !== undefined && !had.has(name)).map(([name]) => name);
|
|
1185
1401
|
}
|
|
1186
|
-
async function shutOthersOut(path) {
|
|
1402
|
+
async function shutOthersOut(path, group = false) {
|
|
1187
1403
|
try {
|
|
1188
|
-
await chmod2(path, (await stat3(path)).mode & 504);
|
|
1404
|
+
await chmod2(path, (await stat3(path)).mode & (group ? 448 : 504));
|
|
1189
1405
|
return true;
|
|
1190
1406
|
} catch (error) {
|
|
1191
1407
|
if (error.code === "EPERM")
|
|
@@ -1193,13 +1409,14 @@ async function shutOthersOut(path) {
|
|
|
1193
1409
|
throw error;
|
|
1194
1410
|
}
|
|
1195
1411
|
}
|
|
1196
|
-
async function fixCompose(path) {
|
|
1412
|
+
async function fixCompose(path, group = false) {
|
|
1197
1413
|
const env = join3(dirname3(path), ".env");
|
|
1198
1414
|
const compose = await Bun.file(path).text();
|
|
1199
1415
|
const envBefore = await Bun.file(env).exists() ? await Bun.file(env).text() : undefined;
|
|
1200
1416
|
const dotenv = parseDotenv(envBefore ?? "");
|
|
1201
1417
|
const known = entries(dotenv);
|
|
1202
1418
|
const fix = { path, env, moved: [], left: [], open: [] };
|
|
1419
|
+
const linked = await isLink(env);
|
|
1203
1420
|
const lines = compose.split(`
|
|
1204
1421
|
`).map((line) => {
|
|
1205
1422
|
const entry = composeEntry(line);
|
|
@@ -1210,6 +1427,10 @@ async function fixCompose(path) {
|
|
|
1210
1427
|
fix.left.push({ key, why: entry.unmovable });
|
|
1211
1428
|
return line;
|
|
1212
1429
|
}
|
|
1430
|
+
if (linked) {
|
|
1431
|
+
fix.left.push({ key, why: "its .env is a symbolic link" });
|
|
1432
|
+
return line;
|
|
1433
|
+
}
|
|
1213
1434
|
if (entry.value.replaceAll("$$", "").includes("$")) {
|
|
1214
1435
|
fix.left.push({ key, why: "its value reads another variable" });
|
|
1215
1436
|
return line;
|
|
@@ -1236,7 +1457,7 @@ async function fixCompose(path) {
|
|
|
1236
1457
|
await move(fix, compose, lines.join(`
|
|
1237
1458
|
`), envBefore, serializeDotenv(dotenv));
|
|
1238
1459
|
for (const file of [path, env]) {
|
|
1239
|
-
if (await Bun.file(file).exists() && !await shutOthersOut(file))
|
|
1460
|
+
if (!(file === env && linked) && await Bun.file(file).exists() && !await shutOthersOut(file, group))
|
|
1240
1461
|
fix.open.push(file);
|
|
1241
1462
|
}
|
|
1242
1463
|
return fix;
|
|
@@ -1246,15 +1467,15 @@ async function move(fix, compose, composeAfter, envBefore, envAfter) {
|
|
|
1246
1467
|
const docker = Bun.which("docker") !== null;
|
|
1247
1468
|
const before = docker ? await resolved(path) : undefined;
|
|
1248
1469
|
if (envBefore === undefined) {
|
|
1249
|
-
await
|
|
1470
|
+
await writeAtomic(env, envAfter);
|
|
1250
1471
|
const { uid, gid } = await stat3(path);
|
|
1251
1472
|
try {
|
|
1252
|
-
await
|
|
1473
|
+
await chown2(env, uid, gid);
|
|
1253
1474
|
} catch {}
|
|
1254
1475
|
} else {
|
|
1255
|
-
await
|
|
1476
|
+
await writeAtomic(env, envAfter);
|
|
1256
1477
|
}
|
|
1257
|
-
await
|
|
1478
|
+
await writeAtomic(path, composeAfter);
|
|
1258
1479
|
if (!docker) {
|
|
1259
1480
|
fix.check = "no docker";
|
|
1260
1481
|
return;
|
|
@@ -1268,11 +1489,11 @@ async function move(fix, compose, composeAfter, envBefore, envAfter) {
|
|
|
1268
1489
|
fix.check = "same";
|
|
1269
1490
|
return;
|
|
1270
1491
|
}
|
|
1271
|
-
await
|
|
1492
|
+
await writeAtomic(path, compose);
|
|
1272
1493
|
if (envBefore === undefined)
|
|
1273
1494
|
await rm2(env);
|
|
1274
1495
|
else
|
|
1275
|
-
await
|
|
1496
|
+
await writeAtomic(env, envBefore);
|
|
1276
1497
|
fix.check = "rolled back";
|
|
1277
1498
|
fix.left.push(...fix.moved.map((key) => {
|
|
1278
1499
|
const gained = after === undefined ? [] : gainedBy(before, after, key);
|
|
@@ -1316,9 +1537,9 @@ function shown(path) {
|
|
|
1316
1537
|
async function fixAll(exposed, json) {
|
|
1317
1538
|
const fixed = [];
|
|
1318
1539
|
let left = false;
|
|
1319
|
-
for (const { path, readable, tracked, secrets } of exposed) {
|
|
1320
|
-
const fix = secrets === undefined ? undefined : await fixCompose(path);
|
|
1321
|
-
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] : []);
|
|
1322
1543
|
if (fix !== undefined)
|
|
1323
1544
|
fixed.push(fix);
|
|
1324
1545
|
left ||= tracked || open.length > 0 || (fix?.left.length ?? 0) > 0;
|
|
@@ -1328,7 +1549,7 @@ async function fixAll(exposed, json) {
|
|
|
1328
1549
|
...fix === undefined || fix.moved.length === 0 ? [] : [`moved ${fix.moved.join(", ")} into ${shown(fix.env)}`],
|
|
1329
1550
|
...fix?.left.map(({ key, why }) => `left ${key} in place: ${why}`) ?? [],
|
|
1330
1551
|
...fix?.check === undefined ? [] : [CHECKS[fix.check]],
|
|
1331
|
-
...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))}`),
|
|
1332
1553
|
...readable && !open.includes(path) ? ["now unreadable by other accounts"] : [],
|
|
1333
1554
|
...tracked ? [`tracked by git: git rm --cached ${quoted2(shown(path))}, then ignore it`] : []
|
|
1334
1555
|
];
|
|
@@ -1368,11 +1589,11 @@ async function audit2(args) {
|
|
|
1368
1589
|
return 0;
|
|
1369
1590
|
}
|
|
1370
1591
|
const uid = process7.getuid?.();
|
|
1371
|
-
for (const { path, readable, tracked, secrets } of exposed) {
|
|
1592
|
+
for (const { path, readable, group, tracked, secrets } of exposed) {
|
|
1372
1593
|
const sudo = uid !== undefined && uid !== 0 && (await stat4(path)).uid !== uid ? "sudo " : "";
|
|
1373
1594
|
const problems = [
|
|
1374
1595
|
...secrets === undefined ? [] : [`writes out ${secrets.join(", ")}: ev audit --fix moves the values into a .env beside it`],
|
|
1375
|
-
...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))}`] : [],
|
|
1376
1597
|
...tracked ? [`tracked by git: git rm --cached ${quoted2(shown(path))}, then ignore it`] : []
|
|
1377
1598
|
];
|
|
1378
1599
|
console.log(`${shown(path)}
|
|
@@ -1431,6 +1652,7 @@ async function missing(args) {
|
|
|
1431
1652
|
// src/commands/edit.ts
|
|
1432
1653
|
var DEFAULT_FILE = ".env";
|
|
1433
1654
|
var TRAILING_NEWLINE = /\r?\n$/;
|
|
1655
|
+
var KEY_NAME = /^[A-Z_][\w.-]*$/i;
|
|
1434
1656
|
var setUsage = `ev set KEY [file]
|
|
1435
1657
|
|
|
1436
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.
|
|
@@ -1457,6 +1679,10 @@ async function readValue(from) {
|
|
|
1457
1679
|
fail("the value is empty, nothing was written: --empty sets an empty value");
|
|
1458
1680
|
return input.replace(TRAILING_NEWLINE, "");
|
|
1459
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
|
+
}
|
|
1460
1686
|
async function readOrEmpty(path) {
|
|
1461
1687
|
return await Bun.file(path).exists() ? readEnv(path) : { lines: [] };
|
|
1462
1688
|
}
|
|
@@ -1472,11 +1698,15 @@ async function set(args) {
|
|
|
1472
1698
|
${setUsage}`);
|
|
1473
1699
|
if (parsed.values.empty === true && parsed.values.file !== undefined)
|
|
1474
1700
|
fail("--empty and -F exclude each other");
|
|
1701
|
+
checkKey(key);
|
|
1475
1702
|
const value = parsed.values.empty === true ? "" : await readValue(parsed.values.file);
|
|
1476
|
-
const file = await
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
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(`
|
|
1480
1710
|
`, "\\n")}`);
|
|
1481
1711
|
return 0;
|
|
1482
1712
|
}
|
|
@@ -1487,14 +1717,19 @@ async function unset(args) {
|
|
|
1487
1717
|
fail(`missing KEY
|
|
1488
1718
|
|
|
1489
1719
|
${unsetUsage}`);
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
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
|
+
});
|
|
1494
1726
|
console.log(`${target}: removed ${key}`);
|
|
1495
1727
|
return 0;
|
|
1496
1728
|
}
|
|
1497
1729
|
async function transfer(key, from, to, newKey, replace) {
|
|
1730
|
+
checkKey(key);
|
|
1731
|
+
if (newKey !== undefined)
|
|
1732
|
+
checkKey(newKey);
|
|
1498
1733
|
if (from === to && newKey === undefined)
|
|
1499
1734
|
fail("--from and --to are the same file");
|
|
1500
1735
|
const source = await readEnv(from);
|
|
@@ -1508,7 +1743,7 @@ async function transfer(key, from, to, newKey, replace) {
|
|
|
1508
1743
|
const value = line.value ?? "";
|
|
1509
1744
|
setLine(target, name, value, newKey === undefined && !entries(target).has(name) ? line.raw : undefined);
|
|
1510
1745
|
await writeEnv(to, target);
|
|
1511
|
-
return { source, value, name };
|
|
1746
|
+
return { source, target, value, name };
|
|
1512
1747
|
}
|
|
1513
1748
|
async function mv(args) {
|
|
1514
1749
|
const parsed = parse(args, mvUsage, {
|
|
@@ -1522,9 +1757,11 @@ async function mv(args) {
|
|
|
1522
1757
|
fail(`needs KEY, --from and --to
|
|
1523
1758
|
|
|
1524
1759
|
${mvUsage}`);
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
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
|
+
});
|
|
1528
1765
|
console.log(`${key}: ${from} -> ${to}`);
|
|
1529
1766
|
return 0;
|
|
1530
1767
|
}
|
|
@@ -1540,17 +1777,261 @@ async function cp(args) {
|
|
|
1540
1777
|
fail(`needs KEY, --from and --to
|
|
1541
1778
|
|
|
1542
1779
|
${cpUsage}`);
|
|
1543
|
-
const { value, name } = await transfer(key, from, to, as, true);
|
|
1544
|
-
|
|
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(`
|
|
1545
1783
|
`, "\\n")}`);
|
|
1546
1784
|
return 0;
|
|
1547
1785
|
}
|
|
1548
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
|
+
}
|
|
2029
|
+
|
|
1549
2030
|
// src/commands/read.ts
|
|
1550
2031
|
var {$: $2 } = globalThis.Bun;
|
|
1551
2032
|
var listUsage = `ev list [file]
|
|
1552
2033
|
|
|
1553
|
-
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.
|
|
1554
2035
|
|
|
1555
2036
|
Exits 0 when it printed them, 1 when there is no .env* file, 2 on a usage error.`;
|
|
1556
2037
|
var getUsage = `ev get KEY [file]
|
|
@@ -1558,10 +2039,16 @@ var getUsage = `ev get KEY [file]
|
|
|
1558
2039
|
Prints the masked value of one key, from every .env* file that sets it or from one file.
|
|
1559
2040
|
|
|
1560
2041
|
Exits 0 when the key is set, 1 when it is not set anywhere, 2 on a usage error.`;
|
|
1561
|
-
function display(key, value) {
|
|
1562
|
-
return maskValue(key, value).replaceAll(`
|
|
2042
|
+
function display(key, value, known) {
|
|
2043
|
+
return maskValue(key, value, known).replaceAll(`
|
|
1563
2044
|
`, "\\n").replaceAll("\r", "\\r");
|
|
1564
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
|
+
}
|
|
1565
2052
|
async function list(args) {
|
|
1566
2053
|
const parsed = parse(args, listUsage, {});
|
|
1567
2054
|
const [file] = parsed.positionals;
|
|
@@ -1569,9 +2056,10 @@ async function list(args) {
|
|
|
1569
2056
|
if (names.length === 0)
|
|
1570
2057
|
fail("no .env* file in this folder", 1);
|
|
1571
2058
|
const rows = [];
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
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)]);
|
|
1575
2063
|
}
|
|
1576
2064
|
if (rows.length > 0)
|
|
1577
2065
|
console.log(table(rows));
|
|
@@ -1586,10 +2074,11 @@ async function get(args) {
|
|
|
1586
2074
|
${getUsage}`);
|
|
1587
2075
|
const names = file === undefined ? await envFiles() : [file];
|
|
1588
2076
|
const rows = [];
|
|
1589
|
-
|
|
1590
|
-
|
|
2077
|
+
const { files, known } = await readAll(names);
|
|
2078
|
+
for (const [name, file] of files) {
|
|
2079
|
+
const value = entries(file).get(key);
|
|
1591
2080
|
if (value !== undefined)
|
|
1592
|
-
rows.push([name, display(key, value)]);
|
|
2081
|
+
rows.push([name, display(key, value, known)]);
|
|
1593
2082
|
}
|
|
1594
2083
|
if (rows.length === 0)
|
|
1595
2084
|
fail(`${key} is not set in ${file ?? "any .env* file"}`, 1);
|
|
@@ -1609,7 +2098,7 @@ Exits 0 when it printed them, 1 when git cannot show the file, 2 on a usage erro
|
|
|
1609
2098
|
var REVISION = /^([^:]+):(.+)$/;
|
|
1610
2099
|
async function grep(args) {
|
|
1611
2100
|
const parsed = parse(args, grepUsage, { "ignore-case": { type: "boolean", short: "i", description: "match the key whatever its case" } });
|
|
1612
|
-
const [source, ...
|
|
2101
|
+
const [source, ...given] = parsed.positionals;
|
|
1613
2102
|
if (source === undefined)
|
|
1614
2103
|
fail(`missing the regex
|
|
1615
2104
|
|
|
@@ -1620,12 +2109,13 @@ ${grepUsage}`);
|
|
|
1620
2109
|
} catch (error) {
|
|
1621
2110
|
fail(`${source} is no valid regular expression: ${error instanceof Error ? error.message : String(error)}`);
|
|
1622
2111
|
}
|
|
1623
|
-
const names =
|
|
2112
|
+
const names = given.length === 0 ? await envFiles() : given;
|
|
1624
2113
|
const rows = [];
|
|
1625
|
-
|
|
1626
|
-
|
|
2114
|
+
const { files, known } = await readAll(names);
|
|
2115
|
+
for (const [name, file] of files) {
|
|
2116
|
+
for (const [key, value] of entries(file)) {
|
|
1627
2117
|
if (pattern.test(key))
|
|
1628
|
-
rows.push([name, key, display(key, value)]);
|
|
2118
|
+
rows.push([name, key, display(key, value, known)]);
|
|
1629
2119
|
}
|
|
1630
2120
|
}
|
|
1631
2121
|
if (rows.length === 0)
|
|
@@ -1641,10 +2131,14 @@ async function show(args) {
|
|
|
1641
2131
|
fail(`give <rev>:<file>
|
|
1642
2132
|
|
|
1643
2133
|
${showUsage}`);
|
|
1644
|
-
|
|
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();
|
|
1645
2137
|
if (shown.exitCode !== 0)
|
|
1646
2138
|
fail(`git show ${revision}:${path} failed: ${shown.stderr.toString().trim()}`, 1);
|
|
1647
|
-
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)]);
|
|
1648
2142
|
if (rows.length > 0)
|
|
1649
2143
|
console.log(table(rows));
|
|
1650
2144
|
return 0;
|
|
@@ -1668,6 +2162,7 @@ process8.exitCode = await runCli({
|
|
|
1668
2162
|
mv: { run: mv, summary: "move a key from one file to another" },
|
|
1669
2163
|
diff: { run: diff, summary: "keys set in one file and not the other" },
|
|
1670
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" },
|
|
1671
2166
|
audit: { run: audit2, summary: ".env files others can read or git tracks, compose files writing secrets out" }
|
|
1672
2167
|
},
|
|
1673
2168
|
aliases: { ls: "list" }
|