@kaynooo/env-tools 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,13 +16,14 @@ bun install -g @kaynooo/env-tools
16
16
  | `ev show <rev>:<file>` | an env file as a git revision holds it, values masked |
17
17
  | `ev set KEY [file]` | sets a key in `.env` (or the file), the value read from stdin or `-F <file>` so it never appears in a command line, `--empty` for an empty value |
18
18
  | `ev unset KEY [file]` | removes a key |
19
+ | `ev cp KEY --from <file> --to <file>` | copies a key's value to another file without printing it, replacing the key there or appending it, `--as NEWKEY` to rename |
19
20
  | `ev mv KEY --from <file> --to <file>` | moves a key's line as written, `--force` to replace one the target has |
20
21
  | `ev diff <a> <b>` | names the keys set in one file and not the other, exit 1 when they differ |
21
22
  | `ev missing [file]` | names the keys of `.env.example` (`--against`) that the file lacks, exit 1 when some are |
22
23
  | `ev audit [dir...]` | lists the `.env` files any account can read or git tracks, and the compose files any account can read that write a secret or URL password out, with the fix for each, exit 1 when it finds one |
23
24
  | `ev audit --fix [dir...]` | moves each secret a listed compose file writes out into the `.env` beside it as `${KEY}` (a key the `.env` sets to another value stays put), puts it back when `docker compose config` resolves differently, and shuts other accounts out of both files and of every listed `.env`; exit 1 when something is left |
24
25
 
25
- A value is masked when its key looks like `TOKEN|SECRET|PASSWORD|KEY|PRIVATE|DSN`, when it is a private key, or in the password part of any URL (`postgres://user:***@host/db`) and any known token format. A masked value keeps a few leading characters and its length (`ghp***(40)`) so two values can be told apart. There is no flag that prints a secret unmasked.
26
+ A value is masked when its key looks like `TOKEN|SECRET|PASSWORD|KEY|PRIVATE|DSN`, when it is a private key, or in the password part of any URL (`postgres://user:***@host/db`) and any known token format. A URL is also masked where it carries a token, whatever its key is named: the last segment of a Discord, Slack, Uptime Kuma push or healthchecks.io ping URL, any path segment of 20 or more characters of `A-Za-z0-9_-` mixing lowercase, uppercase and digits, any path segment of 32 or more letters and digits of any case (hex, base32, dashless UUIDs), and the value of a query parameter named `token`, `key`, `apikey`, `secret`, `password`, `sig`, `signature`, `auth` or ending in `_token` or `_secret`; the host and the rest of the URL stay readable. A masked value keeps a few leading characters and its length (`ghp***(40)`) so two values can be told apart. There is no flag that prints a secret unmasked.
26
27
 
27
28
  Files are parsed and written back preserving comments, order, `export KEY=` prefixes, quoting and multiline values; only the lines a command touches change.
28
29
 
@@ -36,7 +37,7 @@ git diff --cached | redact --check
36
37
  `redact` copies stdin to stdout line by line, masking:
37
38
 
38
39
  - known token formats: `ghp_`, `github_pat_`, `glpat-`, `sk-`, `xox?-`, JWTs and the body of PEM private key blocks
39
- - the password of any URL
40
+ - the password and the tokens of any URL (webhook and push URLs, opaque path segments, token query parameters)
40
41
  - the values of secret-looking keys in the `.env*` files of the current folder (whole words only, from 6 characters), `--no-env` skips them
41
42
 
42
43
  `redact --check` prints nothing of the input: it lists `<file>:<line>: <kind>` for each secret found and exits 1. On a diff it reads the added lines only and names the new file's line numbers.
package/dist/ev.js CHANGED
@@ -343,6 +343,26 @@ function printJson(value) {
343
343
  console.log(JSON.stringify(value, undefined, 2));
344
344
  }
345
345
 
346
+ // ../kit/src/text.ts
347
+ var ANSI = new RegExp(`${String.fromCharCode(27)}\\[[\\d;?]*[a-z]`, "gi");
348
+ function distance(a, b) {
349
+ let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
350
+ for (const [i, left] of [...a].entries()) {
351
+ const current = [i + 1];
352
+ for (const [j, right] of [...b].entries())
353
+ current.push(Math.min((previous[j + 1] ?? 0) + 1, (current[j] ?? 0) + 1, (previous[j] ?? 0) + (left === right ? 0 : 1)));
354
+ previous = current;
355
+ }
356
+ return previous[b.length] ?? 0;
357
+ }
358
+ function closest(name, names) {
359
+ const started = names.find((known) => known.startsWith(name));
360
+ if (started !== undefined)
361
+ return started;
362
+ const [best] = names.map((known) => ({ known, far: distance(name, known) })).sort((a, b) => a.far - b.far);
363
+ return best !== undefined && best.far <= (name.length < 4 ? 1 : 2) ? best.known : undefined;
364
+ }
365
+
346
366
  // ../kit/src/args.ts
