@kaynooo/env-tools 0.5.0 → 0.7.0

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