@kaynooo/env-tools 0.6.0 → 0.7.0

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