@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/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.6.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 = 6;
886
- var SECRET_KEY = /TOKEN|SECRET|PASSWORD|KEY|PRIVATE|DSN/i;
887
- var URL_PASSWORD = /\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]*:([^\s@/]+)@/dgi;
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
- ["GitHub token", /\b(?:gh[pousr]_|github_pat_)\w{20,}/g],
891
- ["GitLab token", /\bglpat-[\w-]{20,}/g],
892
- ["API key", /\bsk-[\w-]{20,}/g],
893
- ["Slack token", /\bxox[abprs]-[\w-]{10,}/g],
894
- ["JWT", /\beyJ[\w-]+\.[\w-]+\.[\w-]+/g]
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 URL_PARTS = /\b[a-z][a-z0-9+.-]*:\/\/([^\s/?#'"<>`]*)([^\s?#'"<>`]*)(?:\?([^\s#'"<>`]*))?/dgi;
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 >= MIN_KNOWN_LENGTH && !keys.has(value))
920
- keys.set(value, key);
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 [...text.matchAll(URL_PASSWORD)].flatMap((url) => url[1] ?? []);
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
- for (const param of query.matchAll(QUERY_PARAM)) {
956
- const [name, value] = [param[1] ?? "", param[2] ?? ""];
957
- if (value !== "" && SECRET_PARAM.test(name))
958
- add("URL token", queryStart + param.index + name.length + 1, value);
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 url of line.matchAll(URL_PASSWORD)) {
966
- const [start, end] = url.indices?.[1] ?? [];
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: "URL password", mask: "***" });
1043
+ found.push({ start, end, kind: "private key", mask: KEY_MASK });
969
1044
  }
970
- for (const [kind, pattern] of TOKEN_FORMATS) {
971
- for (const token of line.matchAll(pattern))
972
- found.push({ start: token.index, end: token.index + token[0].length, kind, mask: hint(token[0]) });
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 isSecretKey(key) || PEM_BEGIN.test(value) ? hint(value) : maskLine(value);
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 = /^\.env(?:\..+)?$/;
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
- return found.filter((entry) => entry.isFile() && ENV_FILE.test(entry.name)).map((entry) => entry.name).sort();
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 writeEnv(path, file) {
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
- mode = (await stat(target)).mode & 511;
1021
- } catch {}
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, serializeDotenv(file), { mode: 384, flag: "wx" });
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 loadKnown(folder = process.cwd()) {
1032
- const pairs = [];
1033
- for (const name of await envFiles(folder)) {
1034
- for (const [key, value] of entries(parseDotenv(await Bun.file(`${folder}/${name}`).text()))) {
1035
- if (isSecretKey(key))
1036
- pairs.push([key, value]);
1037
- else
1038
- pairs.push(...urlPasswords(value).map((password) => [key, password]));
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 SKIPPED = new Set(["node_modules", ".git", "vendor"]);
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 SECRET_NAME = /(?:PASSWORD|PASSWD|SECRET|TOKEN|KEY)$/;
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 SECRET_NAME.test(name) || urlPasswords(value).some((password) => !password.startsWith("$"));
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
- return (await $`git ls-files --error-unmatch -- ${path}`.cwd(dirname2(path)).quiet().nothrow()).exitCode === 0;
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 && !SKIPPED.has(entry.name))
1295
+ if (entry.isDirectory() && depth < MAX_DEPTH && !SKIPPED2.has(entry.name))
1087
1296
  yield* candidates2(path, unread, depth + 1);
1088
- else if (entry.isFile() && !TEMPLATE.test(entry.name) && (ENV_FILE.test(entry.name) || COMPOSE_FILE.test(entry.name)))
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 readable = await openToOthers(path);
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, writeFile as writeFile2 } from "fs/promises";
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 writeFile2(env, envAfter, { mode: 384 });
1474
+ await writeAtomic(env, envAfter);
1250
1475
  const { uid, gid } = await stat3(path);
1251
1476
  try {
1252
- await chown(env, uid, gid);
1477
+ await chown2(env, uid, gid);
1253
1478
  } catch {}
1254
1479
  } else {
1255
- await writeFile2(env, envAfter);
1480
+ await writeAtomic(env, envAfter);
1256
1481
  }
1257
- await writeFile2(path, composeAfter);
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 writeFile2(path, compose);
1496
+ await writeAtomic(path, compose);
1272
1497
  if (envBefore === undefined)
1273
1498
  await rm2(env);
1274
1499
  else
1275
- await writeFile2(env, envBefore);
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 o-rwx ${quoted2(shown(file))}`),
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 readOrEmpty(target);
1477
- setLine(file, key, value);
1478
- await writeEnv(target, file);
1479
- console.log(`${target}: ${key}=${maskValue(key, value).replaceAll(`
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
- const file = await readEnv(target);
1491
- if (removeKey(file, key) === 0)
1492
- fail(`${key} is not set in ${target}`, 1);
1493
- await writeEnv(target, file);
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
- const { source } = await transfer(key, from, to, undefined, force === true);
1526
- removeKey(source, key);
1527
- await writeEnv(from, source);
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
- console.log(`${to}: ${name}=${maskValue(name, value).replaceAll(`
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 DSN, and the password and tokens of any URL.
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
- for (const name of names) {
1573
- for (const [key, value] of entries(await readEnv(name)))
1574
- rows.push([name, key, display(key, value)]);
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
- for (const name of names) {
1590
- const value = entries(await readEnv(name)).get(key);
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, ...files] = parsed.positionals;
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 = files.length === 0 ? await envFiles() : files;
2116
+ const names = given.length === 0 ? await envFiles() : given;
1624
2117
  const rows = [];
1625
- for (const name of names) {
1626
- for (const [key, value] of entries(await readEnv(name))) {
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
- const shown = await $2`git show ${`${revision}:${path}`}`.quiet().nothrow();
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 rows = [...entries(parseDotenv(shown.stdout.toString()))].map(([key, value]) => [key, display(key, value)]);
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" }