347
367
  function jsonOption(holds) {
348
368
  return { type: "boolean", description: holds === undefined ? "print JSON" : `print JSON: ${holds}` };
@@ -362,8 +382,8 @@ function fail(message, code = 2, hint) {
362
382
  }
363
383
 
364
384
  class UsageError extends Exit {
365
- constructor(message) {
366
- super(message, 2);
385
+ constructor(message, hint) {
386
+ super(message, 2, hint);
367
387
  }
368
388
  }
369
389
  function badUsage(message) {
@@ -386,6 +406,7 @@ var completion = { active: false };
386
406
  var OPTIONAL = /\[[^\]]+\]/g;
387
407
  var USAGE_PREFIX = /^usage:\s*/i;
388
408
  var FULL_STOP = /\.$/;
409
+ var DASHES = /^-+/;
389
410
  function usageLine(usage, colors = paint(process4.stdout)) {
390
411
  return `${colors.bold("Usage:")} ${usage.replace(USAGE_PREFIX, "").replaceAll(OPTIONAL, (part) => colors.dim(part))}`;
391
412
  }
@@ -422,10 +443,10 @@ function optionRows(options, colors = paint(process4.stdout)) {
422
443
  const rows = Object.entries(options).map(([name, option]) => {
423
444
  const short = option.short === undefined ? " " : `${colors.cyan(`-${option.short}`)}, `;
424
445
  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 };
446
+ return { flag: `${short}${colors.cyan(`--${name}`)}${value}`, width: `-x, --${name}`.length + (option.type === "string" ? " <value>".length : 0), description: option.description ?? "" };
426
447
  });
427
448
  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)}`);
449
+ return rows.map((row) => row.description === "" ? ` ${row.flag}` : ` ${row.flag}${" ".repeat(width - row.width)} ${colors.dim(row.description)}`);
429
450
  }
430
451
  var OUTPUT_OPTIONS = {
431
452
  color: { type: "string", description: "auto (on a terminal alone), always or never" },
@@ -457,7 +478,8 @@ function parse(args, usage, options, settings = {}) {
457
478
  try {
458
479
  parsed = parseArgs({ args: joinDashValues(own, all), options: all, allowPositionals: true, allowNegative: settings.allowNegative === true, strict: true });
459
480
  } catch (error) {
460
- badUsage(parseError(error));
481
+ const message = parseError(error);
482
+ throw new UsageError(message, nearestOption(message, all, settings.allowNegative === true));
461
483
  }
462
484
  const values = parsed.values;
463
485
  applyOutput("color" in output ? values.color : undefined, "plain" in output ? values.plain : undefined);
@@ -500,6 +522,22 @@ function parseError(error) {
500
522
  const message = upTo(error instanceof Error ? error.message : String(error), ". ");
501
523
  return `${message.charAt(0).toLowerCase()}${message.slice(1)}`;
502
524
  }
525
+ var UNKNOWN_OPTION = /^unknown option '(-[^']+)'/;
526
+ function nearestOption(message, options, allowNegative) {
527
+ const typed = UNKNOWN_OPTION.exec(message)?.[1];
528
+ if (typed === undefined)
529
+ return;
530
+ const word = typed.replace(DASHES, "");
531
+ if (word.length < 2 && !typed.startsWith("--"))
532
+ return;
533
+ const known = Object.entries(options).flatMap(([name, option]) => [
534
+ ...option.short === undefined ? [] : [`-${option.short}`],
535
+ `--${name}`,
536
+ ...allowNegative && option.type === "boolean" ? [`--no-${name}`] : []
537
+ ]);
538
+ const meant = closest(word, known.map((flag) => flag.replace(DASHES, "")));
539
+ return meant === undefined ? undefined : `did you mean ${known.find((flag) => flag.replace(DASHES, "") === meant)}?`;
540
+ }
503
541
  async function readInput(path, { noStdin = "nothing is piped on stdin" } = {}) {
504
542
  if (path === "-") {
505
543
  if (process4.stdin.isTTY)
@@ -513,33 +551,19 @@ async function readInput(path, { noStdin = "nothing is piped on stdin" } = {}) {
513
551
  }
514
552
  // ../kit/src/cli.ts
515
553
  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
554
  var LEVEL_OPTIONS = {
532
555
  ...OUTPUT_OPTIONS,
533
556
  help: { type: "boolean", short: "h", description: "show this help" }
534
557
  };
535
558
  var TOP_OPTIONS = { ...LEVEL_OPTIONS, version: { type: "boolean", short: "v", description: "print the version" } };
536
- function commandRows(commands, colors) {
559
+ async function commandRows(commands, colors) {
537
560
  const width = Math.max(...Object.keys(commands).map((name) => name.length));
538
561
  const groups = new Map([["Commands:", []]]);
539
- for (const [name, { summary, group }] of Object.entries(commands)) {
562
+ const summaries = await Promise.all(Object.values(commands).map(async ({ summary }) => resolveSummary(summary)));
563
+ for (const [index, [name, { group }]] of Object.entries(commands).entries()) {
540
564
  const heading = group === undefined ? "Commands:" : `${group}:`;
541
565
  const rows = groups.get(heading) ?? [];
542
- rows.push(` ${colors.cyan(name.padEnd(width))} ${colors.dim(summary)}`);
566
+ rows.push(` ${colors.cyan(name.padEnd(width))} ${colors.dim(summaries[index])}`);
543
567
  groups.set(heading, rows);
544
568
  }
545
569
  return [...groups].filter(([, rows]) => rows.length > 0).flatMap(([heading, rows], index) => [...index === 0 ? [] : [""], colors.bold(heading), ...rows]);
@@ -559,28 +583,38 @@ function takeOutputFlags(argv) {
559
583
  }
560
584
  return argv.slice(index);
561
585
  }
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;
586
+ async function resolveSummary(summary) {
587
+ return typeof summary === "string" ? summary : summary();
568
588
  }
569
- async function help({ path, header, summary, commands, defaultCommand, examples, replaces }, stream) {
589
+ function scopeOf(commands, extra, aliases) {
590
+ return { commands: typeof commands === "function" ? {} : commands, lazy: [...typeof commands === "function" ? [commands] : [], ...extra === undefined ? [] : [extra]], aliases };
591
+ }
592
+ async function expand(scope, words) {
593
+ if (scope.lazy.length === 0)
594
+ return scope;
595
+ const commands = { ...scope.commands };
596
+ for (const added of await Promise.all(scope.lazy.map(async (lazy) => lazy(words)))) {
597
+ for (const [name, command] of Object.entries(added))
598
+ commands[name] ??= command;
599
+ }
600
+ return { ...scope, commands, lazy: [] };
601
+ }
602
+ async function help({ path, header, summary: lazySummary, commands, defaultCommand, examples, replaces }, stream) {
570
603
  const colors = paint(stream);
571
604
  const prefix = path.join(" ");
605
+ const summary = await resolveSummary(lazySummary);
572
606
  if (wantsHelpJson())
573
607
  return JSON.stringify(description(`${prefix} <command> [options]: ${summary}`, { examples, replaces }));
574
608
  const fallback = defaultCommand === undefined ? undefined : commands[defaultCommand];
575
609
  const options = fallback !== undefined && "run" in fallback ? await optionsOf(fallback.run) : undefined;
576
610
  const own = Object.entries(options ?? {}).filter(([name]) => !(name in TOP_OPTIONS));
577
611
  return [
578
- header(colors),
612
+ header(colors, summary),
579
613
  "",
580
614
  usageLine(`${prefix} ${defaultCommand === undefined ? colors.cyan("<command>") : `[${colors.cyan("<command>")}]`} [options]`, colors),
581
615
  ...replacesRows(replaces, colors),
582
616
  "",
583
- ...commandRows(commands, colors),
617
+ ...await commandRows(commands, colors),
584
618
  ...own.length === 0 ? [] : ["", `${colors.bold(`Options of ${defaultCommand}:`)} ${colors.dim("the command run when none is named")}`, ...optionRows(Object.fromEntries(own), colors)],
585
619
  "",
586
620
  colors.bold("Options:"),
@@ -626,11 +660,11 @@ async function dispatch(level, typed, { version, redact }) {
626
660
  }
627
661
  const [name, ...args] = argv;
628
662
  if (name === undefined && level.path.length > 1) {
629
- console.error(await help(level, process5.stderr));
663
+ console.error(await help(await expand(level, [...level.path.slice(1), ...argv]), process5.stderr));
630
664
  return 2;
631
665
  }
632
666
  if (name === undefined || name === "help" || name === "--help" || name === "-h") {
633
- console.log(await help(level, process5.stdout));
667
+ console.log(await help(await expand(level, [...level.path.slice(1), ...argv]), process5.stdout));
634
668
  return 0;
635
669
  }
636
670
  if (level.path.length === 1 && (name === "--version" || name === "-v")) {
@@ -639,6 +673,10 @@ async function dispatch(level, typed, { version, redact }) {
639
673
  }
640
674
  let command = level.commands[level.aliases?.[name] ?? name];
641
675
  let rest = args;
676
+ if (command === undefined && level.lazy.length > 0) {
677
+ level = await expand(level, [...level.path.slice(1), ...argv]);
678
+ command = level.commands[level.aliases?.[name] ?? name];
679
+ }
642
680
  if (command === undefined && level.defaultCommand !== undefined) {
643
681
  command = level.commands[level.defaultCommand];
644
682
  rest = argv;
@@ -650,7 +688,7 @@ async function dispatch(level, typed, { version, redact }) {
650
688
  }
651
689
  if ("commands" in command) {
652
690
  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 });
691
+ 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
692
  }
655
693
  try {
656
694
  return await command.run(rest);
@@ -675,18 +713,24 @@ async function optionsOf(run) {
675
713
  async function leafCandidates(run, words) {
676
714
  if (!(words.at(-1) ?? "").startsWith("-"))
677
715
  return [FILES];
678
- return Object.entries(await optionsOf(run)).map(([name, option]) => `--${name} ${option.description}`);
716
+ return Object.entries(await optionsOf(run)).map(([name, option]) => option.description === undefined ? `--${name}` : `--${name} ${option.description}`);
679
717
  }
680
- async function candidates(commands, aliases, defaultCommand, words) {
718
+ async function candidates(scope, defaultCommand, words, before = []) {
681
719
  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}`);
