@kaynooo/env-tools 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/dist/ev.js +605 -106
- package/dist/redact.js +278 -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,21 @@ 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
|
+
}
|
|
787
|
+
// ../kit/src/sessions.ts
|
|
788
|
+
var {Glob } = globalThis.Bun;
|
|
789
|
+
var SESSION_FILES = new Glob("*.json");
|
|
790
|
+
var ACCOUNT_DIRS = new Glob("*/sessions");
|
|
758
791
|
// package.json
|
|
759
|
-
var version = "0.
|
|
792
|
+
var version = "0.7.1";
|
|
760
793
|
|
|
761
794
|
// src/commands/audit.ts
|
|
762
795
|
import { stat as stat4 } from "fs/promises";
|
|
@@ -769,7 +802,7 @@ import { dirname as dirname2, join as join2 } from "path";
|
|
|
769
802
|
var {$ } = globalThis.Bun;
|
|
770
803
|
|
|
771
804
|
// src/files.ts
|
|
772
|
-
import { chmod, readdir, realpath, rename, rm, stat, writeFile } from "fs/promises";
|
|
805
|
+
import { chmod, chown, readdir, realpath, rename, rm, stat, writeFile } from "fs/promises";
|
|
773
806
|
import { basename, dirname, join } from "path";
|
|
774
807
|
|
|
775
808
|
// src/dotenv.ts
|
|
@@ -778,7 +811,9 @@ var EXPORTED = /^\s*export\s/;
|
|
|
778
811
|
var INLINE_COMMENT = /\s+#.*$/;
|
|
779
812
|
var CARRIAGE_RETURN = /\r$/;
|
|
780
813
|
var ESCAPES = { n: `
|
|
781
|
-
`, r: "\r", t: "\t", '"': '"', "\\": "\\" };
|
|
814
|
+
`, r: "\r", t: "\t", '"': '"', "\\": "\\", $: "$" };
|
|
815
|
+
var LINE_BREAK = /[\r\n]/;
|
|
816
|
+
var RAW_ONLY = /["\\]/;
|
|
782
817
|
var NEEDS_QUOTES = /[\s#"'\\$`]/;
|
|
783
818
|
function closingQuote(text, quote) {
|
|
784
819
|
for (let index = 0;index < text.length; index++) {
|
|
@@ -789,6 +824,9 @@ function closingQuote(text, quote) {
|
|
|
789
824
|
}
|
|
790
825
|
return -1;
|
|
791
826
|
}
|
|
827
|
+
function unescapeDollar(text) {
|
|
828
|
+
return text.replaceAll("\\$", "$");
|
|
829
|
+
}
|
|
792
830
|
function unescapeDouble(text) {
|
|
793
831
|
let out = "";
|
|
794
832
|
for (let index = 0;index < text.length; index++) {
|
|
@@ -817,7 +855,7 @@ function parseDotenv(text) {
|
|
|
817
855
|
const start = rest.trimStart();
|
|
818
856
|
const quote = start[0];
|
|
819
857
|
if (quote !== '"' && quote !== "'" && quote !== "`") {
|
|
820
|
-
lines.push({ raw: line, key, value: start.replace(INLINE_COMMENT, "").trim() });
|
|
858
|
+
lines.push({ raw: line, key, value: unescapeDollar(start.replace(INLINE_COMMENT, "").trim()) });
|
|
821
859
|
continue;
|
|
822
860
|
}
|
|
823
861
|
let body = start.slice(1);
|
|
@@ -832,7 +870,7 @@ ${physical[index] ?? ""}`;
|
|
|
832
870
|
end = closingQuote(body, quote);
|
|
833
871
|
}
|
|
834
872
|
const inner = end === -1 ? body : body.slice(0, end);
|
|
835
|
-
lines.push({ raw, key, value: quote === '"' ? unescapeDouble(inner) : inner });
|
|
873
|
+
lines.push({ raw, key, value: quote === '"' ? unescapeDouble(inner) : unescapeDollar(inner) });
|
|
836
874
|
}
|
|
837
875
|
return { lines };
|
|
838
876
|
}
|
|
@@ -851,8 +889,10 @@ function entries(file) {
|
|
|
851
889
|
function formatAssignment(key, value) {
|
|
852
890
|
if (value === "" || !NEEDS_QUOTES.test(value))
|
|
853
891
|
return `${key}=${value}`;
|
|
892
|
+
if (RAW_ONLY.test(value) && !value.includes("'") && !LINE_BREAK.test(value))
|
|
893
|
+
return `${key}='${value.replaceAll("$", "\\$")}'`;
|
|
854
894
|
const escaped = value.replaceAll("\\", "\\\\").replaceAll('"', "\\\"").replaceAll("\r", "\\r").replaceAll(`
|
|
855
|
-
`, "\\n");
|
|
895
|
+
`, "\\n").replaceAll("$", "\\$");
|
|
856
896
|
return `${key}="${escaped}"`;
|
|
857
897
|
}
|
|
858
898
|
function setLine(file, key, value, raw) {
|
|
@@ -882,20 +922,38 @@ function lastLine(file, key) {
|
|
|
882
922
|
}
|
|
883
923
|
|
|
884
924
|
// src/mask.ts
|
|
885
|
-
var MIN_KNOWN_LENGTH =
|
|
886
|
-
var
|
|
887
|
-
var
|
|
925
|
+
var MIN_KNOWN_LENGTH = 4;
|
|
926
|
+
var KEY_MASK = "*** private key redacted ***";
|
|
927
|
+
var PLAIN_VALUE = /^(?:true|false|null|none|nil|yes|no|on|off|undefined)$/i;
|
|
928
|
+
var PLACEHOLDER = /^[$%{<(*/]/;
|
|
929
|
+
var URL_PASSWORD = /(?<![a-z0-9+.-])[a-z][a-z0-9+.-]{0,31}:\/\/[^\s:/@]*:([^\s'"]{1,1024})@/dgi;
|
|
930
|
+
var PORT = /^\d{1,5}$/;
|
|
888
931
|
var ESCAPE2 = /[.*+?^${}()|[\]\\]/g;
|
|
932
|
+
function isPlaceholder(value) {
|
|
933
|
+
return PLACEHOLDER.test(value) || PLAIN_VALUE.test(value);
|
|
934
|
+
}
|
|
889
935
|
var TOKEN_FORMATS = [
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
936
|
+
{ kind: "GitHub token", pattern: /\b(?:gh[pousr]_|github_pat_)\w{20,}/dg },
|
|
937
|
+
{ kind: "GitLab token", pattern: /\bglpat-[\w-]{20,}/dg },
|
|
938
|
+
{ kind: "API key", pattern: /\bsk-[\w-]{20,}/dg },
|
|
939
|
+
{ kind: "Slack token", pattern: /\bx(?:ox[abprs]|app)-[\w-]{10,}/dg },
|
|
940
|
+
{ kind: "JWT", pattern: /\beyJ[\w-]+\.[\w-]+\.[\w-]+/dg },
|
|
941
|
+
{ kind: "AWS access key", pattern: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/dg },
|
|
942
|
+
{ kind: "npm token", pattern: /\bnpm_[A-Za-z0-9]{30,}/dg },
|
|
943
|
+
{ kind: "Stripe key", pattern: /\b[sr]k_(?:live|test)_[A-Za-z0-9]{10,}/dg },
|
|
944
|
+
{ kind: "Google API key", pattern: /\bAIza[\w-]{35}/dg },
|
|
945
|
+
{ kind: "SendGrid key", pattern: /\bSG\.[\w-]{16,}\.[\w-]{16,}/dg },
|
|
946
|
+
{ kind: "Authorization header", pattern: /\bAuthorization["']?\s*[:=]\s*["']?(?:Bearer|Basic|Token|Digest)\s+([^\s"',;]{8,})/dgi, skip: isPlaceholder },
|
|
947
|
+
{ kind: "Bearer token", pattern: /\bBearer\s+((?=[\w.~+/=-]*\d)[\w.~+/=-]{20,})/dg },
|
|
948
|
+
{ kind: "password", pattern: /(?<![\w-])(?:[\w-]{0,30}[_-])?(?:password|passwd|secret|api[_-]?key)(?:[_-][\w-]{0,30})?=([^\s"'&;,]{4,})/dgi, skip: isPlaceholder },
|
|
949
|
+
{ 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
950
|
];
|
|
896
|
-
var
|
|
951
|
+
var PEM_BEGIN = /-----BEGIN [A-Z ]*PRIVATE KEY(?: BLOCK)?-----/;
|
|
952
|
+
var PEM_END = /-----END [A-Z ]*PRIVATE KEY(?: BLOCK)?-----/;
|
|
953
|
+
var PEM_BODY = /(?<=-----BEGIN [A-Z ]*PRIVATE KEY(?: BLOCK)?-----)(?:(?!-----END [A-Z ]*PRIVATE KEY(?: BLOCK)?-----)[\s\S])+/dg;
|
|
954
|
+
var PEM_BEGIN_ALL = /-----BEGIN [A-Z ]*PRIVATE KEY(?: BLOCK)?-----/g;
|
|
955
|
+
var URL_PARTS = /(?<![a-z0-9+.-])[a-z][a-z0-9+.-]{0,31}:\/\/([^\s/?#'"<>`]*)([^\s?#'"<>`]*)(?:\?([^\s#'"<>`]*))?/dgi;
|
|
897
956
|
var PATH_SEGMENT = /[^/]+/g;
|
|
898
|
-
var QUERY_PARAM = /([^&=]+)=([^&]*)/g;
|
|
899
957
|
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
958
|
var WEBHOOK_FORMATS = [
|
|
901
959
|
["Discord webhook", /(?:^|\.)discord(?:app)?\.com\/api\/(?:v\d+\/)?webhooks\/\d+\/([\w-]+)/di],
|
|
@@ -904,28 +962,41 @@ var WEBHOOK_FORMATS = [
|
|
|
904
962
|
["healthchecks.io ping URL", /^hc-ping\.com\/([\w-]+)/di]
|
|
905
963
|
];
|
|
906
964
|
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
965
|
function hint(value) {
|
|
913
966
|
const shown = Math.min(3, Math.floor(value.length / 4));
|
|
914
967
|
return `${value.slice(0, shown)}***(${value.length})`;
|
|
915
968
|
}
|
|
969
|
+
function forms(value) {
|
|
970
|
+
return [...new Set([value, encodeURIComponent(value), JSON.stringify(value).slice(1, -1)])];
|
|
971
|
+
}
|
|
916
972
|
function knownSecrets(pairs) {
|
|
917
973
|
const keys = new Map;
|
|
918
974
|
for (const [key, value] of pairs) {
|
|
919
|
-
if (value.length
|
|
920
|
-
|
|
975
|
+
if (value.length < MIN_KNOWN_LENGTH || PLAIN_VALUE.test(value))
|
|
976
|
+
continue;
|
|
977
|
+
for (const form of forms(value)) {
|
|
978
|
+
if (!keys.has(form))
|
|
979
|
+
keys.set(form, key);
|
|
980
|
+
}
|
|
921
981
|
}
|
|
922
982
|
if (keys.size === 0)
|
|
923
983
|
return { pattern: undefined, keys };
|
|
924
984
|
const alternatives = [...keys.keys()].sort((a, b) => b.length - a.length).map((value) => value.replace(ESCAPE2, "\\$&"));
|
|
925
985
|
return { pattern: new RegExp(`(?<!\\w)(?:${alternatives.join("|")})(?!\\w)`, "g"), keys };
|
|
926
986
|
}
|
|
987
|
+
function passwordSpans(text) {
|
|
988
|
+
const spans = [];
|
|
989
|
+
for (const url of text.matchAll(URL_PASSWORD)) {
|
|
990
|
+
const [start, end] = url.indices?.[1] ?? [];
|
|
991
|
+
const password = url[1] ?? "";
|
|
992
|
+
const slash = password.indexOf("/");
|
|
993
|
+
if (start !== undefined && end !== undefined && !(slash !== -1 && PORT.test(password.slice(0, slash))))
|
|
994
|
+
spans.push([start, end]);
|
|
995
|
+
}
|
|
996
|
+
return spans;
|
|
997
|
+
}
|
|
927
998
|
function urlPasswords(text) {
|
|
928
|
-
return
|
|
999
|
+
return passwordSpans(text).map(([start, end]) => text.slice(start, end));
|
|
929
1000
|
}
|
|
930
1001
|
function urlTokens(line) {
|
|
931
1002
|
const found = [];
|
|
@@ -952,24 +1023,34 @@ function urlTokens(line) {
|
|
|
952
1023
|
}
|
|
953
1024
|
if (query === undefined || queryStart === undefined)
|
|
954
1025
|
continue;
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
1026
|
+
let offset = 0;
|
|
1027
|
+
for (const param of query.split("&")) {
|
|
1028
|
+
const equals = param.indexOf("=");
|
|
1029
|
+
if (equals > 0 && equals < param.length - 1 && SECRET_PARAM.test(param.slice(0, equals)))
|
|
1030
|
+
add("URL token", queryStart + offset + equals + 1, param.slice(equals + 1));
|
|
1031
|
+
offset += param.length + 1;
|
|
959
1032
|
}
|
|
960
1033
|
}
|
|
961
1034
|
return found.filter((match) => match.end > match.start);
|
|
962
1035
|
}
|
|
963
1036
|
function findSecrets(line, known) {
|
|
964
1037
|
const found = [];
|
|
965
|
-
for (const
|
|
966
|
-
|
|
1038
|
+
for (const [start, end] of passwordSpans(line))
|
|
1039
|
+
found.push({ start, end, kind: "URL password", mask: "***" });
|
|
1040
|
+
for (const body of line.matchAll(PEM_BODY)) {
|
|
1041
|
+
const [start, end] = body.indices?.[0] ?? [];
|
|
967
1042
|
if (start !== undefined && end !== undefined)
|
|
968
|
-
found.push({ start, end, kind: "
|
|
1043
|
+
found.push({ start, end, kind: "private key", mask: KEY_MASK });
|
|
969
1044
|
}
|
|
970
|
-
for (const
|
|
971
|
-
for (const token of line.matchAll(pattern))
|
|
972
|
-
|
|
1045
|
+
for (const { kind, pattern, skip } of TOKEN_FORMATS) {
|
|
1046
|
+
for (const token of line.matchAll(pattern)) {
|
|
1047
|
+
const [start, end] = token.indices?.at(-1) ?? [];
|
|
1048
|
+
if (start === undefined || end === undefined)
|
|
1049
|
+
continue;
|
|
1050
|
+
const value = line.slice(start, end);
|
|
1051
|
+
if (!skip?.(value))
|
|
1052
|
+
found.push({ start, end, kind, mask: hint(value) });
|
|
1053
|
+
}
|
|
973
1054
|
}
|
|
974
1055
|
found.push(...urlTokens(line));
|
|
975
1056
|
if (known?.pattern !== undefined) {
|
|
@@ -984,6 +1065,13 @@ function findSecrets(line, known) {
|
|
|
984
1065
|
}
|
|
985
1066
|
return kept;
|
|
986
1067
|
}
|
|
1068
|
+
function openKeyBody(line) {
|
|
1069
|
+
const last = [...line.matchAll(PEM_BEGIN_ALL)].at(-1);
|
|
1070
|
+
if (last === undefined)
|
|
1071
|
+
return;
|
|
1072
|
+
const after = line.slice(last.index + last[0].length);
|
|
1073
|
+
return PEM_END.test(after) ? undefined : after.trim();
|
|
1074
|
+
}
|
|
987
1075
|
function maskLine(line, known) {
|
|
988
1076
|
let out = "";
|
|
989
1077
|
let at = 0;
|
|
@@ -993,18 +1081,38 @@ function maskLine(line, known) {
|
|
|
993
1081
|
}
|
|
994
1082
|
return out + line.slice(at);
|
|
995
1083
|
}
|
|
996
|
-
function maskValue(key, value) {
|
|
1084
|
+
function maskValue(key, value, known) {
|
|
997
1085
|
if (value === "")
|
|
998
1086
|
return value;
|
|
999
|
-
return
|
|
1087
|
+
return isSecretName(key) || PEM_BEGIN.test(value) ? hint(value) : maskLine(value, known);
|
|
1000
1088
|
}
|
|
1001
1089
|
|
|
1002
1090
|
// src/files.ts
|
|
1003
|
-
var ENV_FILE =
|
|
1091
|
+
var ENV_FILE = /^(?:\.env(?:\..+)?|.+\.env|\.envrc)$/;
|
|
1004
1092
|
var TEMPLATE = /\.(?:example|sample|template|dist|defaults)$/;
|
|
1093
|
+
var KNOWN_DEPTH = 3;
|
|
1094
|
+
var SKIPPED = new Set(["node_modules", ".git", "vendor"]);
|
|
1095
|
+
var LOCK_WAIT_MS = 5000;
|
|
1096
|
+
var LOCK_STALE_MS = 30000;
|
|
1097
|
+
async function isFileEntry(entry, path) {
|
|
1098
|
+
if (entry.isFile())
|
|
1099
|
+
return true;
|
|
1100
|
+
if (!entry.isSymbolicLink())
|
|
1101
|
+
return false;
|
|
1102
|
+
try {
|
|
1103
|
+
return (await stat(path)).isFile();
|
|
1104
|
+
} catch {
|
|
1105
|
+
return false;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1005
1108
|
async function envFiles(folder = process.cwd()) {
|
|
1006
1109
|
const found = await readdir(folder, { withFileTypes: true });
|
|
1007
|
-
|
|
1110
|
+
const names = [];
|
|
1111
|
+
for (const entry of found) {
|
|
1112
|
+
if (ENV_FILE.test(entry.name) && await isFileEntry(entry, join(folder, entry.name)))
|
|
1113
|
+
names.push(entry.name);
|
|
1114
|
+
}
|
|
1115
|
+
return names.sort();
|
|
1008
1116
|
}
|
|
1009
1117
|
async function readEnv(path) {
|
|
1010
1118
|
const file = Bun.file(path);
|
|
@@ -1012,46 +1120,125 @@ async function readEnv(path) {
|
|
|
1012
1120
|
fail(`${path} does not exist`);
|
|
1013
1121
|
return parseDotenv(await file.text());
|
|
1014
1122
|
}
|
|
1015
|
-
async function
|
|
1123
|
+
async function writeAtomic(path, text) {
|
|
1016
1124
|
let target = path;
|
|
1017
1125
|
let mode = 384;
|
|
1126
|
+
let owner;
|
|
1018
1127
|
try {
|
|
1019
1128
|
target = await realpath(path);
|
|
1020
|
-
|
|
1021
|
-
|
|
1129
|
+
const current = await stat(target);
|
|
1130
|
+
mode = current.mode & 511;
|
|
1131
|
+
owner = current;
|
|
1132
|
+
} catch (error) {
|
|
1133
|
+
if (error.code !== "ENOENT")
|
|
1134
|
+
throw error;
|
|
1135
|
+
}
|
|
1022
1136
|
const temp = join(dirname(target), `.${basename(target)}.${process.pid}.${crypto.randomUUID()}.tmp`);
|
|
1023
1137
|
try {
|
|
1024
|
-
await writeFile(temp,
|
|
1138
|
+
await writeFile(temp, text, { mode: 384, flag: "wx" });
|
|
1139
|
+
if (owner !== undefined)
|
|
1140
|
+
await keepOwner(temp, owner);
|
|
1025
1141
|
await chmod(temp, mode);
|
|
1026
1142
|
await rename(temp, target);
|
|
1027
1143
|
} finally {
|
|
1028
1144
|
await rm(temp, { force: true });
|
|
1029
1145
|
}
|
|
1030
1146
|
}
|
|
1031
|
-
async function
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1147
|
+
async function keepOwner(path, owner) {
|
|
1148
|
+
try {
|
|
1149
|
+
await chown(path, owner.uid, owner.gid);
|
|
1150
|
+
} catch (error) {
|
|
1151
|
+
if (error.code !== "EPERM")
|
|
1152
|
+
throw error;
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
async function writeEnv(path, file) {
|
|
1156
|
+
await writeAtomic(path, serializeDotenv(file));
|
|
1157
|
+
}
|
|
1158
|
+
async function withLocks(paths, fn) {
|
|
1159
|
+
const held = [];
|
|
1160
|
+
try {
|
|
1161
|
+
for (const path of [...new Set(paths)].sort())
|
|
1162
|
+
held.push(await acquire(path));
|
|
1163
|
+
return await fn();
|
|
1164
|
+
} finally {
|
|
1165
|
+
for (const lock of held)
|
|
1166
|
+
await rm(lock, { force: true });
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
async function acquire(path) {
|
|
1170
|
+
let real = path;
|
|
1171
|
+
try {
|
|
1172
|
+
real = await realpath(path);
|
|
1173
|
+
} catch {}
|
|
1174
|
+
const lock = join(dirname(real), `.${basename(real)}.lock`);
|
|
1175
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
1176
|
+
while (true) {
|
|
1177
|
+
try {
|
|
1178
|
+
await writeFile(lock, String(process.pid), { flag: "wx", mode: 384 });
|
|
1179
|
+
return lock;
|
|
1180
|
+
} catch (error) {
|
|
1181
|
+
if (error.code !== "EEXIST")
|
|
1182
|
+
throw error;
|
|
1039
1183
|
}
|
|
1184
|
+
try {
|
|
1185
|
+
if (Date.now() - (await stat(lock)).mtimeMs > LOCK_STALE_MS)
|
|
1186
|
+
await rm(lock, { force: true });
|
|
1187
|
+
} catch {}
|
|
1188
|
+
if (Date.now() > deadline)
|
|
1189
|
+
fail(`${path} is locked by another ev run (${lock})`, 1);
|
|
1190
|
+
await Bun.sleep(25);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
function secretPairs(file) {
|
|
1194
|
+
const pairs = [];
|
|
1195
|
+
for (const [key, value] of entries(file)) {
|
|
1196
|
+
if (isSecretName(key))
|
|
1197
|
+
pairs.push([key, value]);
|
|
1198
|
+
else
|
|
1199
|
+
pairs.push(...urlPasswords(value).map((password) => [key, password]));
|
|
1200
|
+
}
|
|
1201
|
+
return pairs;
|
|
1202
|
+
}
|
|
1203
|
+
async function envFilesBelow(folder, depth = 0) {
|
|
1204
|
+
const found = [];
|
|
1205
|
+
let children;
|
|
1206
|
+
try {
|
|
1207
|
+
children = await readdir(folder, { withFileTypes: true });
|
|
1208
|
+
} catch {
|
|
1209
|
+
return found;
|
|
1210
|
+
}
|
|
1211
|
+
for (const entry of children) {
|
|
1212
|
+
const path = join(folder, entry.name);
|
|
1213
|
+
if (entry.isDirectory() && depth < KNOWN_DEPTH && !SKIPPED.has(entry.name))
|
|
1214
|
+
found.push(...await envFilesBelow(path, depth + 1));
|
|
1215
|
+
else if (ENV_FILE.test(entry.name) && await isFileEntry(entry, path))
|
|
1216
|
+
found.push(path);
|
|
1217
|
+
}
|
|
1218
|
+
return found;
|
|
1219
|
+
}
|
|
1220
|
+
async function loadKnown(folder = process.cwd(), extra = []) {
|
|
1221
|
+
const pairs = extra.flatMap(secretPairs);
|
|
1222
|
+
for (const path of await envFilesBelow(folder)) {
|
|
1223
|
+
try {
|
|
1224
|
+
pairs.push(...secretPairs(parseDotenv(await Bun.file(path).text())));
|
|
1225
|
+
} catch {}
|
|
1040
1226
|
}
|
|
1041
1227
|
return knownSecrets(pairs);
|
|
1042
1228
|
}
|
|
1043
1229
|
|
|
1044
1230
|
// src/audit.ts
|
|
1045
1231
|
var MAX_DEPTH = 5;
|
|
1046
|
-
var
|
|
1232
|
+
var SKIPPED2 = new Set(["node_modules", ".git", "vendor"]);
|
|
1047
1233
|
var COMPOSE_FILE = /^(?:docker-)?compose[\w.-]*\.ya?ml$/;
|
|
1048
1234
|
var ENTRY = /^\s*(?:-\s*)?["']?([A-Z0-9_]+)["']?\s*[:=]\s*["']?([^"'\s#]+)/;
|
|
1049
|
-
var
|
|
1235
|
+
var SETTING_SUFFIX = /_(?:TTL|FILE|PATH|DIR|NAME|TIMEOUT|EXPIRY|EXPIRES|LIFETIME|LENGTH|ENABLED|ALGORITHM|HEADER|PREFIX|COUNT|MAX|MIN)$/i;
|
|
1236
|
+
var ACL_READER = /^(?:user|group):[^:]+:r/;
|
|
1050
1237
|
var FLAG = /^(?:[01]|yes|no|true|false)$/i;
|
|
1051
1238
|
function isInlineSecret(name, value) {
|
|
1052
1239
|
if (value === "" || value.startsWith("$") || value.startsWith("/run/secrets/") || FLAG.test(value))
|
|
1053
1240
|
return false;
|
|
1054
|
-
return
|
|
1241
|
+
return isSecretName(name) && !SETTING_SUFFIX.test(name) || urlPasswords(value).some((password) => !password.startsWith("$"));
|
|
1055
1242
|
}
|
|
1056
1243
|
function inlineSecrets(compose) {
|
|
1057
1244
|
const names = compose.split(`
|
|
@@ -1070,8 +1257,30 @@ async function openToOthers(path) {
|
|
|
1070
1257
|
}
|
|
1071
1258
|
return true;
|
|
1072
1259
|
}
|
|
1260
|
+
async function sharedWith(path) {
|
|
1261
|
+
const info = await stat2(path);
|
|
1262
|
+
const shared = (info.mode & 32) !== 0 && info.gid !== info.uid && info.gid !== process.getgid?.();
|
|
1263
|
+
if (!shared && (Bun.which("getfacl") === null || !await hasAclReader(path)))
|
|
1264
|
+
return false;
|
|
1265
|
+
for (let dir = dirname2(path);dir !== "/"; dir = dirname2(dir)) {
|
|
1266
|
+
if (((await stat2(dir)).mode & 9) === 0)
|
|
1267
|
+
return false;
|
|
1268
|
+
}
|
|
1269
|
+
return true;
|
|
1270
|
+
}
|
|
1271
|
+
async function hasAclReader(path) {
|
|
1272
|
+
const acl = await $`getfacl --absolute-names --omit-header -- ${path}`.quiet().nothrow();
|
|
1273
|
+
return acl.exitCode === 0 && acl.stdout.toString().split(`
|
|
1274
|
+
`).some((line) => ACL_READER.test(line));
|
|
1275
|
+
}
|
|
1073
1276
|
async function tracked(path) {
|
|
1074
|
-
|
|
1277
|
+
const run = await $`git ls-files --error-unmatch -- ${path}`.cwd(dirname2(path)).quiet().nothrow();
|
|
1278
|
+
const error = run.stderr.toString();
|
|
1279
|
+
if (run.exitCode === 0)
|
|
1280
|
+
return true;
|
|
1281
|
+
if (run.exitCode === 1 || error.includes("not a git repository"))
|
|
1282
|
+
return false;
|
|
1283
|
+
throw new Error(`git could not tell whether ${path} is tracked: ${error.trim() || `exit ${run.exitCode}`}`);
|
|
1075
1284
|
}
|
|
1076
1285
|
async function* candidates2(root, unread, depth = 0) {
|
|
1077
1286
|
let found;
|
|
@@ -1083,9 +1292,9 @@ async function* candidates2(root, unread, depth = 0) {
|
|
|
1083
1292
|
}
|
|
1084
1293
|
for (const entry of found) {
|
|
1085
1294
|
const path = join2(root, entry.name);
|
|
1086
|
-
if (entry.isDirectory() && depth < MAX_DEPTH && !
|
|
1295
|
+
if (entry.isDirectory() && depth < MAX_DEPTH && !SKIPPED2.has(entry.name))
|
|
1087
1296
|
yield* candidates2(path, unread, depth + 1);
|
|
1088
|
-
else if (
|
|
1297
|
+
else if (!TEMPLATE.test(entry.name) && (ENV_FILE.test(entry.name) || COMPOSE_FILE.test(entry.name)) && await isFileEntry(entry, path))
|
|
1089
1298
|
yield path;
|
|
1090
1299
|
}
|
|
1091
1300
|
}
|
|
@@ -1094,24 +1303,26 @@ async function audit(roots) {
|
|
|
1094
1303
|
const unread = [];
|
|
1095
1304
|
for (const root of roots) {
|
|
1096
1305
|
for await (const path of candidates2(root, unread)) {
|
|
1097
|
-
const
|
|
1306
|
+
const others = await openToOthers(path);
|
|
1307
|
+
const readable = others || await sharedWith(path);
|
|
1308
|
+
const group = readable && !others ? true : undefined;
|
|
1098
1309
|
const name = path.slice(path.lastIndexOf("/") + 1);
|
|
1099
1310
|
if (ENV_FILE.test(name)) {
|
|
1100
1311
|
const isTracked = await tracked(path);
|
|
1101
1312
|
if (readable || isTracked)
|
|
1102
|
-
exposed.push({ path, readable, tracked: isTracked });
|
|
1313
|
+
exposed.push({ path, readable, ...group && { group }, tracked: isTracked });
|
|
1103
1314
|
continue;
|
|
1104
1315
|
}
|
|
1105
1316
|
const secrets = readable ? inlineSecrets(await Bun.file(path).text()) : [];
|
|
1106
1317
|
if (secrets.length > 0)
|
|
1107
|
-
exposed.push({ path, readable, tracked: false, secrets });
|
|
1318
|
+
exposed.push({ path, readable, ...group && { group }, tracked: false, secrets });
|
|
1108
1319
|
}
|
|
1109
1320
|
}
|
|
1110
1321
|
return { exposed: exposed.sort((a, b) => a.path.localeCompare(b.path)), unread };
|
|
1111
1322
|
}
|
|
1112
1323
|
|
|
1113
1324
|
// src/fix.ts
|
|
1114
|
-
import { chmod as chmod2, chown, rm as rm2, stat as stat3
|
|
1325
|
+
import { chmod as chmod2, chown as chown2, lstat, rm as rm2, stat as stat3 } from "fs/promises";
|
|
1115
1326
|
import { dirname as dirname3, join as join3 } from "path";
|
|
1116
1327
|
var MAP_ENTRY = /^(\s*)(["']?)([A-Z0-9_]+)\2(\s*:)(.*)$/;
|
|
1117
1328
|
var LIST_ENTRY = /^(\s*-[ \t]*)(["']?)([A-Z0-9_]+)=(.*)$/;
|
|
@@ -1173,6 +1384,15 @@ function envLine(key, value) {
|
|
|
1173
1384
|
return value.includes("'") || value.includes(`
|
|
1174
1385
|
`) ? undefined : `${key}='${value}'`;
|
|
1175
1386
|
}
|
|
1387
|
+
async function isLink(path) {
|
|
1388
|
+
try {
|
|
1389
|
+
return (await lstat(path)).isSymbolicLink();
|
|
1390
|
+
} catch (error) {
|
|
1391
|
+
if (error.code === "ENOENT")
|
|
1392
|
+
return false;
|
|
1393
|
+
throw error;
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1176
1396
|
async function resolved(path) {
|
|
1177
1397
|
const run = Bun.spawn(["docker", "compose", "-f", path, "config", "--format", "json"], { cwd: dirname3(path), stdout: "pipe", stderr: "ignore", stdin: "ignore" });
|
|
1178
1398
|
const [out, code] = await Promise.all([new Response(run.stdout).text(), run.exited]);
|
|
@@ -1183,9 +1403,9 @@ function gainedBy(before, after, key) {
|
|
|
1183
1403
|
const had = new Set(services(before).filter(([, service]) => service.environment?.[key] !== undefined).map(([name]) => name));
|
|
1184
1404
|
return services(after).filter(([name, service]) => service.environment?.[key] !== undefined && !had.has(name)).map(([name]) => name);
|
|
1185
1405
|
}
|
|
1186
|
-
async function shutOthersOut(path) {
|
|
1406
|
+
async function shutOthersOut(path, group = false) {
|
|
1187
1407
|
try {
|
|
1188
|
-
await chmod2(path, (await stat3(path)).mode & 504);
|
|
1408
|
+
await chmod2(path, (await stat3(path)).mode & (group ? 448 : 504));
|
|
1189
1409
|
return true;
|
|
1190
1410
|
} catch (error) {
|
|
1191
1411
|
if (error.code === "EPERM")
|
|
@@ -1193,13 +1413,14 @@ async function shutOthersOut(path) {
|
|
|
1193
1413
|
throw error;
|
|
1194
1414
|
}
|
|
1195
1415
|
}
|
|
1196
|
-
async function fixCompose(path) {
|
|
1416
|
+
async function fixCompose(path, group = false) {
|
|
1197
1417
|
const env = join3(dirname3(path), ".env");
|
|
1198
1418
|
const compose = await Bun.file(path).text();
|
|
1199
1419
|
const envBefore = await Bun.file(env).exists() ? await Bun.file(env).text() : undefined;
|
|
1200
1420
|
const dotenv = parseDotenv(envBefore ?? "");
|
|
1201
1421
|
const known = entries(dotenv);
|
|
1202
1422
|
const fix = { path, env, moved: [], left: [], open: [] };
|
|
1423
|
+
const linked = await isLink(env);
|
|
1203
1424
|
const lines = compose.split(`
|
|
1204
1425
|
`).map((line) => {
|
|
1205
1426
|
const entry = composeEntry(line);
|
|
@@ -1210,6 +1431,10 @@ async function fixCompose(path) {
|
|
|
1210
1431
|
fix.left.push({ key, why: entry.unmovable });
|
|
1211
1432
|
return line;
|
|
1212
1433
|
}
|
|
1434
|
+
if (linked) {
|
|
1435
|
+
fix.left.push({ key, why: "its .env is a symbolic link" });
|
|
1436
|
+
return line;
|
|
1437
|
+
}
|
|
1213
1438
|
if (entry.value.replaceAll("$$", "").includes("$")) {
|
|
1214
1439
|
fix.left.push({ key, why: "its value reads another variable" });
|
|
1215
1440
|
return line;
|
|
@@ -1236,7 +1461,7 @@ async function fixCompose(path) {
|
|
|
1236
1461
|
await move(fix, compose, lines.join(`
|
|
1237
1462
|
`), envBefore, serializeDotenv(dotenv));
|
|
1238
1463
|
for (const file of [path, env]) {
|
|
1239
|
-
if (await Bun.file(file).exists() && !await shutOthersOut(file))
|
|
1464
|
+
if (!(file === env && linked) && await Bun.file(file).exists() && !await shutOthersOut(file, group))
|
|
1240
1465
|
fix.open.push(file);
|
|
1241
1466
|
}
|
|
1242
1467
|
return fix;
|
|
@@ -1246,15 +1471,15 @@ async function move(fix, compose, composeAfter, envBefore, envAfter) {
|
|
|
1246
1471
|
const docker = Bun.which("docker") !== null;
|
|
1247
1472
|
const before = docker ? await resolved(path) : undefined;
|
|
1248
1473
|
if (envBefore === undefined) {
|
|
1249
|
-
await
|
|
1474
|
+
await writeAtomic(env, envAfter);
|
|
1250
1475
|
const { uid, gid } = await stat3(path);
|
|
1251
1476
|
try {
|
|
1252
|
-
await
|
|
1477
|
+
await chown2(env, uid, gid);
|
|
1253
1478
|
} catch {}
|
|
1254
1479
|
} else {
|
|
1255
|
-
await
|
|
1480
|
+
await writeAtomic(env, envAfter);
|
|
1256
1481
|
}
|
|
1257
|
-
await
|
|
1482
|
+
await writeAtomic(path, composeAfter);
|
|
1258
1483
|
if (!docker) {
|
|
1259
1484
|
fix.check = "no docker";
|
|
1260
1485
|
return;
|
|
@@ -1268,11 +1493,11 @@ async function move(fix, compose, composeAfter, envBefore, envAfter) {
|
|
|
1268
1493
|
fix.check = "same";
|
|
1269
1494
|
return;
|
|
1270
1495
|
}
|
|
1271
|
-
await
|
|
1496
|
+
await writeAtomic(path, compose);
|
|
1272
1497
|
if (envBefore === undefined)
|
|
1273
1498
|
await rm2(env);
|
|
1274
1499
|
else
|
|
1275
|
-
await
|
|
1500
|
+
await writeAtomic(env, envBefore);
|
|
1276
1501
|
fix.check = "rolled back";
|
|
1277
1502
|
fix.left.push(...fix.moved.map((key) => {
|
|
1278
1503
|
const gained = after === undefined ? [] : gainedBy(before, after, key);
|
|
@@ -1316,9 +1541,9 @@ function shown(path) {
|
|
|
1316
1541
|
async function fixAll(exposed, json) {
|
|
1317
1542
|
const fixed = [];
|
|
1318
1543
|
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] : []);
|
|
1544
|
+
for (const { path, readable, group, tracked, secrets } of exposed) {
|
|
1545
|
+
const fix = secrets === undefined ? undefined : await fixCompose(path, group === true);
|
|
1546
|
+
const open = fix?.open ?? (readable && !await shutOthersOut(path, group === true) ? [path] : []);
|
|
1322
1547
|
if (fix !== undefined)
|
|
1323
1548
|
fixed.push(fix);
|
|
1324
1549
|
left ||= tracked || open.length > 0 || (fix?.left.length ?? 0) > 0;
|
|
@@ -1328,7 +1553,7 @@ async function fixAll(exposed, json) {
|
|
|
1328
1553
|
...fix === undefined || fix.moved.length === 0 ? [] : [`moved ${fix.moved.join(", ")} into ${shown(fix.env)}`],
|
|
1329
1554
|
...fix?.left.map(({ key, why }) => `left ${key} in place: ${why}`) ?? [],
|
|
1330
1555
|
...fix?.check === undefined ? [] : [CHECKS[fix.check]],
|
|
1331
|
-
...open.map((file) => `another account owns ${file === path ? "it" : shown(file)}, so it stays readable: sudo chmod
|
|
1556
|
+
...open.map((file) => `another account owns ${file === path ? "it" : shown(file)}, so it stays readable: sudo chmod go-rwx ${quoted2(shown(file))}`),
|
|
1332
1557
|
...readable && !open.includes(path) ? ["now unreadable by other accounts"] : [],
|
|
1333
1558
|
...tracked ? [`tracked by git: git rm --cached ${quoted2(shown(path))}, then ignore it`] : []
|
|
1334
1559
|
];
|
|
@@ -1368,11 +1593,11 @@ async function audit2(args) {
|
|
|
1368
1593
|
return 0;
|
|
1369
1594
|
}
|
|
1370
1595
|
const uid = process7.getuid?.();
|
|
1371
|
-
for (const { path, readable, tracked, secrets } of exposed) {
|
|
1596
|
+
for (const { path, readable, group, tracked, secrets } of exposed) {
|
|
1372
1597
|
const sudo = uid !== undefined && uid !== 0 && (await stat4(path)).uid !== uid ? "sudo " : "";
|
|
1373
1598
|
const problems = [
|
|
1374
1599
|
...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))}`] : [],
|
|
1600
|
+
...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
1601
|
...tracked ? [`tracked by git: git rm --cached ${quoted2(shown(path))}, then ignore it`] : []
|
|
1377
1602
|
];
|
|
1378
1603
|
console.log(`${shown(path)}
|
|
@@ -1431,6 +1656,7 @@ async function missing(args) {
|
|
|
1431
1656
|
// src/commands/edit.ts
|
|
1432
1657
|
var DEFAULT_FILE = ".env";
|
|
1433
1658
|
var TRAILING_NEWLINE = /\r?\n$/;
|
|
1659
|
+
var KEY_NAME = /^[A-Z_][\w.-]*$/i;
|
|
1434
1660
|
var setUsage = `ev set KEY [file]
|
|
1435
1661
|
|
|
1436
1662
|
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 +1683,10 @@ async function readValue(from) {
|
|
|
1457
1683
|
fail("the value is empty, nothing was written: --empty sets an empty value");
|
|
1458
1684
|
return input.replace(TRAILING_NEWLINE, "");
|
|
1459
1685
|
}
|
|
1686
|
+
function checkKey(key) {
|
|
1687
|
+
if (!KEY_NAME.test(key))
|
|
1688
|
+
badUsage(`${JSON.stringify(key)} is not a valid key: letters, digits, _, . and - only, not starting with a digit`);
|
|
1689
|
+
}
|
|
1460
1690
|
async function readOrEmpty(path) {
|
|
1461
1691
|
return await Bun.file(path).exists() ? readEnv(path) : { lines: [] };
|
|
1462
1692
|
}
|
|
@@ -1472,11 +1702,15 @@ async function set(args) {
|
|
|
1472
1702
|
${setUsage}`);
|
|
1473
1703
|
if (parsed.values.empty === true && parsed.values.file !== undefined)
|
|
1474
1704
|
fail("--empty and -F exclude each other");
|
|
1705
|
+
checkKey(key);
|
|
1475
1706
|
const value = parsed.values.empty === true ? "" : await readValue(parsed.values.file);
|
|
1476
|
-
const file = await
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1707
|
+
const file = await withLocks([target], async () => {
|
|
1708
|
+
const current = await readOrEmpty(target);
|
|
1709
|
+
setLine(current, key, value);
|
|
1710
|
+
await writeEnv(target, current);
|
|
1711
|
+
return current;
|
|
1712
|
+
});
|
|
1713
|
+
console.log(`${target}: ${key}=${maskValue(key, value, await loadKnown(process.cwd(), [file])).replaceAll(`
|
|
1480
1714
|
`, "\\n")}`);
|
|
1481
1715
|
return 0;
|
|
1482
1716
|
}
|
|
@@ -1487,14 +1721,19 @@ async function unset(args) {
|
|
|
1487
1721
|
fail(`missing KEY
|
|
1488
1722
|
|
|
1489
1723
|
${unsetUsage}`);
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1724
|
+
await withLocks([target], async () => {
|
|
1725
|
+
const file = await readEnv(target);
|
|
1726
|
+
if (removeKey(file, key) === 0)
|
|
1727
|
+
fail(`${key} is not set in ${target}`, 1);
|
|
1728
|
+
await writeEnv(target, file);
|
|
1729
|
+
});
|
|
1494
1730
|
console.log(`${target}: removed ${key}`);
|
|
1495
1731
|
return 0;
|
|
1496
1732
|
}
|
|
1497
1733
|
async function transfer(key, from, to, newKey, replace) {
|
|
1734
|
+
checkKey(key);
|
|
1735
|
+
if (newKey !== undefined)
|
|
1736
|
+
checkKey(newKey);
|
|
1498
1737
|
if (from === to && newKey === undefined)
|
|
1499
1738
|
fail("--from and --to are the same file");
|
|
1500
1739
|
const source = await readEnv(from);
|
|
@@ -1508,7 +1747,7 @@ async function transfer(key, from, to, newKey, replace) {
|
|
|
1508
1747
|
const value = line.value ?? "";
|
|
1509
1748
|
setLine(target, name, value, newKey === undefined && !entries(target).has(name) ? line.raw : undefined);
|
|
1510
1749
|
await writeEnv(to, target);
|
|
1511
|
-
return { source, value, name };
|
|
1750
|
+
return { source, target, value, name };
|
|
1512
1751
|
}
|
|
1513
1752
|
async function mv(args) {
|
|
1514
1753
|
const parsed = parse(args, mvUsage, {
|
|
@@ -1522,9 +1761,11 @@ async function mv(args) {
|
|
|
1522
1761
|
fail(`needs KEY, --from and --to
|
|
1523
1762
|
|
|
1524
1763
|
${mvUsage}`);
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1764
|
+
await withLocks([from, to], async () => {
|
|
1765
|
+
const { source } = await transfer(key, from, to, undefined, force === true);
|
|
1766
|
+
removeKey(source, key);
|
|
1767
|
+
await writeEnv(from, source);
|
|
1768
|
+
});
|
|
1528
1769
|
console.log(`${key}: ${from} -> ${to}`);
|
|
1529
1770
|
return 0;
|
|
1530
1771
|
}
|
|
@@ -1540,17 +1781,261 @@ async function cp(args) {
|
|
|
1540
1781
|
fail(`needs KEY, --from and --to
|
|
1541
1782
|
|
|
1542
1783
|
${cpUsage}`);
|
|
1543
|
-
const { value, name } = await transfer(key, from, to, as, true);
|
|
1544
|
-
|
|
1784
|
+
const { source, target, value, name } = await withLocks([from, to], async () => transfer(key, from, to, as, true));
|
|
1785
|
+
const known = await loadKnown(process.cwd(), [source, target]);
|
|
1786
|
+
console.log(`${to}: ${name}=${maskValue(name, value, known).replaceAll(`
|
|
1545
1787
|
`, "\\n")}`);
|
|
1546
1788
|
return 0;
|
|
1547
1789
|
}
|
|
1548
1790
|
|
|
1791
|
+
// src/commands/pins.ts
|
|
1792
|
+
import { readdir as readdir4, stat as stat5 } from "fs/promises";
|
|
1793
|
+
import { join as join5, resolve as resolve2 } from "path";
|
|
1794
|
+
|
|
1795
|
+
// src/pins.ts
|
|
1796
|
+
import { readdir as readdir3, readFile } from "fs/promises";
|
|
1797
|
+
import { join as join4, relative as relative2 } from "path";
|
|
1798
|
+
var OPTIONAL2 = /^\s*([A-Z][A-Z0-9_]*)\s*:\s*s\.Optional\(/gm;
|
|
1799
|
+
var REACHING = /(?:URL|URI|TOKEN|KEY|SECRET|PASSWORD|DSN|_HOST)(?:_FILE)?$/;
|
|
1800
|
+
var SELF = /(?:^|_)(?:PUBLIC|SITE|BASE|APP)_URL$/;
|
|
1801
|
+
var RENDERS = /\b(?:testSite|proxyKeyTests)\s*\(/;
|
|
1802
|
+
var NOT_NEEDED = /\/\/\s*ev pins:\s*none reached\b/;
|
|
1803
|
+
var OVERRIDE = /\boverrideEnv\s*\(/g;
|
|
1804
|
+
var KEY = /(?:^|[{,])\s*([A-Z_]\w*)\s*:/gi;
|
|
1805
|
+
var TEST_FILE = /\.test\.[cm]?[jt]sx?$/;
|
|
1806
|
+
var ENV_TEST = /^\.env\.test(?:\.local)?$/;
|
|
1807
|
+
var TEST_ROOTS = ["tests", "test", "__tests__", "e2e", "src"];
|
|
1808
|
+
var SKIPPED3 = new Set(["node_modules", ".git", ".kay", "dist", "vendor"]);
|
|
1809
|
+
var SCHEMA_CANDIDATES = ["src/env.ts", "env.ts", "src/env/index.ts"];
|
|
1810
|
+
var OPENERS = new Set(["(", "[", "{"]);
|
|
1811
|
+
var CLOSERS = new Set([")", "]", "}"]);
|
|
1812
|
+
var QUOTES = new Set(["'", '"', "`"]);
|
|
1813
|
+
async function exists(path) {
|
|
1814
|
+
return Bun.file(path).exists();
|
|
1815
|
+
}
|
|
1816
|
+
async function walk(folder, keep) {
|
|
1817
|
+
const found = [];
|
|
1818
|
+
let entries = [];
|
|
1819
|
+
try {
|
|
1820
|
+
entries = await readdir3(folder, { withFileTypes: true });
|
|
1821
|
+
} catch {
|
|
1822
|
+
return found;
|
|
1823
|
+
}
|
|
1824
|
+
for (const entry of entries) {
|
|
1825
|
+
if (SKIPPED3.has(entry.name))
|
|
1826
|
+
continue;
|
|
1827
|
+
const path = join4(folder, entry.name);
|
|
1828
|
+
if (entry.isDirectory())
|
|
1829
|
+
found.push(...await walk(path, keep));
|
|
1830
|
+
else if (entry.isFile() && keep(entry.name))
|
|
1831
|
+
found.push(path);
|
|
1832
|
+
}
|
|
1833
|
+
return found.sort();
|
|
1834
|
+
}
|
|
1835
|
+
async function testFiles(folder) {
|
|
1836
|
+
const found = [];
|
|
1837
|
+
for (const root of TEST_ROOTS) {
|
|
1838
|
+
found.push(...await walk(join4(folder, root), (name) => TEST_FILE.test(name)));
|
|
1839
|
+
}
|
|
1840
|
+
return found;
|
|
1841
|
+
}
|
|
1842
|
+
function balanced(text, open) {
|
|
1843
|
+
let depth = 0;
|
|
1844
|
+
let quote = "";
|
|
1845
|
+
for (let index = open;index < text.length; index++) {
|
|
1846
|
+
const char = text[index] ?? "";
|
|
1847
|
+
if (quote !== "") {
|
|
1848
|
+
if (char === "\\")
|
|
1849
|
+
index++;
|
|
1850
|
+
else if (char === quote)
|
|
1851
|
+
quote = "";
|
|
1852
|
+
} else if (QUOTES.has(char)) {
|
|
1853
|
+
quote = char;
|
|
1854
|
+
} else if (OPENERS.has(char)) {
|
|
1855
|
+
depth++;
|
|
1856
|
+
} else if (CLOSERS.has(char) && --depth === 0) {
|
|
1857
|
+
return text.slice(open + 1, index);
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
return text.slice(open + 1);
|
|
1861
|
+
}
|
|
1862
|
+
function topLevel(text) {
|
|
1863
|
+
let depth = 0;
|
|
1864
|
+
let quote = "";
|
|
1865
|
+
let out = "";
|
|
1866
|
+
for (let index = 0;index < text.length; index++) {
|
|
1867
|
+
const char = text[index] ?? "";
|
|
1868
|
+
if (quote !== "") {
|
|
1869
|
+
if (char === "\\")
|
|
1870
|
+
index++;
|
|
1871
|
+
else if (char === quote)
|
|
1872
|
+
quote = "";
|
|
1873
|
+
} else if (QUOTES.has(char)) {
|
|
1874
|
+
quote = char;
|
|
1875
|
+
} else if (OPENERS.has(char)) {
|
|
1876
|
+
out += depth === 0 ? char : "";
|
|
1877
|
+
depth++;
|
|
1878
|
+
} else if (CLOSERS.has(char)) {
|
|
1879
|
+
depth--;
|
|
1880
|
+
out += depth === 0 ? char : "";
|
|
1881
|
+
} else if (depth <= 1) {
|
|
1882
|
+
out += char;
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
return out;
|
|
1886
|
+
}
|
|
1887
|
+
function overriddenNames(source) {
|
|
1888
|
+
const names = new Set;
|
|
1889
|
+
for (const match of source.matchAll(OVERRIDE)) {
|
|
1890
|
+
const args = topLevel(balanced(source, match.index + match[0].length - 1));
|
|
1891
|
+
const object = args.slice(args.indexOf(",") + 1);
|
|
1892
|
+
for (const key of object.matchAll(KEY))
|
|
1893
|
+
names.add(key[1] ?? "");
|
|
1894
|
+
}
|
|
1895
|
+
return names;
|
|
1896
|
+
}
|
|
1897
|
+
function optionalNames(source) {
|
|
1898
|
+
const start = source.indexOf("defineEnv(");
|
|
1899
|
+
return start === -1 ? [] : [...source.slice(start).matchAll(OPTIONAL2)].map((match) => match[1] ?? "");
|
|
1900
|
+
}
|
|
1901
|
+
function reachesOutside(name) {
|
|
1902
|
+
return REACHING.test(name) && !SELF.test(name);
|
|
1903
|
+
}
|
|
1904
|
+
async function schemaFile(folder) {
|
|
1905
|
+
for (const candidate of SCHEMA_CANDIDATES) {
|
|
1906
|
+
const path = join4(folder, candidate);
|
|
1907
|
+
if (await exists(path) && (await readFile(path, "utf8")).includes("defineEnv("))
|
|
1908
|
+
return path;
|
|
1909
|
+
}
|
|
1910
|
+
return;
|
|
1911
|
+
}
|
|
1912
|
+
async function testEnvKeys(folder) {
|
|
1913
|
+
const keys = new Set;
|
|
1914
|
+
for (const entry of await readdir3(folder, { withFileTypes: true })) {
|
|
1915
|
+
if (!entry.isFile() || !ENV_TEST.test(entry.name))
|
|
1916
|
+
continue;
|
|
1917
|
+
for (const line of parseDotenv(await readFile(join4(folder, entry.name), "utf8")).lines) {
|
|
1918
|
+
if (line.key !== undefined)
|
|
1919
|
+
keys.add(line.key);
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
return keys;
|
|
1923
|
+
}
|
|
1924
|
+
async function pinningPreload(folder) {
|
|
1925
|
+
const path = join4(folder, "bunfig.toml");
|
|
1926
|
+
if (!await exists(path))
|
|
1927
|
+
return;
|
|
1928
|
+
const config = Bun.TOML.parse(await readFile(path, "utf8"));
|
|
1929
|
+
const preloads = [config.test?.preload, config.preload].flat().filter((entry) => typeof entry === "string" && entry.startsWith("."));
|
|
1930
|
+
for (const preload of preloads) {
|
|
1931
|
+
const file = join4(folder, preload);
|
|
1932
|
+
if (!await exists(file))
|
|
1933
|
+
continue;
|
|
1934
|
+
const source = await readFile(file, "utf8");
|
|
1935
|
+
if (source.includes(".env") && source.includes("process.env"))
|
|
1936
|
+
return preload;
|
|
1937
|
+
}
|
|
1938
|
+
return;
|
|
1939
|
+
}
|
|
1940
|
+
async function checkApp(folder, except = new Set) {
|
|
1941
|
+
const schema = await schemaFile(folder);
|
|
1942
|
+
if (schema === undefined)
|
|
1943
|
+
return;
|
|
1944
|
+
const reaching = optionalNames(await readFile(schema, "utf8")).filter((name) => reachesOutside(name) && !except.has(name));
|
|
1945
|
+
const preload = await pinningPreload(folder);
|
|
1946
|
+
const app = { app: folder, schema: relative2(folder, schema), reaching, unpinned: [], files: [], ...preload !== undefined && { preload } };
|
|
1947
|
+
if (preload !== undefined)
|
|
1948
|
+
return app;
|
|
1949
|
+
const fromEnv = await testEnvKeys(folder);
|
|
1950
|
+
const open = reaching.filter((name) => !fromEnv.has(name));
|
|
1951
|
+
const anywhere = new Set;
|
|
1952
|
+
for (const path of await testFiles(folder)) {
|
|
1953
|
+
const source = await readFile(path, "utf8");
|
|
1954
|
+
const pinned = overriddenNames(source);
|
|
1955
|
+
for (const name of pinned)
|
|
1956
|
+
anywhere.add(name);
|
|
1957
|
+
const unpinned = open.filter((name) => !pinned.has(name));
|
|
1958
|
+
if (RENDERS.test(source) && !NOT_NEEDED.test(source) && unpinned.length > 0)
|
|
1959
|
+
app.files.push({ file: relative2(folder, path), unpinned });
|
|
1960
|
+
}
|
|
1961
|
+
app.unpinned = open.filter((name) => !anywhere.has(name));
|
|
1962
|
+
return app;
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
// src/commands/pins.ts
|
|
1966
|
+
var usage2 = `ev pins [dir] [--except NAME,...]
|
|
1967
|
+
|
|
1968
|
+
Checks that an app's tests pin the env that reaches outside.
|
|
1969
|
+
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.
|
|
1970
|
+
A file whose pages reach none of them says so with a \`// ev pins: none reached, <why>\` comment.
|
|
1971
|
+
The dir is an app, or a monorepo holding apps one level down (the current folder by default).
|
|
1972
|
+
Reads variable names only, never a value.
|
|
1973
|
+
Exits 0 when everything is pinned, 1 when something is not, 2 on a usage error.`;
|
|
1974
|
+
var options2 = {
|
|
1975
|
+
json: jsonOption("each app with its reaching, unpinned variables and flagged test files"),
|
|
1976
|
+
except: { type: "string", description: "comma-separated variables to leave out, as ones that only link back to the app" }
|
|
1977
|
+
};
|
|
1978
|
+
async function isFolder2(path) {
|
|
1979
|
+
try {
|
|
1980
|
+
return (await stat5(path)).isDirectory();
|
|
1981
|
+
} catch {
|
|
1982
|
+
return false;
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
async function apps(dir, except) {
|
|
1986
|
+
const own = await checkApp(dir, except);
|
|
1987
|
+
if (own !== undefined)
|
|
1988
|
+
return [own];
|
|
1989
|
+
const found = [];
|
|
1990
|
+
for (const entry of (await readdir4(dir, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
1991
|
+
if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules")
|
|
1992
|
+
continue;
|
|
1993
|
+
const app = await checkApp(join5(dir, entry.name), except);
|
|
1994
|
+
if (app !== undefined)
|
|
1995
|
+
found.push(app);
|
|
1996
|
+
}
|
|
1997
|
+
return found;
|
|
1998
|
+
}
|
|
1999
|
+
function failing(app) {
|
|
2000
|
+
return app.unpinned.length > 0 || app.files.length > 0;
|
|
2001
|
+
}
|
|
2002
|
+
function name(app, root) {
|
|
2003
|
+
return app.app === root ? app.app.split("/").at(-1) ?? app.app : app.app.slice(root.length + 1);
|
|
2004
|
+
}
|
|
2005
|
+
async function pins(args) {
|
|
2006
|
+
const parsed = parse(args, usage2, options2, { positionals: ["[dir]"] });
|
|
2007
|
+
const json = parsed.values.json === true;
|
|
2008
|
+
const root = resolve2(parsed.positionals[0] ?? ".");
|
|
2009
|
+
const except = new Set((parsed.values.except ?? "").split(",").map((entry) => entry.trim()).filter((entry) => entry !== ""));
|
|
2010
|
+
if (!await isFolder2(root))
|
|
2011
|
+
fail(`${root} is not a folder`);
|
|
2012
|
+
const found = await apps(root, except);
|
|
2013
|
+
if (found.length === 0)
|
|
2014
|
+
fail(`no env schema (a defineEnv call in src/env.ts) in ${root} or its folders`);
|
|
2015
|
+
if (json) {
|
|
2016
|
+
printJson(found.map((app) => ({ ...app, app: name(app, root) })));
|
|
2017
|
+
return found.some(failing) ? 1 : 0;
|
|
2018
|
+
}
|
|
2019
|
+
for (const app of found) {
|
|
2020
|
+
const label = name(app, root);
|
|
2021
|
+
if (!failing(app)) {
|
|
2022
|
+
ok(label, app.preload === undefined ? `${app.reaching.length} reaching variables pinned` : `every variable pinned by ${app.preload}`);
|
|
2023
|
+
continue;
|
|
2024
|
+
}
|
|
2025
|
+
console.log(`${label}: ${app.reaching.length} optional variables reach outside`);
|
|
2026
|
+
if (app.unpinned.length > 0)
|
|
2027
|
+
console.log(` unpinned by .env.test or any overrideEnv: ${app.unpinned.join(", ")}`);
|
|
2028
|
+
for (const { file, unpinned } of app.files)
|
|
2029
|
+
console.log(` ${file} renders a page without pinning: ${unpinned.join(", ")}`);
|
|
2030
|
+
}
|
|
2031
|
+
return found.some(failing) ? 1 : 0;
|
|
2032
|
+
}
|
|
2033
|
+
|
|
1549
2034
|
// src/commands/read.ts
|
|
1550
2035
|
var {$: $2 } = globalThis.Bun;
|
|
1551
2036
|
var listUsage = `ev list [file]
|
|
1552
2037
|
|
|
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
|
|
2038
|
+
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
2039
|
|
|
1555
2040
|
Exits 0 when it printed them, 1 when there is no .env* file, 2 on a usage error.`;
|
|
1556
2041
|
var getUsage = `ev get KEY [file]
|
|
@@ -1558,10 +2043,16 @@ var getUsage = `ev get KEY [file]
|
|
|
1558
2043
|
Prints the masked value of one key, from every .env* file that sets it or from one file.
|
|
1559
2044
|
|
|
1560
2045
|
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(`
|
|
2046
|
+
function display(key, value, known) {
|
|
2047
|
+
return maskValue(key, value, known).replaceAll(`
|
|
1563
2048
|
`, "\\n").replaceAll("\r", "\\r");
|
|
1564
2049
|
}
|
|
2050
|
+
async function readAll(names) {
|
|
2051
|
+
const files = [];
|
|
2052
|
+
for (const name of names)
|
|
2053
|
+
files.push([name, await readEnv(name)]);
|
|
2054
|
+
return { files, known: await loadKnown(process.cwd(), files.map(([, file]) => file)) };
|
|
2055
|
+
}
|
|
1565
2056
|
async function list(args) {
|
|
1566
2057
|
const parsed = parse(args, listUsage, {});
|
|
1567
2058
|
const [file] = parsed.positionals;
|
|
@@ -1569,9 +2060,10 @@ async function list(args) {
|
|
|
1569
2060
|
if (names.length === 0)
|
|
1570
2061
|
fail("no .env* file in this folder", 1);
|
|
1571
2062
|
const rows = [];
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
2063
|
+
const { files, known } = await readAll(names);
|
|
2064
|
+
for (const [name, file] of files) {
|
|
2065
|
+
for (const [key, value] of entries(file))
|
|
2066
|
+
rows.push([name, key, display(key, value, known)]);
|
|
1575
2067
|
}
|
|
1576
2068
|
if (rows.length > 0)
|
|
1577
2069
|
console.log(table(rows));
|
|
@@ -1586,10 +2078,11 @@ async function get(args) {
|
|
|
1586
2078
|
${getUsage}`);
|
|
1587
2079
|
const names = file === undefined ? await envFiles() : [file];
|
|
1588
2080
|
const rows = [];
|
|
1589
|
-
|
|
1590
|
-
|
|
2081
|
+
const { files, known } = await readAll(names);
|
|
2082
|
+
for (const [name, file] of files) {
|
|
2083
|
+
const value = entries(file).get(key);
|
|
1591
2084
|
if (value !== undefined)
|
|
1592
|
-
rows.push([name, display(key, value)]);
|
|
2085
|
+
rows.push([name, display(key, value, known)]);
|
|
1593
2086
|
}
|
|
1594
2087
|
if (rows.length === 0)
|
|
1595
2088
|
fail(`${key} is not set in ${file ?? "any .env* file"}`, 1);
|
|
@@ -1609,7 +2102,7 @@ Exits 0 when it printed them, 1 when git cannot show the file, 2 on a usage erro
|
|
|
1609
2102
|
var REVISION = /^([^:]+):(.+)$/;
|
|
1610
2103
|
async function grep(args) {
|
|
1611
2104
|
const parsed = parse(args, grepUsage, { "ignore-case": { type: "boolean", short: "i", description: "match the key whatever its case" } });
|
|
1612
|
-
const [source, ...
|
|
2105
|
+
const [source, ...given] = parsed.positionals;
|
|
1613
2106
|
if (source === undefined)
|
|
1614
2107
|
fail(`missing the regex
|
|
1615
2108
|
|
|
@@ -1620,12 +2113,13 @@ ${grepUsage}`);
|
|
|
1620
2113
|
} catch (error) {
|
|
1621
2114
|
fail(`${source} is no valid regular expression: ${error instanceof Error ? error.message : String(error)}`);
|
|
1622
2115
|
}
|
|
1623
|
-
const names =
|
|
2116
|
+
const names = given.length === 0 ? await envFiles() : given;
|
|
1624
2117
|
const rows = [];
|
|
1625
|
-
|
|
1626
|
-
|
|
2118
|
+
const { files, known } = await readAll(names);
|
|
2119
|
+
for (const [name, file] of files) {
|
|
2120
|
+
for (const [key, value] of entries(file)) {
|
|
1627
2121
|
if (pattern.test(key))
|
|
1628
|
-
rows.push([name, key, display(key, value)]);
|
|
2122
|
+
rows.push([name, key, display(key, value, known)]);
|
|
1629
2123
|
}
|
|
1630
2124
|
}
|
|
1631
2125
|
if (rows.length === 0)
|
|
@@ -1641,10 +2135,14 @@ async function show(args) {
|
|
|
1641
2135
|
fail(`give <rev>:<file>
|
|
1642
2136
|
|
|
1643
2137
|
${showUsage}`);
|
|
1644
|
-
|
|
2138
|
+
if (revision.startsWith("-"))
|
|
2139
|
+
fail(`${revision} is not a revision: it reads as a git option`);
|
|
2140
|
+
const shown = await $2`git show --end-of-options ${`${revision}:${path}`}`.quiet().nothrow();
|
|
1645
2141
|
if (shown.exitCode !== 0)
|
|
1646
2142
|
fail(`git show ${revision}:${path} failed: ${shown.stderr.toString().trim()}`, 1);
|
|
1647
|
-
const
|
|
2143
|
+
const file = parseDotenv(shown.stdout.toString());
|
|
2144
|
+
const known = await loadKnown(process.cwd(), [file]);
|
|
2145
|
+
const rows = [...entries(file)].map(([key, value]) => [key, display(key, value, known)]);
|
|
1648
2146
|
if (rows.length > 0)
|
|
1649
2147
|
console.log(table(rows));
|
|
1650
2148
|
return 0;
|
|
@@ -1668,6 +2166,7 @@ process8.exitCode = await runCli({
|
|
|
1668
2166
|
mv: { run: mv, summary: "move a key from one file to another" },
|
|
1669
2167
|
diff: { run: diff, summary: "keys set in one file and not the other" },
|
|
1670
2168
|
missing: { run: missing, summary: "keys of .env.example that .env lacks" },
|
|
2169
|
+
pins: { run: pins, summary: "the env vars reaching outside that an app's tests leave to a developer's env" },
|
|
1671
2170
|
audit: { run: audit2, summary: ".env files others can read or git tracks, compose files writing secrets out" }
|
|
1672
2171
|
},
|
|
1673
2172
|
aliases: { ls: "list" }
|