720
+ const listing = rest.length === 0 && !word.startsWith("-");
721
+ let { commands, aliases } = scope;
722
+ let command = commands[aliases?.[word] ?? word];
723
+ if (listing || command === undefined) {
724
+ ({ commands, aliases } = await expand(scope, [...before, ...words]));
725
+ command = commands[aliases?.[word] ?? word];
726
+ }
727
+ if (listing)
728
+ return Promise.all(Object.entries(commands).map(async ([name, { summary }]) => `${name} ${await resolveSummary(summary)}`));
685
729
  if (command === undefined) {
686
730
  const fallback = defaultCommand === undefined ? undefined : commands[defaultCommand];
687
731
  return fallback !== undefined && "run" in fallback ? leafCandidates(fallback.run, words) : [];
688
732
  }
689
- return "commands" in command ? candidates(command.commands, command.aliases, undefined, rest) : leafCandidates(command.run, rest);
733
+ return "commands" in command ? candidates(scopeOf(command.commands, command.extraCommands, command.aliases), undefined, rest, [...before, word]) : leafCandidates(command.run, rest);
690
734
  }
691
735
  async function printCandidates(found) {
692
736
  for (const line of await found)
@@ -695,8 +739,8 @@ async function printCandidates(found) {
695
739
  }
696
740
  async function runCli(cli, argv = process5.argv.slice(2)) {
697
741
  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 });
742
+ return printCandidates(candidates(scopeOf(cli.commands, cli.extraCommands, cli.aliases), cli.defaultCommand, argv.slice(1)));
743
+ 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
744
  }
701
745
  async function runMain({ name, version, run, redact }, argv = process5.argv.slice(2)) {
702
746
  if (argv[0] === COMPLETE)
@@ -712,20 +756,21 @@ async function runMain({ name, version, run, redact }, argv = process5.argv.slic
712
756
  }
713
757
  }
714
758
  // package.json
715
- var version = "0.5.0";
759
+ var version = "0.6.0";
716
760
 
717
761
  // src/commands/audit.ts
718
- import { stat as stat3 } from "fs/promises";
762
+ import { stat as stat4 } from "fs/promises";
719
763
  import { relative, resolve } from "path";
720
764
  import process7 from "process";
721
765
 
722
766
  // src/audit.ts
723
- import { readdir as readdir2, stat } from "fs/promises";
724
- import { dirname, join } from "path";
767
+ import { readdir as readdir2, stat as stat2 } from "fs/promises";
768
+ import { dirname as dirname2, join as join2 } from "path";
725
769
  var {$ } = globalThis.Bun;
726
770
 
727
771
  // src/files.ts
728
- import { readdir } from "fs/promises";
772
+ import { chmod, readdir, realpath, rename, rm, stat, writeFile } from "fs/promises";
773
+ import { basename, dirname, join } from "path";
729
774
 
730
775
  // src/dotenv.ts
731
776
  var ASSIGNMENT = /^\s*(?:export\s+)?([A-Z_][\w.-]*)\s*=(.*)$/i;
@@ -848,6 +893,17 @@ var TOKEN_FORMATS = [
848
893
  ["Slack token", /\bxox[abprs]-[\w-]{10,}/g],
849
894
  ["JWT", /\beyJ[\w-]+\.[\w-]+\.[\w-]+/g]
850
895
  ];
896
+ var URL_PARTS = /\b[a-z][a-z0-9+.-]*:\/\/([^\s/?#'"<>`]*)([^\s?#'"<>`]*)(?:\?([^\s#'"<>`]*))?/dgi;
897
+ var PATH_SEGMENT = /[^/]+/g;
898
+ var QUERY_PARAM = /([^&=]+)=([^&]*)/g;
899
+ 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
+ var WEBHOOK_FORMATS = [
901
+ ["Discord webhook", /(?:^|\.)discord(?:app)?\.com\/api\/(?:v\d+\/)?webhooks\/\d+\/([\w-]+)/di],
902
+ ["Slack webhook", /^hooks\.slack\.com\/(?:services|workflows)\/(?:[\w-]+\/)*([\w-]+)/di],
903
+ ["Uptime Kuma push URL", /\/api\/push\/([\w-]+)/di],
904
+ ["healthchecks.io ping URL", /^hc-ping\.com\/([\w-]+)/di]
905
+ ];
906
+ var OPAQUE_SEGMENT = /^(?:(?=[\w-]{20,}$)(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[\w-]+|(?=[A-Za-z\d]{32,}$)(?=.*[A-Za-z])(?=.*\d).+)$/;
851
907
  var PEM_BEGIN = /-----BEGIN [A-Z ]*PRIVATE KEY-----/;
852
908
  var PEM_END = /-----END [A-Z ]*PRIVATE KEY-----/;
853
909
  function isSecretKey(key) {
@@ -871,6 +927,39 @@ function knownSecrets(pairs) {
871
927
  function urlPasswords(text) {
872
928
  return [...text.matchAll(URL_PASSWORD)].flatMap((url) => url[1] ?? []);
873
929
  }
930
+ function urlTokens(line) {
931
+ const found = [];
932
+ const add = (kind, start, value) => {
933
+ found.push({ start, end: start + value.length, kind, mask: hint(value) });
934
+ };
935
+ for (const url of line.matchAll(URL_PARTS)) {
936
+ const [authority, path = "", query] = [url[1] ?? "", url[2] ?? "", url[3]];
937
+ const authorityStart = url.indices?.[1]?.[0];
938
+ const pathStart = url.indices?.[2]?.[0];
939
+ const queryStart = url.indices?.[3]?.[0];
940
+ if (authorityStart === undefined || pathStart === undefined)
941
+ continue;
942
+ const hostAt = authority.lastIndexOf("@") + 1;
943
+ const target = authority.slice(hostAt) + path;
944
+ for (const [kind, pattern] of WEBHOOK_FORMATS) {
945
+ const [start, end] = pattern.exec(target)?.indices?.[1] ?? [];
946
+ if (start !== undefined && end !== undefined)
947
+ add(kind, authorityStart + hostAt + start, target.slice(start, end));
948
+ }
949
+ for (const segment of path.matchAll(PATH_SEGMENT)) {
950
+ if (OPAQUE_SEGMENT.test(segment[0]))
951
+ add("URL token", pathStart + segment.index, segment[0]);
952
+ }
953
+ if (query === undefined || queryStart === undefined)
954
+ 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);
959
+ }
960
+ }
961
+ return found.filter((match) => match.end > match.start);
962
+ }
874
963
  function findSecrets(line, known) {
875
964
  const found = [];
876
965
  for (const url of line.matchAll(URL_PASSWORD)) {
@@ -882,6 +971,7 @@ function findSecrets(line, known) {
882
971
  for (const token of line.matchAll(pattern))
883
972
  found.push({ start: token.index, end: token.index + token[0].length, kind, mask: hint(token[0]) });
884
973
  }
974
+ found.push(...urlTokens(line));
885
975
  if (known?.pattern !== undefined) {
886
976
  for (const value of line.matchAll(known.pattern))
887
977
  found.push({ start: value.index, end: value.index + value[0].length, kind: `value of ${known.keys.get(value[0])}`, mask: hint(value[0]) });
@@ -923,7 +1013,20 @@ async function readEnv(path) {
923
1013
  return parseDotenv(await file.text());
924
1014
  }
925
1015
  async function writeEnv(path, file) {
926
- await Bun.write(path, serializeDotenv(file));
1016
+ let target = path;
1017
+ let mode = 384;
1018
+ try {
1019
+ target = await realpath(path);
1020
+ mode = (await stat(target)).mode & 511;
1021
+ } catch {}
1022
+ const temp = join(dirname(target), `.${basename(target)}.${process.pid}.${crypto.randomUUID()}.tmp`);
1023
+ try {
1024
+ await writeFile(temp, serializeDotenv(file), { mode: 384, flag: "wx" });
1025
+ await chmod(temp, mode);
1026
+ await rename(temp, target);
1027
+ } finally {
1028
+ await rm(temp, { force: true });
1029
+ }
927
1030
  }
928
1031
  async function loadKnown(folder = process.cwd()) {
929
1032
  const pairs = [];
@@ -959,16 +1062,16 @@ function inlineSecrets(compose) {
959
1062
  return [...new Set(names)];
960
1063
  }
961
1064
  async function openToOthers(path) {
962
- if (((await stat(path)).mode & 4) === 0)
1065
+ if (((await stat2(path)).mode & 4) === 0)
963
1066
  return false;
964
- for (let dir = dirname(path);dir !== "/"; dir = dirname(dir)) {
965
- if (((await stat(dir)).mode & 1) === 0)
1067
+ for (let dir = dirname2(path);dir !== "/"; dir = dirname2(dir)) {
1068
+ if (((await stat2(dir)).mode & 1) === 0)
966
1069
  return false;
967
1070
  }
968
1071
  return true;
969
1072
  }
970
1073
  async function tracked(path) {
971
- return (await $`git ls-files --error-unmatch -- ${path}`.cwd(dirname(path)).quiet().nothrow()).exitCode === 0;
1074
+ return (await $`git ls-files --error-unmatch -- ${path}`.cwd(dirname2(path)).quiet().nothrow()).exitCode === 0;
972
1075
  }
973
1076
  async function* candidates2(root, unread, depth = 0) {
974
1077
  let found;
@@ -979,7 +1082,7 @@ async function* candidates2(root, unread, depth = 0) {
979
1082
  return;
980
1083
  }
981
1084
  for (const entry of found) {
982
- const path = join(root, entry.name);
1085
+ const path = join2(root, entry.name);
983
1086
  if (entry.isDirectory() && depth < MAX_DEPTH && !SKIPPED.has(entry.name))
984
1087
  yield* candidates2(path, unread, depth + 1);
985
1088
  else if (entry.isFile() && !TEMPLATE.test(entry.name) && (ENV_FILE.test(entry.name) || COMPOSE_FILE.test(entry.name)))
@@ -1008,8 +1111,8 @@ async function audit(roots) {
1008
1111
  }
1009
1112
 
1010
1113
  // 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";
1114
+ import { chmod as chmod2, chown, rm as rm2, stat as stat3, writeFile as writeFile2 } from "fs/promises";
1115
+ import { dirname as dirname3, join as join3 } from "path";
1013
1116
  var MAP_ENTRY = /^(\s*)(["']?)([A-Z0-9_]+)\2(\s*:)(.*)$/;
1014
1117
  var LIST_ENTRY = /^(\s*-[ \t]*)(["']?)([A-Z0-9_]+)=(.*)$/;
1015
1118
  var TRAILING = /^\s*(?:#.*)?$/;
@@ -1071,7 +1174,7 @@ function envLine(key, value) {
1071
1174
  `) ? undefined : `${key}='${value}'`;
1072
1175
  }
1073
1176
  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" });
1177
+ const run = Bun.spawn(["docker", "compose", "-f", path, "config", "--format", "json"], { cwd: dirname3(path), stdout: "pipe", stderr: "ignore", stdin: "ignore" });
1075
1178
  const [out, code] = await Promise.all([new Response(run.stdout).text(), run.exited]);
1076
1179
  return code === 0 ? out : undefined;
1077
1180
  }
@@ -1082,7 +1185,7 @@ function gainedBy(before, after, key) {
1082
1185
  }
1083
1186
  async function shutOthersOut(path) {
1084
1187
  try {
1085
- await chmod(path, (await stat2(path)).mode & 504);
1188
+ await chmod2(path, (await stat3(path)).mode & 504);
1086
1189
  return true;
1087
1190
  } catch (error) {
1088
1191
  if (error.code === "EPERM")
@@ -1091,7 +1194,7 @@ async function shutOthersOut(path) {
1091
1194
  }
1092
1195
  }
1093
1196
  async function fixCompose(path) {
1094
- const env = join2(dirname2(path), ".env");
1197
+ const env = join3(dirname3(path), ".env");
1095
1198
  const compose = await Bun.file(path).text();
1096
1199
  const envBefore = await Bun.file(env).exists() ? await Bun.file(env).text() : undefined;
1097
1200
  const dotenv = parseDotenv(envBefore ?? "");
@@ -1143,15 +1246,15 @@ async function move(fix, compose, composeAfter, envBefore, envAfter) {
1143
1246
  const docker = Bun.which("docker") !== null;
1144
1247
  const before = docker ? await resolved(path) : undefined;
1145
1248
  if (envBefore === undefined) {
1146
- await writeFile(env, envAfter, { mode: 384 });
1147
- const { uid, gid } = await stat2(path);
1249
+ await writeFile2(env, envAfter, { mode: 384 });
1250
+ const { uid, gid } = await stat3(path);
1148
1251
  try {
1149
1252
  await chown(env, uid, gid);
1150
1253
  } catch {}
1151
1254
  } else {
1152
- await writeFile(env, envAfter);
1255
+ await writeFile2(env, envAfter);
1153
1256
  }
1154
- await writeFile(path, composeAfter);
1257
+ await writeFile2(path, composeAfter);
1155
1258
  if (!docker) {
1156
1259
  fix.check = "no docker";
1157
1260
  return;
@@ -1165,11 +1268,11 @@ async function move(fix, compose, composeAfter, envBefore, envAfter) {
1165
1268
  fix.check = "same";
1166
1269
  return;
1167
1270
  }
1168
- await writeFile(path, compose);
1271
+ await writeFile2(path, compose);
1169
1272
  if (envBefore === undefined)
1170
- await rm(env);
1273
+ await rm2(env);
1171
1274
  else
1172
- await writeFile(env, envBefore);
1275
+ await writeFile2(env, envBefore);
1173
1276
  fix.check = "rolled back";
1174
1277
  fix.left.push(...fix.moved.map((key) => {
1175
1278
  const gained = after === undefined ? [] : gainedBy(before, after, key);
@@ -1199,7 +1302,7 @@ var CHECKS = {
1199
1302
  };
1200
1303
  async function isFolder(path) {
1201
1304
  try {
1202
- return (await stat3(path)).isDirectory();
1305
+ return (await stat4(path)).isDirectory();
1203
1306
  } catch {
1204
1307
  return false;
1205
1308
  }
@@ -1266,7 +1369,7 @@ async function audit2(args) {
1266
1369
  }
1267
1370
  const uid = process7.getuid?.();
1268
1371
  for (const { path, readable, tracked, secrets } of exposed) {
1269
- const sudo = uid !== undefined && uid !== 0 && (await stat3(path)).uid !== uid ? "sudo " : "";
1372
+ const sudo = uid !== undefined && uid !== 0 && (await stat4(path)).uid !== uid ? "sudo " : "";
1270
1373
  const problems = [
1271
1374
  ...secrets === undefined ? [] : [`writes out ${secrets.join(", ")}: ev audit --fix moves the values into a .env beside it`],
1272
1375
  ...readable ? [`readable by any account: ${sudo}chmod o-rwx ${quoted2(shown(path))}`] : [],
@@ -1343,6 +1446,11 @@ var mvUsage = `ev mv KEY --from <file> --to <file>
1343
1446
  Moves KEY's line from one file to another, as written. Refuses to replace a key the target already sets unless --force.
1344
1447
 
1345
1448
  Exits 0 when it moved the key, 1 when the source lacks it or the target already sets it, 2 on a usage error.`;
1449
+ var cpUsage = `ev cp KEY --from <file> --to <file> [--as NEWKEY]
1450
+
1451
+ 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.
1452
+
1453
+ Exits 0 when it copied the key, 1 when the source lacks it, 2 on a usage error.`;
1346
1454
  async function readValue(from) {
1347
1455
  const input = await readInput(from ?? "-", { noStdin: "pipe the value on stdin or give -F <file>, it is never taken from the command line" });
1348
1456
  if (input === "")
@@ -1386,6 +1494,22 @@ ${unsetUsage}`);
1386
1494
  console.log(`${target}: removed ${key}`);
1387
1495
  return 0;
1388
1496
  }
1497
+ async function transfer(key, from, to, newKey, replace) {
1498
+ if (from === to && newKey === undefined)
1499
+ fail("--from and --to are the same file");
1500
+ const source = await readEnv(from);
1501
+ const line = lastLine(source, key);
1502
+ if (line === undefined)
1503
+ fail(`${key} is not set in ${from}`, 1);
1504
+ const target = from === to ? source : await readOrEmpty(to);
1505
+ const name = newKey ?? key;
1506
+ if (!replace && entries(target).has(name))
1507
+ fail(`${to} already sets ${name}, --force replaces it`, 1);
1508
+ const value = line.value ?? "";
1509
+ setLine(target, name, value, newKey === undefined && !entries(target).has(name) ? line.raw : undefined);
1510
+ await writeEnv(to, target);
1511
+ return { source, value, name };
1512
+ }
1389
1513
  async function mv(args) {
1390
1514
  const parsed = parse(args, mvUsage, {
1391
1515
  from: { type: "string", description: "the file to take KEY from" },
@@ -1398,28 +1522,35 @@ async function mv(args) {
1398
1522
  fail(`needs KEY, --from and --to
1399
1523
 
1400
1524
  ${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);
1525
+ const { source } = await transfer(key, from, to, undefined, force === true);
1412
1526
  removeKey(source, key);
1413
1527
  await writeEnv(from, source);
1414
1528
  console.log(`${key}: ${from} -> ${to}`);
1415
1529
  return 0;
1416
1530
  }
1531
+ async function cp(args) {
1532
+ const parsed = parse(args, cpUsage, {
1533
+ from: { type: "string", description: "the file to take KEY from" },
1534
+ to: { type: "string", description: "the file to put the copy in, created when missing" },
1535
+ as: { type: "string", description: "store the copy under this key instead" }
1536
+ });
1537
+ const [key] = parsed.positionals;
1538
+ const { from, to, as } = parsed.values;
1539
+ if (key === undefined || from === undefined || to === undefined)
1540
+ fail(`needs KEY, --from and --to
1541
+
1542
+ ${cpUsage}`);
1543
+ const { value, name } = await transfer(key, from, to, as, true);
1544
+ console.log(`${to}: ${name}=${maskValue(name, value).replaceAll(`
1545
+ `, "\\n")}`);
1546
+ return 0;
1547
+ }
1417
1548
 
1418
1549
  // src/commands/read.ts
1419
1550
  var {$: $2 } = globalThis.Bun;
1420
1551
  var listUsage = `ev list [file]
1421
1552
 
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.
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.
1423
1554
 
1424
1555
  Exits 0 when it printed them, 1 when there is no .env* file, 2 on a usage error.`;
1425
1556
  var getUsage = `ev get KEY [file]
@@ -1533,6 +1664,7 @@ process8.exitCode = await runCli({
1533
1664
  show: { run: show, summary: "an env file at a git revision, values masked" },
1534
1665
  set: { run: set, summary: "set a key, the value read from stdin or -F" },
1535
1666
  unset: { run: unset, summary: "remove a key from a file" },
1667
+ cp: { run: cp, summary: "copy a key from one file to another, the value never printed" },
1536
1668
  mv: { run: mv, summary: "move a key from one file to another" },
1537
1669
  diff: { run: diff, summary: "keys set in one file and not the other" },
1538
1670
  missing: { run: missing, summary: "keys of .env.example that .env lacks" },
package/dist/redact.js CHANGED
@@ -343,6 +343,26 @@ function printJson(value) {
343
343
  console.log(JSON.stringify(value, undefined, 2));
344
344
  }
345
345
 
346
+ // ../kit/src/text.ts
347
+ var ANSI = new RegExp(`${String.fromCharCode(27)}\\[[\\d;?]*[a-z]`, "gi");
348
+ function distance(a, b) {
349
+ let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
350
+ for (const [i, left] of [...a].entries()) {
351
+ const current = [i + 1];
352
+ for (const [j, right] of [...b].entries())
353
+ current.push(Math.min((previous[j + 1] ?? 0) + 1, (current[j] ?? 0) + 1, (previous[j] ?? 0) + (left === right ? 0 : 1)));
354
+ previous = current;
355
+ }
356
+ return previous[b.length] ?? 0;
357
+ }
358
+ function closest(name, names) {
359
+ const started = names.find((known) => known.startsWith(name));
360
+ if (started !== undefined)
361
+ return started;
362
+ const [best] = names.map((known) => ({ known, far: distance(name, known) })).sort((a, b) => a.far - b.far);
363
+ return best !== undefined && best.far <= (name.length < 4 ? 1 : 2) ? best.known : undefined;
364
+ }
365
+
346
366
  // ../kit/src/args.ts
347
367
  function jsonOption(holds) {
348
368
  return { type: "boolean", description: holds === undefined ? "print JSON" : `print JSON: ${holds}` };
@@ -362,8 +382,8 @@ function fail(message, code = 2, hint) {
362
382
  }
363
383
 
364
384
  class UsageError extends Exit {
365
- constructor(message) {
366
- super(message, 2);
385
+ constructor(message, hint) {
386
+ super(message, 2, hint);
367
387
  }
368
388
  }
369
389
  function badUsage(message) {
@@ -386,6 +406,7 @@ var completion = { active: false };
386
406
  var OPTIONAL = /\[[^\]]+\]/g;
387
407
  var USAGE_PREFIX = /^usage:\s*/i;
388
408
  var FULL_STOP = /\.$/;
409
+ var DASHES = /^-+/;
389
410
  function usageLine(usage, colors = paint(process4.stdout)) {
390
411
  return `${colors.bold("Usage:")} ${usage.replace(USAGE_PREFIX, "").replaceAll(OPTIONAL, (part) => colors.dim(part))}`;
391
412
  }
@@ -422,10 +443,10 @@ function optionRows(options, colors = paint(process4.stdout)) {
422
443
  const rows = Object.entries(options).map(([name, option]) => {
423
444
  const short = option.short === undefined ? " " : `${colors.cyan(`-${option.short}`)}, `;
424
445
  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 };
446
+ return { flag: `${short}${colors.cyan(`--${name}`)}${value}`, width: `-x, --${name}`.length + (option.type === "string" ? " <value>".length : 0), description: option.description ?? "" };
426
447
  });
427
448
  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)}`);
449
+ return rows.map((row) => row.description === "" ? ` ${row.flag}` : ` ${row.flag}${" ".repeat(width - row.width)} ${colors.dim(row.description)}`);
429
450
  }
430
451
  var OUTPUT_OPTIONS = {
431
452
  color: { type: "string", description: "auto (on a terminal alone), always or never" },
@@ -457,7 +478,8 @@ function parse(args, usage, options, settings = {}) {
457
478
  try {
458
479
  parsed = parseArgs({ args: joinDashValues(own, all), options: all, allowPositionals: true, allowNegative: settings.allowNegative === true, strict: true });
459
480
  } catch (error) {
460
- badUsage(parseError(error));
481
+ const message = parseError(error);
482
+ throw new UsageError(message, nearestOption(message, all, settings.allowNegative === true));
461
483
  }
462
484
  const values = parsed.values;
463
485
  applyOutput("color" in output ? values.color : undefined, "plain" in output ? values.plain : undefined);
@@ -500,6 +522,22 @@ function parseError(error) {
500
522
  const message = upTo(error instanceof Error ? error.message : String(error), ". ");
501
523
  return `${message.charAt(0).toLowerCase()}${message.slice(1)}`;
502
524
  }
525
+ var UNKNOWN_OPTION = /^unknown option '(-[^']+)'/;
526
+ function nearestOption(message, options, allowNegative) {
527
+ const typed = UNKNOWN_OPTION.exec(message)?.[1];
528
+ if (typed === undefined)
529
+ return;
530
+ const word = typed.replace(DASHES, "");
531
+ if (word.length < 2 && !typed.startsWith("--"))
532
+ return;
533
+ const known = Object.entries(options).flatMap(([name, option]) => [
534
+ ...option.short === undefined ? [] : [`-${option.short}`],
535
+ `--${name}`,
536
+ ...allowNegative && option.type === "boolean" ? [`--no-${name}`] : []
537
+ ]);
538
+ const meant = closest(word, known.map((flag) => flag.replace(DASHES, "")));
539
+ return meant === undefined ? undefined : `did you mean ${known.find((flag) => flag.replace(DASHES, "") === meant)}?`;
540
+ }
503
541
  async function readInput(path, { noStdin = "nothing is piped on stdin" } = {}) {
504
542
  if (path === "-") {
505
543
  if (process4.stdin.isTTY)
@@ -513,33 +551,19 @@ async function readInput(path, { noStdin = "nothing is piped on stdin" } = {}) {
513
551
  }
514
552
  // ../kit/src/cli.ts
515
553
  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
554
  var LEVEL_OPTIONS = {
532
555
  ...OUTPUT_OPTIONS,
533
556
  help: { type: "boolean", short: "h", description: "show this help" }
534
557
  };
535
558
  var TOP_OPTIONS = { ...LEVEL_OPTIONS, version: { type: "boolean", short: "v", description: "print the version" } };
536
- function commandRows(commands, colors) {
559
+ async function commandRows(commands, colors) {
537
560
  const width = Math.max(...Object.keys(commands).map((name) => name.length));
538
561
  const groups = new Map([["Commands:", []]]);
539
- for (const [name, { summary, group }] of Object.entries(commands)) {
562
+ const summaries = await Promise.all(Object.values(commands).map(async ({ summary }) => resolveSummary(summary)));
563
+ for (const [index, [name, { group }]] of Object.entries(commands).entries()) {
540
564
  const heading = group === undefined ? "Commands:" : `${group}:`;
541
565
  const rows = groups.get(heading) ?? [];
542
- rows.push(` ${colors.cyan(name.padEnd(width))} ${colors.dim(summary)}`);
566
+ rows.push(` ${colors.cyan(name.padEnd(width))} ${colors.dim(summaries[index])}`);
543
567
  groups.set(heading, rows);
544
568
  }
545
569
  return [...groups].filter(([, rows]) => rows.length > 0).flatMap(([heading, rows], index) => [...index === 0 ? [] : [""], colors.bold(heading), ...rows]);
@@ -559,28 +583,38 @@ function takeOutputFlags(argv) {
559
583
  }
560
584
  return argv.slice(index);
561
585
  }
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;
586
+ async function resolveSummary(summary) {
587
+ return typeof summary === "string" ? summary : summary();
568
588
  }
569
- async function help({ path, header, summary, commands, defaultCommand, examples, replaces }, stream) {
589
+ function scopeOf(commands, extra, aliases) {
590
+ return { commands: typeof commands === "function" ? {} : commands, lazy: [...typeof commands === "function" ? [commands] : [], ...extra === undefined ? [] : [extra]], aliases };
591
+ }
592
+ async function expand(scope, words) {
593
+ if (scope.lazy.length === 0)
594
+ return scope;
595
+ const commands = { ...scope.commands };
596
+ for (const added of await Promise.all(scope.lazy.map(async (lazy) => lazy(words)))) {
597
+ for (const [name, command] of Object.entries(added))
598
+ commands[name] ??= command;
599
+ }
600
+ return { ...scope, commands, lazy: [] };
601
+ }
602
+ async function help({ path, header, summary: lazySummary, commands, defaultCommand, examples, replaces }, stream) {
570
603
  const colors = paint(stream);
571
604
  const prefix = path.join(" ");
605
+ const summary = await resolveSummary(lazySummary);
572
606
  if (wantsHelpJson())
573
607
  return JSON.stringify(description(`${prefix} <command> [options]: ${summary}`, { examples, replaces }));
574
608
  const fallback = defaultCommand === undefined ? undefined : commands[defaultCommand];
575
609
  const options = fallback !== undefined && "run" in fallback ? await optionsOf(fallback.run) : undefined;
576
610
  const own = Object.entries(options ?? {}).filter(([name]) => !(name in TOP_OPTIONS));
577
611
  return [
578
- header(colors),
612
+ header(colors, summary),
579
613
  "",
580
614
  usageLine(`${prefix} ${defaultCommand === undefined ? colors.cyan("<command>") : `[${colors.cyan("<command>")}]`} [options]`, colors),
581
615
  ...replacesRows(replaces, colors),
582
616
  "",
583
- ...commandRows(commands, colors),
617
+ ...await commandRows(commands, colors),
584
618
  ...own.length === 0 ? [] : ["", `${colors.bold(`Options of ${defaultCommand}:`)} ${colors.dim("the command run when none is named")}`, ...optionRows(Object.fromEntries(own), colors)],
585
619
  "",
586
620
  colors.bold("Options:"),
@@ -626,11 +660,11 @@ async function dispatch(level, typed, { version, redact }) {
626
660
  }
627
661
  const [name, ...args] = argv;
628
662
  if (name === undefined && level.path.length > 1) {
629
- console.error(await help(level, process5.stderr));
663
+ console.error(await help(await expand(level, [...level.path.slice(1), ...argv]), process5.stderr));
630
664
  return 2;
631
665
  }
632
666
  if (name === undefined || name === "help" || name === "--help" || name === "-h") {
633
- console.log(await help(level, process5.stdout));
667
+ console.log(await help(await expand(level, [...level.path.slice(1), ...argv]), process5.stdout));
634
668
  return 0;
635
669
  }
636
670
  if (level.path.length === 1 && (name === "--version" || name === "-v")) {
@@ -639,6 +673,10 @@ async function dispatch(level, typed, { version, redact }) {
639
673
  }
640
674
  let command = level.commands[level.aliases?.[name] ?? name];
641
675
  let rest = args;
676
+ if (command === undefined && level.lazy.length > 0) {
677
+ level = await expand(level, [...level.path.slice(1), ...argv]);
678
+ command = level.commands[level.aliases?.[name] ?? name];
679
+ }
642
680
  if (command === undefined && level.defaultCommand !== undefined) {
643
681
  command = level.commands[level.defaultCommand];
644
682
  rest = argv;
@@ -650,7 +688,7 @@ async function dispatch(level, typed, { version, redact }) {
650
688
  }
651
689
  if ("commands" in command) {
652
690
  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 });
691
+ 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
692
  }
655
693
  try {
656
694
  return await command.run(rest);
@@ -675,18 +713,24 @@ async function optionsOf(run) {
675
713
  async function leafCandidates(run, words) {
676
714
  if (!(words.at(-1) ?? "").startsWith("-"))
677
715
  return [FILES];
678
- return Object.entries(await optionsOf(run)).map(([name, option]) => `--${name} ${option.description}`);
716
+ return Object.entries(await optionsOf(run)).map(([name, option]) => option.description === undefined ? `--${name}` : `--${name} ${option.description}`);
679
717
  }
680
- async function candidates(commands, aliases, defaultCommand, words) {
718
+ async function candidates(scope, defaultCommand, words, before = []) {
681
719
  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}`);
720
+ const listing = rest.length === 0 && !word.startsWith("-");
721
+ let { commands, aliases } = scope;
722
+ let command = commands[aliases?.[word] ?? word];
723
+ if (listing || command === undefined) {
724
+ ({ commands, aliases } = await expand(scope, [...before, ...words]));
725
+ command = commands[aliases?.[word] ?? word];
726
+ }
727
+ if (listing)
728
+ return Promise.all(Object.entries(commands).map(async ([name, { summary }]) => `${name} ${await resolveSummary(summary)}`));
685
729
  if (command === undefined) {
686
730
  const fallback = defaultCommand === undefined ? undefined : commands[defaultCommand];
687
731
  return fallback !== undefined && "run" in fallback ? leafCandidates(fallback.run, words) : [];
688
732
  }
689
- return "commands" in command ? candidates(command.commands, command.aliases, undefined, rest) : leafCandidates(command.run, rest);
733
+ return "commands" in command ? candidates(scopeOf(command.commands, command.extraCommands, command.aliases), undefined, rest, [...before, word]) : leafCandidates(command.run, rest);
690
734
  }
691
735
  async function printCandidates(found) {
692
736
  for (const line of await found)
@@ -695,8 +739,8 @@ async function printCandidates(found) {
695
739
  }
696
740
  async function runCli(cli, argv = process5.argv.slice(2)) {
697
741
  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 });
742
+ return printCandidates(candidates(scopeOf(cli.commands, cli.extraCommands, cli.aliases), cli.defaultCommand, argv.slice(1)));
743
+ 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
744
  }
701
745
  async function runMain({ name, version, run, redact }, argv = process5.argv.slice(2)) {
702
746
  if (argv[0] === COMPLETE)
@@ -712,10 +756,11 @@ async function runMain({ name, version, run, redact }, argv = process5.argv.slic
712
756
  }
713
757
  }
714
758
  // package.json
715
- var version = "0.5.0";
759
+ var version = "0.6.0";
716
760
 
717
761
  // src/files.ts
718
- import { readdir } from "fs/promises";
762
+ import { chmod, readdir, realpath, rename, rm, stat, writeFile } from "fs/promises";
763
+ import { basename, dirname, join } from "path";
719
764
 
720
765
  // src/dotenv.ts
721
766
  var ASSIGNMENT = /^\s*(?:export\s+)?([A-Z_][\w.-]*)\s*=(.*)$/i;
@@ -838,6 +883,17 @@ var TOKEN_FORMATS = [
838
883
  ["Slack token", /\bxox[abprs]-[\w-]{10,}/g],
839
884
  ["JWT", /\beyJ[\w-]+\.[\w-]+\.[\w-]+/g]
840
885
  ];
886
+ var URL_PARTS = /\b[a-z][a-z0-9+.-]*:\/\/([^\s/?#'"<>`]*)([^\s?#'"<>`]*)(?:\?([^\s#'"<>`]*))?/dgi;
887
+ var PATH_SEGMENT = /[^/]+/g;
888
+ var QUERY_PARAM = /([^&=]+)=([^&]*)/g;
889
+ var SECRET_PARAM = /^(?:token|[a-z]+_token|key|api[_-]?key|access[_-]?key|secret|[a-z]+_secret|password|passwd|pwd|sig|signature|auth|authorization)$/i;
890
+ var WEBHOOK_FORMATS = [
891
+ ["Discord webhook", /(?:^|\.)discord(?:app)?\.com\/api\/(?:v\d+\/)?webhooks\/\d+\/([\w-]+)/di],
892
+ ["Slack webhook", /^hooks\.slack\.com\/(?:services|workflows)\/(?:[\w-]+\/)*([\w-]+)/di],
893
+ ["Uptime Kuma push URL", /\/api\/push\/([\w-]+)/di],
894
+ ["healthchecks.io ping URL", /^hc-ping\.com\/([\w-]+)/di]
895
+ ];
896
+ var OPAQUE_SEGMENT = /^(?:(?=[\w-]{20,}$)(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[\w-]+|(?=[A-Za-z\d]{32,}$)(?=.*[A-Za-z])(?=.*\d).+)$/;
841
897
  var PEM_BEGIN = /-----BEGIN [A-Z ]*PRIVATE KEY-----/;
842
898
  var PEM_END = /-----END [A-Z ]*PRIVATE KEY-----/;
843
899
  function isSecretKey(key) {
@@ -861,6 +917,39 @@ function knownSecrets(pairs) {
861
917
  function urlPasswords(text) {
862
918
  return [...text.matchAll(URL_PASSWORD)].flatMap((url) => url[1] ?? []);
863
919
  }
920
+ function urlTokens(line) {
921
+ const found = [];
922
+ const add = (kind, start, value) => {
923
+ found.push({ start, end: start + value.length, kind, mask: hint(value) });
924
+ };
925
+ for (const url of line.matchAll(URL_PARTS)) {
926
+ const [authority, path = "", query] = [url[1] ?? "", url[2] ?? "", url[3]];
927
+ const authorityStart = url.indices?.[1]?.[0];
928
+ const pathStart = url.indices?.[2]?.[0];
929
+ const queryStart = url.indices?.[3]?.[0];
930
+ if (authorityStart === undefined || pathStart === undefined)
931
+ continue;
932
+ const hostAt = authority.lastIndexOf("@") + 1;
933
+ const target = authority.slice(hostAt) + path;
934
+ for (const [kind, pattern] of WEBHOOK_FORMATS) {
935
+ const [start, end] = pattern.exec(target)?.indices?.[1] ?? [];
936
+ if (start !== undefined && end !== undefined)
937
+ add(kind, authorityStart + hostAt + start, target.slice(start, end));
938
+ }
939
+ for (const segment of path.matchAll(PATH_SEGMENT)) {
940
+ if (OPAQUE_SEGMENT.test(segment[0]))
941
+ add("URL token", pathStart + segment.index, segment[0]);
942
+ }
943
+ if (query === undefined || queryStart === undefined)
944
+ continue;
945
+ for (const param of query.matchAll(QUERY_PARAM)) {
946
+ const [name, value] = [param[1] ?? "", param[2] ?? ""];
947
+ if (value !== "" && SECRET_PARAM.test(name))
948
+ add("URL token", queryStart + param.index + name.length + 1, value);
949
+ }
950
+ }
951
+ return found.filter((match) => match.end > match.start);
952
+ }
864
953
  function findSecrets(line, known) {
865
954
  const found = [];
866
955
  for (const url of line.matchAll(URL_PASSWORD)) {
@@ -872,6 +961,7 @@ function findSecrets(line, known) {
872
961
  for (const token of line.matchAll(pattern))
873
962
  found.push({ start: token.index, end: token.index + token[0].length, kind, mask: hint(token[0]) });
874
963
  }
964
+ found.push(...urlTokens(line));
875
965
  if (known?.pattern !== undefined) {
876
966
  for (const value of line.matchAll(known.pattern))
877
967
  found.push({ start: value.index, end: value.index + value[0].length, kind: `value of ${known.keys.get(value[0])}`, mask: hint(value[0]) });
@@ -913,7 +1003,20 @@ async function readEnv(path) {
913
1003
  return parseDotenv(await file.text());
914
1004
  }
915
1005
  async function writeEnv(path, file) {
916
- await Bun.write(path, serializeDotenv(file));
1006
+ let target = path;
1007
+ let mode = 384;
1008
+ try {
1009
+ target = await realpath(path);
1010
+ mode = (await stat(target)).mode & 511;
1011
+ } catch {}
1012
+ const temp = join(dirname(target), `.${basename(target)}.${process.pid}.${crypto.randomUUID()}.tmp`);
1013
+ try {
1014
+ await writeFile(temp, serializeDotenv(file), { mode: 384, flag: "wx" });
1015
+ await chmod(temp, mode);
1016
+ await rename(temp, target);
1017
+ } finally {
1018
+ await rm(temp, { force: true });
1019
+ }
917
1020
  }
918
1021
  async function loadKnown(folder = process.cwd()) {
919
1022
  const pairs = [];
@@ -1028,7 +1131,7 @@ function check(text, known) {
1028
1131
  // src/redact.ts
1029
1132
  var usage = `redact [--check]
1030
1133
 
1031
- Copies stdin to stdout line by line with secrets masked: known token formats (ghp_, github_pat_, glpat-, sk-, xox?-, JWTs), private key blocks, the password of any URL and the values of secret-looking keys in this folder's .env* files.
1134
+ Copies stdin to stdout line by line with secrets masked: known token formats (ghp_, github_pat_, glpat-, sk-, xox?-, JWTs), private key blocks, the password and the tokens of any URL and the values of secret-looking keys in this folder's .env* files.
1032
1135
  --check prints nothing of the input and lists the file and line of each secret found, exiting 1 when there is any: \`git diff --cached | redact --check\`.
1033
1136
 
1034
1137
  Exits 0 when it copied the input or found no secret, 1 when --check finds one, 2 on a usage error.`;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kaynooo/env-tools",
3
3
  "type": "module",
4
- "version": "0.5.0",
4
+ "version": "0.6.0",
5
5
  "description": "ev lists and edits .env files with secrets masked, redact masks secrets in any piped output.",
6
6
  "author": "Kaynooo <contact@kaynooo.fr>",
7
7
  "license": "MIT",