@kaynooo/guard-tools 0.4.0 → 0.5.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.
Files changed (2) hide show
  1. package/dist/cli.js +1034 -185
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -2,10 +2,10 @@
2
2
  // @bun
3
3
 
4
4
  // src/cli.ts
5
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
5
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "fs";
6
6
  import { homedir } from "os";
7
7
  import { dirname } from "path";
8
- import process6 from "process";
8
+ import process7 from "process";
9
9
 
10
10
  // ../kit/src/args.ts
11
11
  import process3 from "process";
@@ -298,6 +298,26 @@ function upTo(text, separator) {
298
298
  }
299
299
  var ESCAPE = String.fromCharCode(27);
300
300
 
301
+ // ../kit/src/text.ts
302
+ var ANSI = new RegExp(`${String.fromCharCode(27)}\\[[\\d;?]*[a-z]`, "gi");
303
+ function distance(a, b) {
304
+ let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
305
+ for (const [i, left] of [...a].entries()) {
306
+ const current = [i + 1];
307
+ for (const [j, right] of [...b].entries())
308
+ current.push(Math.min((previous[j + 1] ?? 0) + 1, (current[j] ?? 0) + 1, (previous[j] ?? 0) + (left === right ? 0 : 1)));
309
+ previous = current;
310
+ }
311
+ return previous[b.length] ?? 0;
312
+ }
313
+ function closest(name, names) {
314
+ const started = names.find((known) => known.startsWith(name));
315
+ if (started !== undefined)
316
+ return started;
317
+ const [best] = names.map((known) => ({ known, far: distance(name, known) })).sort((a, b) => a.far - b.far);
318
+ return best !== undefined && best.far <= (name.length < 4 ? 1 : 2) ? best.known : undefined;
319
+ }
320
+
301
321
  // ../kit/src/args.ts
302
322
  function jsonOption(holds) {
303
323
  return { type: "boolean", description: holds === undefined ? "print JSON" : `print JSON: ${holds}` };
@@ -317,8 +337,8 @@ function fail(message, code = 2, hint) {
317
337
  }
318
338
 
319
339
  class UsageError extends Exit {
320
- constructor(message) {
321
- super(message, 2);
340
+ constructor(message, hint) {
341
+ super(message, 2, hint);
322
342
  }
323
343
  }
324
344
  function badUsage(message) {
@@ -341,6 +361,7 @@ var completion = { active: false };
341
361
  var OPTIONAL = /\[[^\]]+\]/g;
342
362
  var USAGE_PREFIX = /^usage:\s*/i;
343
363
  var FULL_STOP = /\.$/;
364
+ var DASHES = /^-+/;
344
365
  function usageLine(usage, colors = paint(process3.stdout)) {
345
366
  return `${colors.bold("Usage:")} ${usage.replace(USAGE_PREFIX, "").replaceAll(OPTIONAL, (part) => colors.dim(part))}`;
346
367
  }
@@ -377,10 +398,10 @@ function optionRows(options, colors = paint(process3.stdout)) {
377
398
  const rows = Object.entries(options).map(([name, option]) => {
378
399
  const short = option.short === undefined ? " " : `${colors.cyan(`-${option.short}`)}, `;
379
400
  const value = option.type === "string" ? ` ${colors.dim("<value>")}` : "";
380
- return { flag: `${short}${colors.cyan(`--${name}`)}${value}`, width: `-x, --${name}`.length + (option.type === "string" ? " <value>".length : 0), description: option.description };
401
+ return { flag: `${short}${colors.cyan(`--${name}`)}${value}`, width: `-x, --${name}`.length + (option.type === "string" ? " <value>".length : 0), description: option.description ?? "" };
381
402
  });
382
403
  const width = Math.max(0, ...rows.map((row) => row.width));
383
- return rows.map((row) => ` ${row.flag}${" ".repeat(width - row.width)} ${colors.dim(row.description)}`);
404
+ return rows.map((row) => row.description === "" ? ` ${row.flag}` : ` ${row.flag}${" ".repeat(width - row.width)} ${colors.dim(row.description)}`);
384
405
  }
385
406
  var OUTPUT_OPTIONS = {
386
407
  color: { type: "string", description: "auto (on a terminal alone), always or never" },
@@ -412,7 +433,8 @@ function parse(args, usage, options, settings = {}) {
412
433
  try {
413
434
  parsed = parseArgs({ args: joinDashValues(own, all), options: all, allowPositionals: true, allowNegative: settings.allowNegative === true, strict: true });
414
435
  } catch (error) {
415
- badUsage(parseError(error));
436
+ const message = parseError(error);
437
+ throw new UsageError(message, nearestOption(message, all, settings.allowNegative === true));
416
438
  }
417
439
  const values = parsed.values;
418
440
  applyOutput("color" in output ? values.color : undefined, "plain" in output ? values.plain : undefined);
@@ -455,6 +477,22 @@ function parseError(error) {
455
477
  const message = upTo(error instanceof Error ? error.message : String(error), ". ");
456
478
  return `${message.charAt(0).toLowerCase()}${message.slice(1)}`;
457
479
  }
480
+ var UNKNOWN_OPTION = /^unknown option '(-[^']+)'/;
481
+ function nearestOption(message, options, allowNegative) {
482
+ const typed = UNKNOWN_OPTION.exec(message)?.[1];
483
+ if (typed === undefined)
484
+ return;
485
+ const word = typed.replace(DASHES, "");
486
+ if (word.length < 2 && !typed.startsWith("--"))
487
+ return;
488
+ const known = Object.entries(options).flatMap(([name, option]) => [
489
+ ...option.short === undefined ? [] : [`-${option.short}`],
490
+ `--${name}`,
491
+ ...allowNegative && option.type === "boolean" ? [`--no-${name}`] : []
492
+ ]);
493
+ const meant = closest(word, known.map((flag) => flag.replace(DASHES, "")));
494
+ return meant === undefined ? undefined : `did you mean ${known.find((flag) => flag.replace(DASHES, "") === meant)}?`;
495
+ }
458
496
  async function readInput(path, { noStdin = "nothing is piped on stdin" } = {}) {
459
497
  if (path === "-") {
460
498
  if (process3.stdin.isTTY)
@@ -466,35 +504,116 @@ async function readInput(path, { noStdin = "nothing is piped on stdin" } = {}) {
466
504
  fail(`no file ${path}`);
467
505
  return file.text();
468
506
  }
469
- // ../kit/src/cli.ts
507
+ // ../kit/src/process.ts
508
+ import { constants } from "os";
470
509
  import process4 from "process";
471
-
472
- // ../kit/src/text.ts
473
- var ANSI = new RegExp(`${String.fromCharCode(27)}\\[[\\d;?]*[a-z]`, "gi");
474
- function distance(a, b) {
475
- let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
476
- for (const [i, left] of [...a].entries()) {
477
- const current = [i + 1];
478
- for (const [j, right] of [...b].entries())
479
- current.push(Math.min((previous[j + 1] ?? 0) + 1, (current[j] ?? 0) + 1, (previous[j] ?? 0) + (left === right ? 0 : 1)));
480
- previous = current;
481
- }
482
- return previous[b.length] ?? 0;
510
+ var KILL_GRACE_MS = 2000;
511
+ function exitCodeOf(code, signal) {
512
+ if (code !== null)
513
+ return code;
514
+ if (typeof signal === "number")
515
+ return 128 + signal;
516
+ return signal === null ? 129 : 128 + constants.signals[signal];
517
+ }
518
+ async function runCommand(argv, { cwd, env, stdin = "ignore", timeoutMs, signal, maxBytes = Infinity, onChunk, onStart } = {}) {
519
+ const chunks = [];
520
+ const grouped = timeoutMs !== undefined || signal !== undefined || onStart !== undefined || onChunk !== undefined;
521
+ const proc = Bun.spawn(argv, { ...cwd === undefined ? {} : { cwd }, ...env === undefined ? {} : { env }, stdin, stdout: "pipe", stderr: "pipe", ...grouped ? { detached: true } : {} });
522
+ const state = { kept: 0, truncated: false, timedOut: false, failure: undefined, done: false };
523
+ const timers = [];
524
+ const killGroup = (killing) => {
525
+ if (state.done)
526
+ return;
527
+ try {
528
+ if (grouped)
529
+ process4.kill(-proc.pid, killing);
530
+ else
531
+ proc.kill(killing);
532
+ } catch {}
533
+ };
534
+ const stop = () => {
535
+ killGroup("SIGTERM");
536
+ timers.push(setTimeout(killGroup, KILL_GRACE_MS, "SIGKILL"));
537
+ };
538
+ const onAbort = () => stop();
539
+ if (timeoutMs !== undefined) {
540
+ timers.push(setTimeout(() => {
541
+ state.timedOut = true;
542
+ stop();
543
+ }, timeoutMs));
544
+ }
545
+ if (signal?.aborted === true)
546
+ onAbort();
547
+ else
548
+ signal?.addEventListener("abort", onAbort, { once: true });
549
+ onStart?.({ kill: (killing) => killGroup(killing) });
550
+ const drain = async (stream, name) => {
551
+ for await (const data of stream) {
552
+ if (state.failure !== undefined)
553
+ continue;
554
+ try {
555
+ onChunk?.({ stream: name, data });
556
+ } catch (error) {
557
+ state.failure = error;
558
+ stop();
559
+ continue;
560
+ }
561
+ if (state.kept + data.length > maxBytes) {
562
+ state.truncated = true;
563
+ continue;
564
+ }
565
+ state.kept += data.length;
566
+ chunks.push({ stream: name, data });
567
+ }
568
+ };
569
+ try {
570
+ await Promise.all([drain(proc.stdout, "stdout"), drain(proc.stderr, "stderr")]);
571
+ await proc.exited;
572
+ } finally {
573
+ state.done = true;
574
+ for (const timer of timers)
575
+ clearTimeout(timer);
576
+ signal?.removeEventListener("abort", onAbort);
577
+ }
578
+ if (state.failure !== undefined)
579
+ throw state.failure;
580
+ const joined = () => Buffer.concat(chunks.map(({ data }) => data));
581
+ let output;
582
+ let text;
583
+ return {
584
+ code: exitCodeOf(proc.exitCode, proc.signalCode),
585
+ chunks,
586
+ timedOut: state.timedOut,
587
+ truncated: state.truncated,
588
+ get output() {
589
+ return output ??= joined();
590
+ },
591
+ get text() {
592
+ return text ??= (output ??= joined()).toString();
593
+ }
594
+ };
483
595
  }
484
596
 
597
+ // ../kit/src/progress.ts
598
+ var ESC = String.fromCharCode(27);
599
+ var ESCAPE_CODE = new RegExp(`${ESC}(?:\\[[\\x30-\\x3F]*[\\x20-\\x2F]*[\\x40-\\x7E]|\\][^\\u0007${ESC}]*(?:\\u0007|${ESC}\\\\))`, "y");
600
+ var RESET_STYLE = `${ESC}[0m`;
601
+ var CLOSE_LINK = `${ESC}]8;;${ESC}\\`;
485
602
  // ../kit/src/cli.ts
603
+ import process5 from "process";
486
604
  var LEVEL_OPTIONS = {
487
605
  ...OUTPUT_OPTIONS,
488
606
  help: { type: "boolean", short: "h", description: "show this help" }
489
607
  };
490
608
  var TOP_OPTIONS = { ...LEVEL_OPTIONS, version: { type: "boolean", short: "v", description: "print the version" } };
491
- function commandRows(commands, colors) {
609
+ async function commandRows(commands, colors) {
492
610
  const width = Math.max(...Object.keys(commands).map((name) => name.length));
493
611
  const groups = new Map([["Commands:", []]]);
494
- for (const [name, { summary, group }] of Object.entries(commands)) {
612
+ const summaries = await Promise.all(Object.values(commands).map(async ({ summary }) => resolveSummary(summary)));
613
+ for (const [index, [name, { group }]] of Object.entries(commands).entries()) {
495
614
  const heading = group === undefined ? "Commands:" : `${group}:`;
496
615
  const rows = groups.get(heading) ?? [];
497
- rows.push(` ${colors.cyan(name.padEnd(width))} ${colors.dim(summary)}`);
616
+ rows.push(` ${colors.cyan(name.padEnd(width))} ${colors.dim(summaries[index])}`);
498
617
  groups.set(heading, rows);
499
618
  }
500
619
  return [...groups].filter(([, rows]) => rows.length > 0).flatMap(([heading, rows], index) => [...index === 0 ? [] : [""], colors.bold(heading), ...rows]);
@@ -514,28 +633,38 @@ function takeOutputFlags(argv) {
514
633
  }
515
634
  return argv.slice(index);
516
635
  }
517
- function closest(name, names) {
518
- const started = names.find((known) => known.startsWith(name));
519
- if (started !== undefined)
520
- return started;
521
- const [best] = names.map((known) => ({ known, far: distance(name, known) })).sort((a, b) => a.far - b.far);
522
- return best !== undefined && best.far <= (name.length < 4 ? 1 : 2) ? best.known : undefined;
636
+ async function resolveSummary(summary) {
637
+ return typeof summary === "string" ? summary : summary();
638
+ }
639
+ function scopeOf(commands, extra, aliases) {
640
+ return { commands: typeof commands === "function" ? {} : commands, lazy: [...typeof commands === "function" ? [commands] : [], ...extra === undefined ? [] : [extra]], aliases };
523
641
  }
524
- async function help({ path, header, summary, commands, defaultCommand, examples, replaces }, stream) {
642
+ async function expand(scope, words) {
643
+ if (scope.lazy.length === 0)
644
+ return scope;
645
+ const commands = { ...scope.commands };
646
+ for (const added of await Promise.all(scope.lazy.map(async (lazy) => lazy(words)))) {
647
+ for (const [name, command] of Object.entries(added))
648
+ commands[name] ??= command;
649
+ }
650
+ return { ...scope, commands, lazy: [] };
651
+ }
652
+ async function help({ path, header, summary: lazySummary, commands, defaultCommand, examples, replaces }, stream) {
525
653
  const colors = paint(stream);
526
654
  const prefix = path.join(" ");
655
+ const summary = await resolveSummary(lazySummary);
527
656
  if (wantsHelpJson())
528
657
  return JSON.stringify(description(`${prefix} <command> [options]: ${summary}`, { examples, replaces }));
529
658
  const fallback = defaultCommand === undefined ? undefined : commands[defaultCommand];
530
659
  const options = fallback !== undefined && "run" in fallback ? await optionsOf(fallback.run) : undefined;
531
660
  const own = Object.entries(options ?? {}).filter(([name]) => !(name in TOP_OPTIONS));
532
661
  return [
533
- header(colors),
662
+ header(colors, summary),
534
663
  "",
535
664
  usageLine(`${prefix} ${defaultCommand === undefined ? colors.cyan("<command>") : `[${colors.cyan("<command>")}]`} [options]`, colors),
536
665
  ...replacesRows(replaces, colors),
537
666
  "",
538
- ...commandRows(commands, colors),
667
+ ...await commandRows(commands, colors),
539
668
  ...own.length === 0 ? [] : ["", `${colors.bold(`Options of ${defaultCommand}:`)} ${colors.dim("the command run when none is named")}`, ...optionRows(Object.fromEntries(own), colors)],
540
669
  "",
541
670
  colors.bold("Options:"),
@@ -547,8 +676,8 @@ async function help({ path, header, summary, commands, defaultCommand, examples,
547
676
  `);
548
677
  }
549
678
  function printError(prefix, message, hint, cause) {
550
- const colors = paint(process4.stderr);
551
- const styled = takesControlCodes(process4.stderr);
679
+ const colors = paint(process5.stderr);
680
+ const styled = takesControlCodes(process5.stderr);
552
681
  const head = styled ? `${colors.red.bold("error")} ${colors.dim(`${prefix}:`)}` : `error: ${prefix}:`;
553
682
  const under = (label, text, tint) => text === undefined ? "" : `
554
683
  ${styled ? tint(label) : `${label}:`} ${text}`;
@@ -581,11 +710,11 @@ async function dispatch(level, typed, { version, redact }) {
581
710
  }
582
711
  const [name, ...args] = argv;
583
712
  if (name === undefined && level.path.length > 1) {
584
- console.error(await help(level, process4.stderr));
713
+ console.error(await help(await expand(level, [...level.path.slice(1), ...argv]), process5.stderr));
585
714
  return 2;
586
715
  }
587
716
  if (name === undefined || name === "help" || name === "--help" || name === "-h") {
588
- console.log(await help(level, process4.stdout));
717
+ console.log(await help(await expand(level, [...level.path.slice(1), ...argv]), process5.stdout));
589
718
  return 0;
590
719
  }
591
720
  if (level.path.length === 1 && (name === "--version" || name === "-v")) {
@@ -594,6 +723,10 @@ async function dispatch(level, typed, { version, redact }) {
594
723
  }
595
724
  let command = level.commands[level.aliases?.[name] ?? name];
596
725
  let rest = args;
726
+ if (command === undefined && level.lazy.length > 0) {
727
+ level = await expand(level, [...level.path.slice(1), ...argv]);
728
+ command = level.commands[level.aliases?.[name] ?? name];
729
+ }
597
730
  if (command === undefined && level.defaultCommand !== undefined) {
598
731
  command = level.commands[level.defaultCommand];
599
732
  rest = argv;
@@ -605,7 +738,7 @@ async function dispatch(level, typed, { version, redact }) {
605
738
  }
606
739
  if ("commands" in command) {
607
740
  const { summary } = command;
608
- 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 });
741
+ 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 });
609
742
  }
610
743
  try {
611
744
  return await command.run(rest);
@@ -630,67 +763,104 @@ async function optionsOf(run) {
630
763
  async function leafCandidates(run, words) {
631
764
  if (!(words.at(-1) ?? "").startsWith("-"))
632
765
  return [FILES];
633
- return Object.entries(await optionsOf(run)).map(([name, option]) => `--${name} ${option.description}`);
766
+ return Object.entries(await optionsOf(run)).map(([name, option]) => option.description === undefined ? `--${name}` : `--${name} ${option.description}`);
634
767
  }
635
- async function candidates(commands, aliases, defaultCommand, words) {
768
+ async function candidates(scope, defaultCommand, words, before = []) {
636
769
  const [word = "", ...rest] = words;
637
- const command = commands[aliases?.[word] ?? word];
638
- if (rest.length === 0 && !word.startsWith("-"))
639
- return Object.entries(commands).map(([name, { summary }]) => `${name} ${summary}`);
770
+ const listing = rest.length === 0 && !word.startsWith("-");
771
+ let { commands, aliases } = scope;
772
+ let command = commands[aliases?.[word] ?? word];
773
+ if (listing || command === undefined) {
774
+ ({ commands, aliases } = await expand(scope, [...before, ...words]));
775
+ command = commands[aliases?.[word] ?? word];
776
+ }
777
+ if (listing)
778
+ return Promise.all(Object.entries(commands).map(async ([name, { summary }]) => `${name} ${await resolveSummary(summary)}`));
640
779
  if (command === undefined) {
641
780
  const fallback = defaultCommand === undefined ? undefined : commands[defaultCommand];
642
781
  return fallback !== undefined && "run" in fallback ? leafCandidates(fallback.run, words) : [];
643
782
  }
644
- return "commands" in command ? candidates(command.commands, command.aliases, undefined, rest) : leafCandidates(command.run, rest);
783
+ return "commands" in command ? candidates(scopeOf(command.commands, command.extraCommands, command.aliases), undefined, rest, [...before, word]) : leafCandidates(command.run, rest);
645
784
  }
646
785
  async function printCandidates(found) {
647
786
  for (const line of await found)
648
787
  console.log(line);
649
788
  return 0;
650
789
  }
651
- async function runCli(cli, argv = process4.argv.slice(2)) {
790
+ async function runCli(cli, argv = process5.argv.slice(2)) {
652
791
  if (argv[0] === COMPLETE)
653
- return printCandidates(candidates(cli.commands, cli.aliases, cli.defaultCommand, argv.slice(1)));
654
- 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 });
655
- }
656
- // ../kit/src/process.ts
657
- import { constants } from "os";
658
- async function collect(stream, name, chunks) {
659
- for await (const data of stream)
660
- chunks.push({ stream: name, data });
661
- }
662
- function exitCodeOf(code, signal) {
663
- if (code !== null)
664
- return code;
665
- if (typeof signal === "number")
666
- return 128 + signal;
667
- return signal === null ? 129 : 128 + constants.signals[signal];
668
- }
669
- async function runCommand(argv, { cwd, env, stdin = "ignore" } = {}) {
670
- const chunks = [];
671
- const proc = Bun.spawn(argv, { ...cwd === undefined ? {} : { cwd }, ...env === undefined ? {} : { env }, stdin, stdout: "pipe", stderr: "pipe" });
672
- await Promise.all([collect(proc.stdout, "stdout", chunks), collect(proc.stderr, "stderr", chunks)]);
673
- await proc.exited;
674
- const output = Buffer.concat(chunks.map(({ data }) => data));
675
- return { code: exitCodeOf(proc.exitCode, proc.signalCode), chunks, output, text: output.toString() };
792
+ return printCandidates(candidates(scopeOf(cli.commands, cli.extraCommands, cli.aliases), cli.defaultCommand, argv.slice(1)));
793
+ 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 });
676
794
  }
795
+ // ../kit/src/logs.ts
796
+ var DAY_MS = 24 * 60 * 60 * 1000;
797
+ // ../kit/src/secrets.ts
798
+ var SECRET_WORDS = new Set(["key", "pass", "pwd", "auth", "salt", "cookie", "session", "jwt", "ssn", "iban", "card", "cvv", "otp"]);
677
799
  // package.json
678
- var version = "0.4.0";
800
+ var version = "0.5.0";
679
801
 
680
802
  // ../kit/src/shell.ts
681
803
  var SEPARATORS = new Set([`
682
804
  `, ";", "|"]);
683
805
  var PLAIN_BREAKERS = new Set(["<", ">", "(", ")", "&", "|"]);
806
+ var GLOB_CHARS = new Set(["*", "?", "["]);
807
+ var COMMENT_PREFIXES = new Set([";", "&", "|", "(", ")"]);
684
808
  var WHITESPACE = /\s/;
685
809
  var ASSIGNMENT = /^\w+=/;
686
810
  var TRAILING_SLASHES = /\/+$/;
811
+ var TRAILING_CR = /\r$/;
687
812
  var LEADING_TILDE = /^~(?=\/|$)/;
688
813
  var LEADING_TABS = /^\t+/;
689
814
  var OPENERS = /^[({]+/;
690
815
  var CLOSERS = /[)}]+$/;
816
+ var CASE_LABEL = /^\(?[^\s()]*\)$/;
817
+ var REDIRECT_ONLY = /^\d*(?:<|>|>>|<&|>&|&>|>\||<>)$/;
818
+ var REDIRECT_START = /^\d*[<>]|^&>/;
819
+ var SAFE_WORD = /^[\w@%+=:,./-]+$/;
820
+ var LEADING_HEX = /^[\da-f]*/i;
691
821
  var DOUBLE_QUOTED_ESCAPES = new Set(["$", "`", '"', "\\"]);
692
822
  var HEREDOC = /^<<(-?)[ \t]*(?:'([^']*)'|"([^"]*)"|([^\s;&|<>()-][^\s;&|<>()]*))/;
693
- var WRAPPERS = new Set(["command", "env", "sudo", "exec", "nohup", "time"]);
823
+ var ANSI_QUOTE = "$'";
824
+ var ANSI_ESCAPES = { n: `
825
+ `, t: "\t", r: "\r", a: "\x07", b: "\b", f: "\f", v: "\v", e: "\x1B", E: "\x1B", "\\": "\\", "'": "'", '"': '"', "?": "?" };
826
+ var ANSI_DIGITS = { x: 2, u: 4, U: 8 };
827
+ var OCTAL = /^[0-7]{1,3}/;
828
+ var KEYWORDS = new Set(["then", "do", "else", "if", "elif", "while", "until", "!", "{", "}"]);
829
+ var MAX_DEPTH = 8;
830
+ var MAX_BRACES = 256;
831
+ var SHELLS = new Set(["sh", "bash", "zsh", "dash", "ash", "ksh"]);
832
+ var COMMAND_STRING_PROGRAMS = new Set([...SHELLS, "su", "runuser"]);
833
+ var SHELL_VALUE_OPTIONS = new Set(["-o", "-O", "--rcfile", "--init-file"]);
834
+ var PRINTERS = new Set(["echo", "printf"]);
835
+ var FIND_EXEC = new Set(["-exec", "-execdir", "-ok", "-okdir"]);
836
+ function wrapper(short = "", long = [], operands = 0) {
837
+ return { short, long, operands };
838
+ }
839
+ var WRAPPERS = new Map([
840
+ ["command", wrapper()],
841
+ ["builtin", wrapper()],
842
+ ["nohup", wrapper()],
843
+ ["setsid", wrapper()],
844
+ ["busybox", wrapper()],
845
+ ["exec", wrapper("a")],
846
+ ["time", wrapper("fo", ["--format", "--output"])],
847
+ ["env", wrapper("uCS", ["--unset", "--chdir", "--split-string", "--argv0"])],
848
+ ["sudo", wrapper("ughpCTrtUDR", ["--user", "--group", "--host", "--prompt", "--close-from", "--command-timeout", "--role", "--type", "--other-user", "--chdir", "--chroot"])],
849
+ ["doas", wrapper("uC")],
850
+ ["timeout", wrapper("sk", ["--signal", "--kill-after"], 1)],
851
+ ["nice", wrapper("n", ["--adjustment"])],
852
+ ["ionice", wrapper("cnpPu", ["--class", "--classdata", "--pid", "--pgid", "--uid"])],
853
+ ["stdbuf", wrapper("ioe", ["--input", "--output", "--error"])],
854
+ ["chroot", wrapper("", ["--userspec", "--groups"], 1)],
855
+ ["xargs", wrapper("IndsEaPL", ["--max-args", "--max-procs", "--max-chars", "--delimiter", "--arg-file", "--eof", "--max-lines", "--process-slot-var"])]
856
+ ]);
857
+ function closerOf(quote) {
858
+ return quote === ANSI_QUOTE ? "'" : quote;
859
+ }
860
+ function startsComment(command, i) {
861
+ const before = command[i - 1] ?? "";
862
+ return i === 0 || WHITESPACE.test(before) || COMMENT_PREFIXES.has(before);
863
+ }
694
864
  function separatorWidth(command, i) {
695
865
  const pair = command.slice(i, i + 2);
696
866
  if (pair === "&&" || pair === "||")
@@ -707,7 +877,7 @@ function heredocEnd(command, newline, words) {
707
877
  const next = command.indexOf(`
708
878
  `, lineStart);
709
879
  const lineEnd = next === -1 ? command.length : next;
710
- const line = command.slice(lineStart, lineEnd);
880
+ const line = command.slice(lineStart, lineEnd).replace(TRAILING_CR, "");
711
881
  lineStart = lineEnd + 1;
712
882
  if ((stripsTabs ? line.replace(LEADING_TABS, "") : line) === word)
713
883
  break;
@@ -729,6 +899,7 @@ function withoutRanges(command, start, end, ranges) {
729
899
  function splitCommand(command) {
730
900
  const bounds = [];
731
901
  const bodies = [];
902
+ const comments = [];
732
903
  let heredocs = [];
733
904
  let start = 0;
734
905
  let quote = null;
@@ -739,14 +910,27 @@ function splitCommand(command) {
739
910
  continue;
740
911
  }
741
912
  if (quote !== null) {
742
- if (char === quote)
913
+ if (char === closerOf(quote))
743
914
  quote = null;
744
915
  continue;
745
916
  }
917
+ if (char === "$" && command[i + 1] === "'") {
918
+ quote = ANSI_QUOTE;
919
+ i++;
920
+ continue;
921
+ }
746
922
  if (char === "'" || char === '"') {
747
923
  quote = char;
748
924
  continue;
749
925
  }
926
+ if (char === "#" && startsComment(command, i)) {
927
+ const newline = command.indexOf(`
928
+ `, i);
929
+ const end = newline === -1 ? command.length : newline;
930
+ comments.push([i, end]);
931
+ i = end - 1;
932
+ continue;
933
+ }
750
934
  const heredoc = char === "<" && command[i + 1] === "<" && command[i + 2] !== "<" ? HEREDOC.exec(command.slice(i)) : null;
751
935
  if (heredoc) {
752
936
  heredocs.push({ word: heredoc[2] ?? heredoc[3] ?? heredoc[4] ?? "", stripsTabs: heredoc[1] === "-", isQuoted: heredoc[4] === undefined });
@@ -769,32 +953,78 @@ function splitCommand(command) {
769
953
  start = i + 1;
770
954
  }
771
955
  bounds.push([start, command.length, ""]);
772
- const ranges = bodies.map(([from, to]) => [from, to]);
773
- return bounds.map(([from, to, separator]) => ({
774
- text: command.slice(from, to),
775
- start: from,
776
- end: to,
777
- command: withoutRanges(command, from, to, ranges),
778
- separator,
779
- expanded: bodies.filter(([body, , expands]) => expands && body >= from && body < to).map(([body, bodyEnd]) => command.slice(body, bodyEnd)).join("")
780
- }));
956
+ const ranges = [...bodies, ...comments].map(([from, to]) => [from, to]).sort(([a], [b]) => a - b);
957
+ return bounds.map(([from, to, separator]) => {
958
+ const inside = bodies.filter(([body]) => body >= from && body < to);
959
+ return {
960
+ text: command.slice(from, to),
961
+ start: from,
962
+ end: to,
963
+ command: withoutRanges(command, from, to, ranges),
964
+ separator,
965
+ expanded: inside.filter(([, , expands]) => expands).map(([body, bodyEnd]) => command.slice(body, bodyEnd)).join(""),
966
+ heredocs: inside.map(([body, bodyEnd]) => command.slice(body, bodyEnd))
967
+ };
968
+ });
781
969
  }
782
970
  function isPlain(text) {
783
971
  let quote = null;
784
972
  for (let i = 0;i < text.length; i++) {
785
973
  const char = text[i] ?? "";
786
974
  const isSubstitution = char === "`" || char === "$" && text[i + 1] === "(";
975
+ if (quote === null && char === "$" && text[i + 1] === "'") {
976
+ quote = ANSI_QUOTE;
977
+ i++;
978
+ continue;
979
+ }
787
980
  if (char === "\\" && quote !== "'")
788
981
  i++;
789
- else if (quote !== null && char === quote)
982
+ else if (quote !== null && char === closerOf(quote))
790
983
  quote = null;
791
984
  else if (quote === null && (char === '"' || char === "'"))
792
985
  quote = char;
793
- else if (quote !== "'" && (isSubstitution || quote === null && PLAIN_BREAKERS.has(char)))
986
+ else if (quote !== "'" && quote !== ANSI_QUOTE && (isSubstitution || quote === null && PLAIN_BREAKERS.has(char)))
794
987
  return false;
795
988
  }
796
989
  return quote === null;
797
990
  }
991
+ function unmatchedClosers(text) {
992
+ let depth = 0;
993
+ let unmatched = 0;
994
+ let quote = null;
995
+ for (let i = 0;i < text.length; i++) {
996
+ const char = text[i] ?? "";
997
+ if (quote === null && char === "$" && text[i + 1] === "'") {
998
+ quote = ANSI_QUOTE;
999
+ i++;
1000
+ continue;
1001
+ }
1002
+ if (char === "\\" && quote !== "'")
1003
+ i++;
1004
+ else if (quote !== null && char === closerOf(quote))
1005
+ quote = null;
1006
+ else if (quote === null && (char === '"' || char === "'"))
1007
+ quote = char;
1008
+ else if (quote === null && char === "(")
1009
+ depth++;
1010
+ else if (quote === null && char === ")" && depth > 0)
1011
+ depth--;
1012
+ else if (quote === null && char === ")")
1013
+ unmatched++;
1014
+ }
1015
+ return unmatched;
1016
+ }
1017
+ function withoutSubshell(text) {
1018
+ let result = text.trim();
1019
+ let closers = unmatchedClosers(result);
1020
+ while (result.startsWith("("))
1021
+ result = result.slice(1).trimStart();
1022
+ while (closers > 0 && result.endsWith(")")) {
1023
+ result = result.slice(0, -1).trimEnd();
1024
+ closers--;
1025
+ }
1026
+ return result;
1027
+ }
798
1028
  function closingParen(text, open) {
799
1029
  let depth = 0;
800
1030
  let quote = null;
@@ -803,8 +1033,11 @@ function closingParen(text, open) {
803
1033
  if (char === "\\" && quote !== "'") {
804
1034
  i++;
805
1035
  } else if (quote !== null) {
806
- if (char === quote)
1036
+ if (char === closerOf(quote))
807
1037
  quote = null;
1038
+ } else if (char === "$" && text[i + 1] === "'") {
1039
+ quote = ANSI_QUOTE;
1040
+ i++;
808
1041
  } else if (char === "'" || char === '"') {
809
1042
  quote = char;
810
1043
  } else if (char === "(") {
@@ -829,12 +1062,18 @@ function bodies(text, isQuoting) {
829
1062
  let quote = null;
830
1063
  for (let i = 0;i < text.length; i++) {
831
1064
  const char = text[i] ?? "";
1065
+ const next = text[i + 1];
832
1066
  if (quote === "'") {
833
1067
  if (char === "'")
834
1068
  quote = null;
1069
+ } else if (quote === ANSI_QUOTE) {
1070
+ if (char === "\\")
1071
+ i++;
1072
+ else if (char === "'")
1073
+ quote = null;
835
1074
  } else if (char === "\\") {
836
1075
  i++;
837
- } else if (char === "$" && text[i + 1] === "(") {
1076
+ } else if (char === "$" && next === "(" || isQuoting && quote === null && (char === "<" || char === ">") && next === "(") {
838
1077
  const end = closingParen(text, i + 1);
839
1078
  found.push(text.slice(i + 2, end));
840
1079
  i = end;
@@ -844,6 +1083,9 @@ function bodies(text, isQuoting) {
844
1083
  i = end;
845
1084
  } else if (isQuoting && char === '"') {
846
1085
  quote = quote === null ? char : null;
1086
+ } else if (isQuoting && char === "$" && next === "'" && quote === null) {
1087
+ quote = ANSI_QUOTE;
1088
+ i++;
847
1089
  } else if (isQuoting && char === "'" && quote === null) {
848
1090
  quote = char;
849
1091
  }
@@ -853,11 +1095,26 @@ function bodies(text, isQuoting) {
853
1095
  function substitutions(command) {
854
1096
  return splitCommand(command).flatMap((piece) => [...bodies(piece.command, true), ...bodies(piece.expanded, false)]);
855
1097
  }
1098
+ function ansiEscape(text, at) {
1099
+ const letter = text[at + 1] ?? "";
1100
+ const simple = ANSI_ESCAPES[letter];
1101
+ if (simple !== undefined)
1102
+ return [simple, at + 1];
1103
+ const hex = LEADING_HEX.exec(text.slice(at + 2, at + 2 + (ANSI_DIGITS[letter] ?? 0)))?.[0] ?? "";
1104
+ if (hex !== "")
1105
+ return [String.fromCodePoint(Number.parseInt(hex, 16)), at + 1 + hex.length];
1106
+ const octal = OCTAL.exec(text.slice(at + 1))?.[0];
1107
+ if (octal !== undefined)
1108
+ return [String.fromCodePoint(Number.parseInt(octal, 8)), at + octal.length];
1109
+ return [`\\${letter}`, at + 1];
1110
+ }
856
1111
  function words(text) {
857
1112
  const found = [];
858
1113
  let current = "";
859
1114
  let isStarted = false;
860
1115
  let isProse = false;
1116
+ let hasGlob = false;
1117
+ let hasBrace = false;
861
1118
  let quote = null;
862
1119
  for (let i = 0;i < text.length; i++) {
863
1120
  const char = text[i] ?? "";
@@ -867,40 +1124,79 @@ function words(text) {
867
1124
  i++;
868
1125
  if (quote === null && (isContinuation || WHITESPACE.test(char))) {
869
1126
  if (isStarted)
870
- found.push({ text: current, isProse });
1127
+ found.push({ text: current, isProse, hasGlob, hasBrace });
871
1128
  current = "";
872
1129
  isStarted = false;
873
1130
  isProse = false;
1131
+ hasGlob = false;
1132
+ hasBrace = false;
874
1133
  continue;
875
1134
  }
876
1135
  if (isContinuation)
877
1136
  continue;
878
1137
  isStarted = true;
879
- if (char === "\\" && quote === '"' && !DOUBLE_QUOTED_ESCAPES.has(text[i + 1] ?? ""))
1138
+ if (char === "\\" && quote === ANSI_QUOTE) {
1139
+ const [decoded, last] = ansiEscape(text, i);
1140
+ current += decoded;
1141
+ i = last;
1142
+ } else if (char === "\\" && quote === '"' && !DOUBLE_QUOTED_ESCAPES.has(text[i + 1] ?? "")) {
880
1143
  current += char;
881
- else if (char === "\\" && quote !== "'")
1144
+ } else if (char === "\\" && quote !== "'") {
882
1145
  current += text[++i] ?? "";
883
- else if (quote !== null && char === quote)
1146
+ } else if (quote !== null && char === closerOf(quote)) {
884
1147
  quote = null;
885
- else if (quote === null && (char === '"' || char === "'"))
1148
+ } else if (quote === null && char === "$" && text[i + 1] === "'") {
1149
+ quote = ANSI_QUOTE;
1150
+ i++;
1151
+ } else if (quote === null && (char === '"' || char === "'")) {
886
1152
  quote = char;
887
- else
1153
+ } else {
888
1154
  current += char;
1155
+ hasGlob ||= quote === null && GLOB_CHARS.has(char);
1156
+ hasBrace ||= quote === null && char === "{";
1157
+ }
889
1158
  isProse ||= quote !== null && WHITESPACE.test(char);
890
1159
  }
891
1160
  if (isStarted)
892
- found.push({ text: current, isProse });
1161
+ found.push({ text: current, isProse, hasGlob, hasBrace });
893
1162
  return found;
894
1163
  }
895
1164
  function tokenize(text) {
896
1165
  return words(text).map((word) => word.text);
897
1166
  }
1167
+ function skipOptions(tokens, spec) {
1168
+ const take = (name, attached) => {
1169
+ const value = attached === "" ? tokens.shift() ?? "" : attached;
1170
+ if (name === "S" || name === "--split-string")
1171
+ tokens.unshift(...tokenize(value));
1172
+ };
1173
+ while (tokens[0] !== undefined && tokens[0].startsWith("-") && tokens[0] !== "-") {
1174
+ const option = tokens.shift() ?? "";
1175
+ if (option === "--")
1176
+ break;
1177
+ if (option.startsWith("--")) {
1178
+ const [name = "", ...value] = option.split("=");
1179
+ if (spec.long.includes(name))
1180
+ take(name, value.join("="));
1181
+ continue;
1182
+ }
1183
+ for (const [index, letter] of [...option.slice(1)].entries()) {
1184
+ if (!spec.short.includes(letter))
1185
+ continue;
1186
+ take(letter, option.slice(index + 2));
1187
+ break;
1188
+ }
1189
+ }
1190
+ for (let operand = 0;operand < spec.operands; operand++)
1191
+ tokens.shift();
1192
+ }
898
1193
  function parseCommand(text) {
899
1194
  const tokens = tokenize(text);
900
1195
  const assignments = [];
901
1196
  let isWrapped = false;
902
1197
  for (;; ) {
903
1198
  const first = tokens[0] ?? "";
1199
+ const spec = WRAPPERS.get(first.split("/").pop() ?? "");
904
1200
  if (OPENERS.test(first)) {
905
1201
  isWrapped = true;
906
1202
  const rest = first.replace(OPENERS, "");
@@ -910,11 +1206,20 @@ function parseCommand(text) {
910
1206
  tokens[0] = rest;
911
1207
  } else if (ASSIGNMENT.test(first)) {
912
1208
  assignments.push(tokens.shift() ?? "");
913
- } else if (WRAPPERS.has(first)) {
1209
+ } else if (KEYWORDS.has(first)) {
914
1210
  isWrapped = true;
915
1211
  tokens.shift();
916
- while (tokens[0]?.startsWith("-"))
917
- tokens.shift();
1212
+ } else if (first === "case") {
1213
+ isWrapped = true;
1214
+ const at = tokens.indexOf("in");
1215
+ tokens.splice(0, at === -1 ? tokens.length : at + 1);
1216
+ } else if (tokens.length > 1 && CASE_LABEL.test(first)) {
1217
+ isWrapped = true;
1218
+ tokens.shift();
1219
+ } else if (spec !== undefined) {
1220
+ isWrapped = true;
1221
+ tokens.shift();
1222
+ skipOptions(tokens, spec);
918
1223
  } else {
919
1224
  break;
920
1225
  }
@@ -928,6 +1233,131 @@ function parseCommand(text) {
928
1233
  const [program = "", ...args] = tokens;
929
1234
  return { program: program.split("/").pop() ?? "", args, assignments, isWrapped };
930
1235
  }
1236
+ function quoted(word) {
1237
+ return SAFE_WORD.test(word) || word === "{}" ? word : `'${word.replaceAll("'", "'\\''")}'`;
1238
+ }
1239
+ function commandStrings(tokens) {
1240
+ const found = [];
1241
+ for (const [i, token] of tokens.entries()) {
1242
+ const name = token.split("/").pop() ?? "";
1243
+ if (!COMMAND_STRING_PROGRAMS.has(name))
1244
+ continue;
1245
+ const isUser = name === "su" || name === "runuser";
1246
+ let isValue = false;
1247
+ for (let j = i + 1;j < tokens.length; j++) {
1248
+ const option = tokens[j] ?? "";
1249
+ if (isValue) {
1250
+ isValue = false;
1251
+ continue;
1252
+ }
1253
+ if (SHELL_VALUE_OPTIONS.has(option)) {
1254
+ isValue = true;
1255
+ continue;
1256
+ }
1257
+ if (option.startsWith("--command=")) {
1258
+ found.push(option.slice("--command=".length));
1259
+ break;
1260
+ }
1261
+ if (option === "--command" || !option.startsWith("--") && option.startsWith("-") && option.slice(1).includes("c")) {
1262
+ found.push(tokens[j + 1] ?? "");
1263
+ break;
1264
+ }
1265
+ if (!option.startsWith("-") && !isUser)
1266
+ break;
1267
+ }
1268
+ }
1269
+ return found;
1270
+ }
1271
+ function redirections(args) {
1272
+ const operands = [];
1273
+ const hereStrings = [];
1274
+ for (let i = 0;i < args.length; i++) {
1275
+ const arg = args[i] ?? "";
1276
+ if (arg.startsWith("<<<"))
1277
+ hereStrings.push(arg.length > 3 ? arg.slice(3) : args[++i] ?? "");
1278
+ else if (REDIRECT_ONLY.test(arg))
1279
+ i++;
1280
+ else if (!REDIRECT_START.test(arg))
1281
+ operands.push(arg);
1282
+ }
1283
+ return { operands, hereStrings };
1284
+ }
1285
+ function findExecs(args) {
1286
+ const found = [];
1287
+ for (const [i, arg] of args.entries()) {
1288
+ if (!FIND_EXEC.has(arg))
1289
+ continue;
1290
+ const rest = args.slice(i + 1);
1291
+ const end = rest.findIndex((word) => word === ";" || word === "+");
1292
+ found.push((end === -1 ? rest : rest.slice(0, end)).map(quoted).join(" "));
1293
+ }
1294
+ return found;
1295
+ }
1296
+ function nestedCommands(piece, previous) {
1297
+ const tokens = tokenize(piece.command);
1298
+ const { program, args } = parseCommand(piece.command);
1299
+ const found = commandStrings(tokens);
1300
+ if (program === "eval")
1301
+ found.push(args.join(" "));
1302
+ if (program === "find")
1303
+ found.push(...findExecs(args));
1304
+ if (!SHELLS.has(program))
1305
+ return found;
1306
+ const { operands, hereStrings } = redirections(args);
1307
+ if (operands.length > 0)
1308
+ return found;
1309
+ found.push(...piece.heredocs, ...hereStrings);
1310
+ if (previous?.separator === "|") {
1311
+ const before = parseCommand(previous.command);
1312
+ if (PRINTERS.has(before.program))
1313
+ found.push(...before.args, before.args.join(" "));
1314
+ }
1315
+ return found;
1316
+ }
1317
+ function commandsRun(command, depth = 0) {
1318
+ const found = [command];
1319
+ if (depth >= MAX_DEPTH)
1320
+ return found;
1321
+ const pieces = splitCommand(command);
1322
+ for (const [index, piece] of pieces.entries()) {
1323
+ const inner = withoutSubshell(piece.command);
1324
+ const nested = [...bodies(piece.command, true), ...bodies(piece.expanded, false), ...nestedCommands(piece, pieces[index - 1])];
1325
+ if (inner !== piece.command.trim())
1326
+ nested.push(inner);
1327
+ for (const each of nested)
1328
+ found.push(...commandsRun(each, depth + 1));
1329
+ }
1330
+ return found;
1331
+ }
1332
+ function expandBraces(word) {
1333
+ const open = word.indexOf("{");
1334
+ if (open === -1)
1335
+ return [word];
1336
+ const alternatives = [];
1337
+ let depth = 0;
1338
+ let cut = open;
1339
+ for (let i = open;i < word.length; i++) {
1340
+ const char = word[i];
1341
+ if (char === "{") {
1342
+ depth++;
1343
+ } else if (char === "," && depth === 1) {
1344
+ alternatives.push(word.slice(cut + 1, i));
1345
+ cut = i;
1346
+ } else if (char === "}" && --depth === 0) {
1347
+ if (alternatives.length === 0)
1348
+ break;
1349
+ alternatives.push(word.slice(cut + 1, i));
1350
+ const prefix = word.slice(0, open);
1351
+ const suffix = word.slice(i + 1);
1352
+ return alternatives.flatMap((alternative) => expandBraces(prefix + alternative + suffix)).slice(0, MAX_BRACES + 1);
1353
+ }
1354
+ }
1355
+ return expandBraces(word.slice(open + 1)).map((rest) => `${word.slice(0, open + 1)}${rest}`);
1356
+ }
1357
+ function braceExpand(word) {
1358
+ const found = expandBraces(word);
1359
+ return found.length > MAX_BRACES ? [word] : found;
1360
+ }
931
1361
  function replacePieces(command, replacements) {
932
1362
  let result = command;
933
1363
  for (const [piece, text] of [...replacements].sort(([a], [b]) => b.start - a.start)) {
@@ -961,10 +1391,14 @@ var LOCK_KIND = /^(?:update|share|no|key)$/;
961
1391
  var DIGITS = /\d/;
962
1392
  var COMMAND_ENDS = ";";
963
1393
  var UNCLOSED = "unclosed";
1394
+ var NUL = "\x00";
1395
+ var UNICODE_IDENTIFIER = 'U&"';
1396
+ var UNICODE_PREFIX = /u&$/i;
1397
+ var LINE_END = /[\r\n]/;
964
1398
  var PRIVILEGED_SET = new Set(["role", "session", "transaction"]);
965
1399
  var READ_STARTS = new Set(["select", "with", "values", "table", "show", "explain"]);
966
1400
  var WRITE_WORDS = new Set(["insert", "update", "delete", "merge", "truncate", "drop", "alter", "create", "grant", "revoke", "into", "call", "vacuum", "lock", "refresh", "reindex", "cluster", "do"]);
967
- var SIDE_EFFECT_FUNCTIONS = /^(?:nextval|setval|set_config|pg_terminate_backend|pg_cancel_backend|pg_reload_conf|pg_rotate_logfile|pg_switch_wal|pg_create_restore_point|pg_advisory_lock\w*|pg_advisory_xact_lock\w*|pg_try_advisory\w*|pg_notify|lo_\w+|dblink\w*|pg_read_file|pg_read_binary_file|pg_ls_dir|query_to_xml\w*|pg_logical_\w+|pg_replication_\w+|pg_drop_replication_slot|pg_stat_reset\w*|pg_promote|pg_file_\w+)$/;
1401
+ var SIDE_EFFECT_FUNCTIONS = /^(?:nextval|setval|set_config|pg_terminate_backend|pg_cancel_backend|pg_reload_conf|pg_rotate_logfile|pg_switch_wal|pg_create_restore_point|pg_advisory_lock\w*|pg_advisory_xact_lock\w*|pg_try_advisory\w*|pg_notify|lo_\w+|dblink\w*|pg_read_file|pg_read_binary_file|pg_ls_\w+|pg_stat_file|pg_sleep\w*|pg_export_snapshot|pg_backup_start|pg_backup_stop|pg_start_backup|pg_stop_backup|txid_current|pg_current_xact_id|pg_current_logfile|pg_wal_replay_\w+|\w+_to_xml\w*|pg_logical_\w+|pg_replication_\w+|pg_drop_replication_slot|pg_stat_reset\w*|pg_promote|pg_file_\w+)$/;
968
1402
  function tokenize2(sql) {
969
1403
  const tokens = [];
970
1404
  let at = 0;
@@ -973,21 +1407,29 @@ function tokenize2(sql) {
973
1407
  const next = sql.charAt(at + 1);
974
1408
  if (SPACE.test(char)) {
975
1409
  at++;
1410
+ } else if (char === NUL) {
1411
+ tokens.push({ kind: "punct", text: NUL });
1412
+ at++;
976
1413
  } else if (char === "-" && next === "-") {
977
- const end = sql.indexOf(`
978
- `, at);
979
- at = end === -1 ? sql.length : end + 1;
1414
+ const end = sql.slice(at).search(LINE_END);
1415
+ at = end === -1 ? sql.length : at + end + 1;
980
1416
  } else if (char === "/" && next === "*" && sql.charAt(at + 2) === "!") {
981
1417
  at += 3;
982
1418
  while (DIGITS.test(sql.charAt(at)))
983
1419
  at++;
984
1420
  } else if (char === "/" && next === "*") {
985
1421
  at = skipComment(sql, at);
986
- } else if (char === "'" || char === '"' || char === "`") {
987
- const backslashes = char === "'" && ESCAPE_PREFIX.test(sql.charAt(at - 1)) && !WORD_PART.test(sql.charAt(at - 2));
1422
+ } else if (char === "'") {
1423
+ const backslashes = ESCAPE_PREFIX.test(sql.charAt(at - 1)) && !WORD_PART.test(sql.charAt(at - 2));
988
1424
  const end = skipQuoted(sql, at, char, backslashes);
989
1425
  at = end ?? sql.length;
990
1426
  tokens.push({ kind: "punct", text: end === undefined ? UNCLOSED : char });
1427
+ } else if (char === '"' || char === "`") {
1428
+ const end = skipQuoted(sql, at, char, false);
1429
+ if (char === '"' && UNICODE_PREFIX.test(sql.slice(Math.max(0, at - 2), at)) && !WORD_PART.test(sql.charAt(at - 3)))
1430
+ tokens.push({ kind: "punct", text: UNICODE_IDENTIFIER });
1431
+ tokens.push(end === undefined ? { kind: "punct", text: UNCLOSED } : { kind: "quoted", text: sql.slice(at + 1, end - 1).replaceAll(char + char, char) });
1432
+ at = end ?? sql.length;
991
1433
  } else if (char === "$" && DOLLAR_TAG.test(sql.slice(at))) {
992
1434
  const tag = DOLLAR_TAG.exec(sql.slice(at))?.[0] ?? "$$";
993
1435
  const end = sql.indexOf(tag, at + tag.length);
@@ -1072,6 +1514,12 @@ function statementWrites(statement) {
1072
1514
  for (const [index, token] of statement.entries()) {
1073
1515
  if (isPunct(token, UNCLOSED))
1074
1516
  reasons.push("has a quote that never closes");
1517
+ else if (isPunct(token, NUL))
1518
+ reasons.push("contains a NUL byte");
1519
+ else if (isPunct(token, UNICODE_IDENTIFIER))
1520
+ reasons.push("uses a U& escaped identifier");
1521
+ if (token.kind === "quoted" && SIDE_EFFECT_FUNCTIONS.test(token.text))
1522
+ reasons.push(`calls ${token.text}`);
1075
1523
  if (token.kind !== "word")
1076
1524
  continue;
1077
1525
  if (SIDE_EFFECT_FUNCTIONS.test(token.text)) {
@@ -1104,23 +1552,104 @@ function writes(sql) {
1104
1552
  var SQL_CLIENTS = new Set(["psql", "mysql", "mariadb", "pgcli", "mycli", "sqlite3"]);
1105
1553
  var WRITING_TOOLS = new Set(["dropdb", "createdb", "pg_restore"]);
1106
1554
  var CONSOLE_PROGRAMS = new Set(["php", "docker", "symfony", "console", "artisan"]);
1107
- var STATEMENT_OPTIONS = new Set(["-c", "--command", "-e", "--execute"]);
1108
- var FILE_OPTIONS = new Set(["-f", "--file", "<"]);
1109
- var PRINTERS = new Set(["echo", "printf"]);
1555
+ var VALUE_SHORTS = { psql: "cdfhLopPRTUvF", pgcli: "dhUpW", mysql: "eDhPSuTA", mariadb: "eDhPSuTA", mycli: "eDhPSuTA", sqlite3: "" };
1556
+ var STATEMENT_SHORTS = { psql: "c", pgcli: "c", mysql: "e", mariadb: "e", mycli: "e", sqlite3: "" };
1557
+ var FILE_SHORTS = { psql: "f", pgcli: "", mysql: "", mariadb: "", mycli: "", sqlite3: "" };
1558
+ var IS_MYSQL = new Set(["mysql", "mariadb", "mycli"]);
1559
+ var DATABASE_SHORTS = new Set(["d", "D"]);
1560
+ var STATEMENT_LONGS = new Set(["--command", "--execute"]);
1561
+ var DATABASE_LONGS = new Set(["--dbname", "--database"]);
1562
+ var VALUE_LONGS = new Set(["--host", "--port", "--username", "--user", "--output", "--set", "--variable", "--field-separator", "--record-separator", "--pset", "--log-file", "--socket"]);
1563
+ var PRINTERS2 = new Set(["echo", "printf"]);
1110
1564
  var LIST_OPTIONS = new Set(["-l", "--list"]);
1565
+ var REDIRECT_ONLY2 = /^\d*(?:>|>>|>&|&>|>\||<&|<>)$/;
1566
+ var REDIRECT_START2 = /^\d*>|^&>/;
1567
+ var URL_DATABASE = /^[a-z][a-z\d+.-]*:\/\/[^/]*\/([^?#]*)/i;
1111
1568
  var WRITING_CONSOLE = /^(?:doctrine:(?:migrations:(?:migrate|execute|rollup)|schema:(?:update|create|drop)|database:(?:create|drop)|fixtures:load)|migrate(?::\w+)?|db:(?:seed|wipe))$/;
1112
1569
  var TEST_SCOPED = /(?:^|[^a-z])test/i;
1113
1570
  var SHOT_STACK = /\.worktrees\/[^/\s]+\/shot(?:\/|$)|--shot(?:-|$)/;
1571
+ var DEVICE_PATH = /^\/(?:dev|proc|sys)\//;
1572
+ var STDIN_PATHS = new Set(["-", "/dev/stdin"]);
1114
1573
  var SQL_WRITE = "Statements that write (INSERT, UPDATE, DELETE, DDL, migrations, fixtures) are handed over: list the exact command for the user to run. Only a clearly test-only database runs them directly.";
1574
+ var SQL_UNREADABLE = "A file this SQL client is fed cannot be read here (missing, a device, or too large), so what it runs is unknown. Hand over the exact command for the user to run.";
1115
1575
  function hasWrite(statement) {
1116
1576
  return writes(statement).length > 0;
1117
1577
  }
1118
1578
  function programOf(word) {
1119
1579
  return word.split("/").pop() ?? "";
1120
1580
  }
1121
- function isTestScoped(words) {
1122
- const client = words.findIndex((word) => SQL_CLIENTS.has(programOf(word)));
1123
- return words.some((word, i) => (TEST_SCOPED.test(word) || SHOT_STACK.test(word)) && (i <= client || client === -1 || !STATEMENT_OPTIONS.has(words[i - 1] ?? "") && !FILE_OPTIONS.has(words[i - 1] ?? "")));
1581
+ function databaseOf(operand) {
1582
+ return URL_DATABASE.exec(operand)?.[1] ?? operand;
1583
+ }
1584
+ function clientInput(name, args) {
1585
+ const input = { statements: [], files: [], databases: [], operands: [] };
1586
+ const valueShorts = VALUE_SHORTS[name] ?? "";
1587
+ let isOperands = false;
1588
+ for (let i = 0;i < args.length; i++) {
1589
+ const arg = args[i] ?? "";
1590
+ if (isOperands) {
1591
+ input.operands.push(arg);
1592
+ } else if (arg === "--") {
1593
+ isOperands = true;
1594
+ } else if (arg.startsWith("<<<")) {
1595
+ input.statements.push(arg.length > 3 ? arg.slice(3) : args[++i] ?? "");
1596
+ } else if (arg.startsWith("<<")) {
1597
+ continue;
1598
+ } else if (arg === "<") {
1599
+ input.files.push(args[++i] ?? "");
1600
+ } else if (arg.startsWith("<")) {
1601
+ input.files.push(arg.slice(1));
1602
+ } else if (REDIRECT_ONLY2.test(arg)) {
1603
+ i++;
1604
+ } else if (REDIRECT_START2.test(arg)) {
1605
+ continue;
1606
+ } else if (arg.startsWith("--")) {
1607
+ const [option = "", ...rest] = arg.split("=");
1608
+ const attached = rest.length > 0 ? rest.join("=") : undefined;
1609
+ const value = attached ?? args[i + 1] ?? "";
1610
+ if (STATEMENT_LONGS.has(option))
1611
+ input.statements.push(value);
1612
+ else if (option === "--file")
1613
+ input.files.push(value);
1614
+ else if (DATABASE_LONGS.has(option))
1615
+ input.databases.push(databaseOf(value));
1616
+ if (attached === undefined && (STATEMENT_LONGS.has(option) || option === "--file" || DATABASE_LONGS.has(option) || VALUE_LONGS.has(option)))
1617
+ i++;
1618
+ } else if (arg === "-cmd" && name === "sqlite3") {
1619
+ input.statements.push(args[++i] ?? "");
1620
+ } else if (arg.startsWith("-p") && IS_MYSQL.has(name)) {
1621
+ continue;
1622
+ } else if (arg.startsWith("-") && arg.length > 1) {
1623
+ for (const [index, letter] of [...arg.slice(1)].entries()) {
1624
+ if (!valueShorts.includes(letter))
1625
+ continue;
1626
+ const attached = arg.slice(index + 2);
1627
+ const value = attached === "" ? args[++i] ?? "" : attached;
1628
+ if (STATEMENT_SHORTS[name]?.includes(letter) === true)
1629
+ input.statements.push(value);
1630
+ else if (FILE_SHORTS[name]?.includes(letter) === true)
1631
+ input.files.push(value);
1632
+ else if (DATABASE_SHORTS.has(letter))
1633
+ input.databases.push(databaseOf(value));
1634
+ break;
1635
+ }
1636
+ } else {
1637
+ input.operands.push(arg);
1638
+ }
1639
+ }
1640
+ if (name === "sqlite3")
1641
+ input.statements.push(...input.operands.slice(1));
1642
+ else if (input.operands[0] !== undefined)
1643
+ input.databases.push(databaseOf(input.operands[0]));
1644
+ return input;
1645
+ }
1646
+ function isTestScoped(words, client, input) {
1647
+ if (client === -1)
1648
+ return words.some((word) => TEST_SCOPED.test(word) || SHOT_STACK.test(word));
1649
+ return words.slice(0, client).some((word) => TEST_SCOPED.test(word) || SHOT_STACK.test(word)) || (input?.databases ?? []).some((name) => TEST_SCOPED.test(name));
1650
+ }
1651
+ async function fileText(path, read) {
1652
+ return DEVICE_PATH.test(path) ? null : read(path);
1124
1653
  }
1125
1654
  async function sqlViolation(command, read) {
1126
1655
  const pieces = splitCommand(command);
@@ -1130,23 +1659,27 @@ async function sqlViolation(command, read) {
1130
1659
  const { program, args } = parseCommand(piece.command);
1131
1660
  if (program === "cd")
1132
1661
  inShotStack = SHOT_STACK.test(args[0] ?? "");
1133
- if (inShotStack || isTestScoped(words))
1662
+ const client = words.findIndex((word) => SQL_CLIENTS.has(programOf(word)));
1663
+ const input = client === -1 ? null : clientInput(programOf(words[client] ?? ""), words.slice(client + 1));
1664
+ if (inShotStack || isTestScoped(words, client, input))
1134
1665
  continue;
1135
1666
  if (WRITING_TOOLS.has(program) && !words.some((word) => LIST_OPTIONS.has(word)))
1136
1667
  return SQL_WRITE;
1137
1668
  if (CONSOLE_PROGRAMS.has(program) && words.some((word) => WRITING_CONSOLE.test(word)))
1138
1669
  return SQL_WRITE;
1139
- const client = words.findIndex((word) => SQL_CLIENTS.has(programOf(word)));
1140
- if (client === -1)
1670
+ if (input === null)
1141
1671
  continue;
1142
- const files = words.filter((_, i) => i > client && FILE_OPTIONS.has(words[i - 1] ?? ""));
1143
1672
  const before = index === 0 ? null : parseCommand(pieces[index - 1]?.command ?? "");
1673
+ const isPiped = pieces[index - 1]?.separator === "|";
1674
+ const paths = input.files.filter((path) => !STDIN_PATHS.has(path));
1675
+ const texts = await Promise.all([...paths, ...isPiped && before?.program === "cat" ? before.args.filter((arg) => !arg.startsWith("-")) : []].map(async (path) => fileText(path, read)));
1676
+ if (texts.includes(null))
1677
+ return SQL_UNREADABLE;
1144
1678
  const statements = [
1145
- ...words.filter((_, i) => i > client && STATEMENT_OPTIONS.has(words[i - 1] ?? "")),
1146
- piece.text === piece.command ? "" : piece.text.slice(piece.text.indexOf(`
1147
- `)),
1148
- before !== null && PRINTERS.has(before.program) ? before.args.join(" ") : "",
1149
- ...(await Promise.all(files.map(read))).map((text) => text ?? "")
1679
+ ...input.statements,
1680
+ ...piece.heredocs,
1681
+ isPiped && before !== null && PRINTERS2.has(before.program) ? before.args.join(" ") : "",
1682
+ ...texts.map((text) => text ?? "")
1150
1683
  ];
1151
1684
  if (statements.some(hasWrite))
1152
1685
  return SQL_WRITE;
@@ -1155,32 +1688,130 @@ async function sqlViolation(command, read) {
1155
1688
  }
1156
1689
 
1157
1690
  // ../env/src/files.ts
1158
- var ENV_FILE = /^\.env(?:\..+)?$/;
1691
+ var ENV_FILE = /^(?:\.env(?:\..+)?|.+\.env|\.envrc)$/;
1159
1692
  var TEMPLATE = /\.(?:example|sample|template|dist|defaults)$/;
1693
+ var SKIPPED = new Set(["node_modules", ".git", "vendor"]);
1160
1694
 
1161
1695
  // ../env/src/guard.ts
1162
1696
  var REDIRECT = /^\d*[<>]+&?/;
1697
+ var ENVRC = ".envrc";
1698
+ var PROC_ENVIRON = /^\/proc\/[^/]+\/environ$/;
1699
+ var ENV_NAME_IN_CODE = /(?:^|[^\w.-])(\.env(?:\.[\w.-]*\w)?|\.envrc)(?![\w-])/g;
1700
+ var PYTHON_ENVIRON = /\bos\.environ\b(?!\s*(?:\[|\.get\b))/;
1701
+ var VARIABLE = /\$(?:(\w+)|\{(\w+)\})/;
1702
+ var ASSIGNMENT2 = /^\w+=/;
1703
+ var SHORT_FLAGS = /^-[a-z]+$/i;
1704
+ var OPENER = /^[({]+/;
1705
+ var ENV_SAMPLES = [".env", ".env.local", ".env.production", ".envrc"];
1706
+ var MAX_VARIANTS = 64;
1707
+ var INTERPRETERS = new Set(["bun", "deno", "node", "python", "python3", "ruby", "perl", "php", "lua", "awk", "gawk"]);
1708
+ var CODE_OPTIONS = new Set(["-e", "-c", "-p", "-E", "-pe", "-r", "--eval", "--print", "--command"]);
1709
+ var GREPS = new Set(["grep", "egrep", "fgrep"]);
1710
+ var GREP_VALUE_OPTIONS = new Set(["-e", "-f", "-m", "-A", "-B", "-C", "-d", "-D", "--regexp", "--file", "--max-count", "--include", "--exclude", "--exclude-dir", "--exclude-from", "--context", "--after-context", "--before-context", "--label", "--binary-files", "--directories", "--devices"]);
1711
+ var RG_VALUE_OPTIONS = new Set(["-e", "-f", "-g", "-t", "-T", "-m", "-A", "-B", "-C", "-j", "-d", "--regexp", "--file", "--glob", "--iglob", "--type", "--type-not", "--max-count", "--context", "--after-context", "--before-context", "--threads", "--max-depth", "--max-filesize"]);
1712
+ var PATTERN_OPTIONS = new Set(["-e", "-f", "--regexp", "--file"]);
1713
+ var HIDDEN_FLAGS = /^(?:-u{2,}|--hidden|-\.)$/i;
1714
+ var REGEX_SPECIALS = /[.+^${}()|\\[\]]/g;
1715
+ var NEGATED_CLASS = /^!/;
1716
+ var GLOB_CHAR = /[*?[]/;
1717
+ var HAS_R = /r/i;
1718
+ var DUMP_FLAGS = /^-[a-z]*[px]/;
1719
+ var DECLARERS = new Set(["export", "declare", "local", "readonly", "typeset"]);
1720
+ var WHOLE_TREE_OPERAND = /^(?:(?:\.\.?\/?)+|~\/?|\$HOME\/?|\/)$/;
1721
+ var GIT_PATCHES = new Set(["diff", "log", "show", "whatchanged", "format-patch", "grep"]);
1722
+ var GIT_NO_PATCH = new Set(["--stat", "--numstat", "--shortstat", "--name-only", "--name-status", "--summary", "-s", "--no-patch", "--check", "--quiet"]);
1723
+ var GIT_VALUE_OPTIONS = new Set(["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path"]);
1724
+ var GIT_PATCH_FLAGS = new Set(["-p", "-u", "--patch", "-c", "--cc", "--patch-with-stat", "--patch-with-raw"]);
1725
+ var GIT_EXCLUDE = /^:(?:!|\^|\(exclude\))/;
1726
+ var EXCLUDES_ENV = /^(?:--exclude(?:-dir)?=.*env|(?:--glob=|--iglob=|-g)?!.*env)/;
1727
+ var EXCLUDE_OPTIONS = new Set(["--exclude", "--exclude-dir", "-g", "--glob", "--iglob"]);
1163
1728
  var ENV_DUMP = /(?:console\.\w+|JSON\.stringify|Object\.(?:entries|keys|values)|print)\(\s*process\.env\s*[,)]/;
1164
1729
  var HARMLESS = new Set(["ev", "redact", "ls", "stat", "test", "[", "[[", "touch"]);
1165
1730
  var GIT_HARMLESS = new Set(["check-ignore", "ls-files", "status"]);
1166
1731
  var ENV_PRINTERS = new Set(["env", "printenv"]);
1167
1732
  var DOCKER = new Set(["docker", "docker-compose"]);
1168
- var SHELLS = new Set(["sh", "bash", "zsh", "ash"]);
1733
+ var SHELLS2 = new Set(["sh", "bash", "zsh", "ash"]);
1169
1734
  var DOCKER_VALUE_OPTIONS = new Set(["-f", "--file", "-p", "--project-name", "--profile", "--env-file", "--project-directory", "--ansi", "--progress", "--parallel", "-H", "--host", "-c", "--context", "--config", "-l", "--log-level"]);
1170
1735
  var EXEC_VALUE_OPTIONS = new Set(["-e", "--env", "--env-file", "-u", "--user", "-w", "--workdir", "--index", "--detach-keys"]);
1171
1736
  var CONFIG_LISTS = new Set(["--services", "--volumes", "--profiles", "--images", "--networks", "--hash", "-q", "--quiet"]);
1172
1737
  var FORMAT_OPTIONS = new Set(["-f", "--format"]);
1173
1738
  var WHOLE_FORMAT = /Env|\{\{\s*json\s+\.\s*\}\}|^json$/;
1174
- var USE_EV = '.env files hold secrets that must never reach the transcript, so they are not read, edited or printed directly. Use `ev` instead: `ev list`, `ev get KEY` (masked), `ev grep <regex>` for the keys matching it, `ev show <rev>:<file>` for one at a git revision, `printf %s "$value" | ev set KEY [file]` (`--empty` for an empty value), `ev unset KEY`, `ev mv KEY --from .env --to .env.local`, `ev diff a b`, `ev missing`; and pipe output that may carry a secret through `redact`, as `docker compose exec <svc> printenv | redact` or `docker compose config | redact`. If `ev` is missing: `bun install -g @kaynooo/env-tools`.';
1739
+ var USE_EV = '.env files hold secrets that must never reach the transcript, so they are not read, edited or printed directly. Use `ev` instead: `ev list`, `ev get KEY` (masked), `ev grep <regex>` for the keys matching it, `ev show <rev>:<file>` for one at a git revision, `printf %s "$value" | ev set KEY [file]` (`--empty` for an empty value), `ev unset KEY`, `ev cp KEY --from .env --to .env.local`, `ev mv KEY --from .env --to .env.local`, `ev diff a b`, `ev missing`; and pipe output that may carry a secret through `redact`, as `docker compose exec <svc> printenv | redact` or `docker compose config | redact`. If `ev` is missing: `bun install -g @kaynooo/env-tools`.';
1175
1740
  function isEnvFile(path) {
1176
1741
  const name = path.slice(Math.max(path.lastIndexOf("/"), path.lastIndexOf(":")) + 1);
1177
- return (ENV_FILE.test(name) || name === ".env*") && !TEMPLATE.test(name);
1742
+ return (ENV_FILE.test(name) || name === ".env*" || name === ENVRC) && !TEMPLATE.test(name);
1743
+ }
1744
+ function globRegex(glob) {
1745
+ let source = "";
1746
+ for (let i = 0;i < glob.length; i++) {
1747
+ const char = glob[i] ?? "";
1748
+ const close = char === "[" ? glob.indexOf("]", i + 2) : -1;
1749
+ if (char === "*") {
1750
+ source += ".*";
1751
+ } else if (char === "?") {
1752
+ source += ".";
1753
+ } else if (close === -1) {
1754
+ source += char.replace(REGEX_SPECIALS, String.raw`\$&`);
1755
+ } else {
1756
+ source += `[${glob.slice(i + 1, close).replace(NEGATED_CLASS, "^")}]`;
1757
+ i = close;
1758
+ }
1759
+ }
1760
+ return new RegExp(`^${source}$`);
1178
1761
  }
1179
- function namesEnvFile(word, previous) {
1180
- if (previous === "--env-file" || word.startsWith("--env-file="))
1762
+ function globReachesEnv(path) {
1763
+ const name = path.slice(path.lastIndexOf("/") + 1);
1764
+ if (!name.startsWith(".") || !GLOB_CHAR.test(name))
1181
1765
  return false;
1766
+ const regex = globRegex(name);
1767
+ return ENV_SAMPLES.some((sample) => regex.test(sample));
1768
+ }
1769
+ function reachesEnvFile(path) {
1770
+ return braceExpand(path).some((each) => isEnvFile(each) || globReachesEnv(each));
1771
+ }
1772
+ function targetOf(word) {
1182
1773
  const bare = word.replace(REDIRECT, "");
1183
- return isEnvFile(bare.includes("=") ? bare.slice(bare.indexOf("=") + 1) : bare);
1774
+ return bare.includes("=") ? bare.slice(bare.indexOf("=") + 1) : bare;
1775
+ }
1776
+ function namesEnvFile(word, previous, isGlob) {
1777
+ if (previous === "--env-file" || word.startsWith("--env-file=") || word.startsWith("--exclude") || EXCLUDE_OPTIONS.has(previous ?? ""))
1778
+ return false;
1779
+ const path = targetOf(word);
1780
+ return isEnvFile(path) || PROC_ENVIRON.test(path) || isGlob && globReachesEnv(path);
1781
+ }
1782
+ function variablesOf(command) {
1783
+ const variables = new Map;
1784
+ const add = (assignment) => {
1785
+ const at = assignment.indexOf("=");
1786
+ const name = assignment.slice(0, at);
1787
+ variables.set(name, [...variables.get(name) ?? [], assignment.slice(at + 1)]);
1788
+ };
1789
+ for (const piece of splitCommand(command)) {
1790
+ const { program, args, assignments } = parseCommand(piece.command);
1791
+ const tokens = tokenize(piece.command);
1792
+ const declared = DECLARERS.has(program) ? args.filter((arg) => ASSIGNMENT2.test(arg)) : [];
1793
+ for (const assignment of [...assignments, ...declared])
1794
+ add(assignment);
1795
+ if (tokens[0] === "for" && tokens[2] === "in" && tokens[1] !== undefined)
1796
+ variables.set(tokens[1], [...variables.get(tokens[1]) ?? [], ...tokens.slice(3)]);
1797
+ }
1798
+ return variables;
1799
+ }
1800
+ function variantsOf(word, variables) {
1801
+ let found = [word.text];
1802
+ for (let round = 0;round < 4 && found.some((each) => VARIABLE.test(each)); round++) {
1803
+ found = found.flatMap((each) => {
1804
+ const match = VARIABLE.exec(each);
1805
+ const values = variables.get(match?.[1] ?? match?.[2] ?? "");
1806
+ if (match === null || values === undefined)
1807
+ return [each.replace(VARIABLE, "")];
1808
+ return values.map((value) => each.slice(0, match.index) + value + each.slice(match.index + match[0].length));
1809
+ }).slice(0, MAX_VARIANTS);
1810
+ }
1811
+ return found.flatMap((each) => word.hasBrace ? braceExpand(each) : [each]);
1812
+ }
1813
+ function codeReadsEnv(found) {
1814
+ return found.some((word, i) => (word.isProse || CODE_OPTIONS.has(found[i - 1]?.text ?? "")) && (PYTHON_ENVIRON.test(word.text) || [...word.text.matchAll(ENV_NAME_IN_CODE)].some((match) => isEnvFile(match[1] ?? ""))));
1184
1815
  }
1185
1816
  function firstOperand(words, valueOptions) {
1186
1817
  for (let i = 0;i < words.length; i++) {
@@ -1193,7 +1824,7 @@ function firstOperand(words, valueOptions) {
1193
1824
  return words.length;
1194
1825
  }
1195
1826
  function printsEnv([program = "", ...args]) {
1196
- if (SHELLS.has(program) && args[0] === "-c")
1827
+ if (SHELLS2.has(program) && args[0] === "-c")
1197
1828
  return splitCommand(args[1] ?? "").some((piece) => ENV_PRINTERS.has(tokenize(piece.command)[0] ?? ""));
1198
1829
  return ENV_PRINTERS.has(program);
1199
1830
  }
@@ -1215,24 +1846,79 @@ function dumpsContainerEnv(program, args) {
1215
1846
  const format = after.find((_, i) => FORMAT_OPTIONS.has(after[i - 1] ?? "")) ?? after.find((word) => word.startsWith("--format="))?.slice("--format=".length);
1216
1847
  return format === undefined || WHOLE_FORMAT.test(format);
1217
1848
  }
1218
- function readsEnv(piece) {
1849
+ function readsEnv(piece, variables) {
1219
1850
  const { program, args } = parseCommand(piece.command);
1220
1851
  const found = words(piece.command);
1221
1852
  if (program === "" || HARMLESS.has(program) || program === "git" && GIT_HARMLESS.has(args[0] ?? ""))
1222
1853
  return false;
1223
- return found.some((word, index) => !word.isProse && namesEnvFile(word.text, found[index - 1]?.text));
1854
+ if (INTERPRETERS.has(program) && codeReadsEnv(found))
1855
+ return true;
1856
+ const isExclusion = (word) => program === "git" && GIT_EXCLUDE.test(word.text);
1857
+ return found.some((word, index) => !word.isProse && !isExclusion(word) && variantsOf(word, variables).some((variant) => namesEnvFile(variant, found[index - 1]?.text, word.hasGlob)));
1858
+ }
1859
+ function operandsOf(args, valueOptions) {
1860
+ const operands = [];
1861
+ let hasPattern = false;
1862
+ for (let i = 0;i < args.length; i++) {
1863
+ const arg = args[i] ?? "";
1864
+ if (PATTERN_OPTIONS.has(arg) || arg.startsWith("--regexp=") || arg.startsWith("--file="))
1865
+ hasPattern = true;
1866
+ if (valueOptions.has(arg))
1867
+ i++;
1868
+ else if (!arg.startsWith("-"))
1869
+ operands.push(arg);
1870
+ }
1871
+ return hasPattern ? operands : operands.slice(1);
1872
+ }
1873
+ function searchesEnv(program, args) {
1874
+ const isGrep = GREPS.has(program);
1875
+ if (!isGrep && program !== "rg" && program !== "ag")
1876
+ return false;
1877
+ const isRecursive = args.some((arg) => SHORT_FLAGS.test(arg) && HAS_R.test(arg) || arg === "--recursive" || arg === "--dereference-recursive");
1878
+ const isHidden = args.some((arg) => HIDDEN_FLAGS.test(arg) || program === "ag" && (arg === "-u" || arg === "--unrestricted"));
1879
+ const isLimited = args.some((arg, i) => arg.startsWith("--include") || EXCLUDES_ENV.test(arg) || EXCLUDE_OPTIONS.has(args[i - 1] ?? "") && arg.includes("env"));
1880
+ if (isLimited || (isGrep ? !isRecursive : !isHidden))
1881
+ return false;
1882
+ const operands = operandsOf(args, isGrep ? GREP_VALUE_OPTIONS : RG_VALUE_OPTIONS);
1883
+ return operands.length === 0 || operands.some((operand) => WHOLE_TREE_OPERAND.test(operand));
1884
+ }
1885
+ function dumpsEnvironment(program, args, tokens) {
1886
+ const isBare = args.every((arg) => arg.startsWith("-"));
1887
+ if (program === "printenv" || program === "export")
1888
+ return isBare;
1889
+ if (program === "declare" || program === "typeset")
1890
+ return isBare && args.some((arg) => DUMP_FLAGS.test(arg));
1891
+ if (program === "set")
1892
+ return args.length === 0;
1893
+ return program === "" && tokens.some((token) => token.replace(OPENER, "") === "env");
1894
+ }
1895
+ function printsTrackedEnv(program, args) {
1896
+ if (program !== "git")
1897
+ return false;
1898
+ const at = args.findIndex((arg, i) => !arg.startsWith("-") && !GIT_VALUE_OPTIONS.has(args[i - 1] ?? ""));
1899
+ const name = args[at] ?? "";
1900
+ const rest = args.slice(at + 1);
1901
+ if (!GIT_PATCHES.has(name) || rest.some((arg) => GIT_EXCLUDE.test(arg)))
1902
+ return false;
1903
+ if (name === "grep")
1904
+ return true;
1905
+ if (name === "log" || name === "whatchanged")
1906
+ return rest.some((arg) => GIT_PATCH_FLAGS.has(arg));
1907
+ return !rest.some((arg) => GIT_NO_PATCH.has(arg));
1224
1908
  }
1225
- function touchesEnv(command) {
1909
+ function touchesEnv(command, options = {}) {
1226
1910
  if (ENV_DUMP.test(command))
1227
1911
  return true;
1228
- if (substitutions(command).some(touchesEnv))
1912
+ if (substitutions(command).some((each) => touchesEnv(each, options)))
1229
1913
  return true;
1230
1914
  const pieces = splitCommand(command);
1915
+ const variables = variablesOf(command);
1231
1916
  return pieces.some((piece, index) => {
1232
- if (readsEnv(piece))
1233
- return true;
1234
1917
  const { program, args } = parseCommand(piece.command);
1235
- if (!dumpsContainerEnv(program, args))
1918
+ if (readsEnv(piece, variables) || searchesEnv(program, args))
1919
+ return true;
1920
+ const isDump = dumpsContainerEnv(program, args) || dumpsEnvironment(program, args, tokenize(piece.command)) || options.hasTrackedEnv === true && printsTrackedEnv(program, args);
1921
+ if (!isDump)
1236
1922
  return false;
1237
1923
  for (let at = index;pieces[at]?.separator === "|"; at++) {
1238
1924
  if (parseCommand(pieces[at + 1]?.command ?? "").program === "redact")
@@ -1243,17 +1929,19 @@ function touchesEnv(command) {
1243
1929
  }
1244
1930
 
1245
1931
  // ../git/src/message.ts
1246
- var CONVENTIONAL = /^[a-z]+(?:\([^()\n]+\))?!?: \S/;
1932
+ var CONVENTIONAL = /^(?:feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(?:\([^()\n]+\))?!?: \S/;
1933
+ var MAX_TITLE = 100;
1247
1934
  var GIT_MADE = /^(?:Merge |Revert "|fixup! |squash! |amend! )/;
1248
1935
  var TICKET = /^(?:[\w.-]+\/[\w.-]+)?#\d+ - /;
1249
1936
  var BREAKING = /^BREAKING[ -]CHANGE: /;
1250
1937
  var BREAKING_TITLE = /^[a-z]+(?:\([^()\n]+\))?!: /;
1251
- var TRAILER = /^(?:co-authored-by|signed-off-by|reviewed-by|acked-by|tested-by|reported-by|suggested-by|helped-by|change-id):|^\W*generated with\b/i;
1938
+ var TRAILER = /^(?:co-authored-by|signed-off-by|reviewed-by|acked-by|tested-by|reported-by|suggested-by|helped-by|change-id):|^\W*generated with \W*claude/i;
1252
1939
  var BULLET = /^\s*(?:[-*+]|\d+[.)])\s/;
1253
1940
  var SENTENCE_END = /[.!?:;)\]`'"]$/;
1254
1941
  var LOWER_START = /^\p{Ll}/u;
1255
1942
  var RULE = 'The rule: a `type(scope): title` line, `!` after the scope for a breaking change, type and scope taken from the repository\'s history; then optionally a blank line and one or two lines on what changed, never a bullet list or a hard-wrapped sentence; then a blank line and one line per ticket, its title copied verbatim, `#<n> - <title>` (`owner/repo#<n> - <title>` for another repository). The message ends there: no Co-Authored-By, Signed-off-by or "Generated with" trailer. Only a breaking change gives full details.';
1256
1943
  var TITLE = `The title is no Conventional Commit. ${RULE}`;
1944
+ var TITLE_TOO_LONG = `The title runs past ${MAX_TITLE} characters: shorten it and move the detail to the body. ${RULE}`;
1257
1945
  var TITLE_LINE = `The title takes one line and a blank line follows it. ${RULE}`;
1258
1946
  var TRAILERS = `No trailers, ever: no Co-Authored-By, Signed-off-by or "Generated with" line, no --signoff or --trailer. ${RULE}`;
1259
1947
  var BULLETS = `The body names what changed in a line or two, never a bullet list narrating the diff. ${RULE}`;
@@ -1279,20 +1967,26 @@ function commitMessageViolation(message) {
1279
1967
  return TITLE;
1280
1968
  if (second !== undefined && second !== "")
1281
1969
  return TITLE_LINE;
1282
- if (BREAKING_TITLE.test(title) || body.some((line) => BREAKING.test(line)))
1970
+ if (!GIT_MADE.test(title) && title.length > MAX_TITLE)
1971
+ return TITLE_TOO_LONG;
1972
+ if (BREAKING_TITLE.test(title))
1283
1973
  return null;
1284
- const descriptions = body.filter(isDescription);
1974
+ const footer = body.findIndex((line) => BREAKING.test(line));
1975
+ const described = footer === -1 ? body : body.slice(0, footer);
1976
+ const descriptions = described.filter(isDescription);
1285
1977
  if (descriptions.some((line) => BULLET.test(line)))
1286
1978
  return BULLETS;
1287
1979
  if (descriptions.length > 2)
1288
1980
  return TOO_LONG;
1289
- const isWrapped = body.some((line, i) => isDescription(line) && isDescription(body[i + 1]) && !SENTENCE_END.test(line) && LOWER_START.test(body[i + 1] ?? ""));
1981
+ const isWrapped = described.some((line, i) => isDescription(line) && isDescription(described[i + 1]) && !SENTENCE_END.test(line) && LOWER_START.test(described[i + 1] ?? ""));
1290
1982
  return isWrapped ? WRAPPED : null;
1291
1983
  }
1292
1984
 
1293
1985
  // ../out/src/wrap.ts
1294
- var ASSIGNMENT2 = /^\w+=/;
1295
- var EXCLUDED = /(?:^|\s)(?:--json|--help|--version|-h)(?:\s|$)/;
1986
+ var ASSIGNMENT3 = /^\w+=/;
1987
+ var EXCLUDED = /(?:^|\s)(?:--json|--help|--version|-h|--watch(?:-?all|=\S*)?|--watchAll)(?:\s|$)/;
1988
+ var SHORT_WATCH = /(?:^|\s)(?:\S*\/)?(?:tsc|tsgo|vue-tsc|vitest|jest)\s(?:.*\s)?-w(?:\s|$)/;
1989
+ var LONG_RUNNING = /^(?:dev|watch|serve|start|up|run|preview|storybook)(?:[:-]\S*)?$/;
1296
1990
  var BUN_SCRIPT = /^(?:build|check|lint|typecheck|types|test|ci|verify|compile)(?::|$)/;
1297
1991
  var BUN_TOOLS = new Set(["tsc", "tsgo", "eslint", "keslint", "vitest", "jest", "prettier", "playwright", "biome", "vue-tsc"]);
1298
1992
  var BUN_SUBCOMMANDS = new Set(["install", "i", "add", "remove", "rm", "update", "test", "check", "lint", "build"]);
@@ -1328,9 +2022,9 @@ var RULES = {
1328
2022
  "php-cs-fixer": always,
1329
2023
  rector: always,
1330
2024
  php: (args) => listed(afterOptions(args, PHP_VALUE_OPTIONS)),
1331
- make: always,
2025
+ make: ([first]) => first === undefined || !LONG_RUNNING.test(first),
1332
2026
  check: always,
1333
- just: ([first]) => first !== undefined && !first.startsWith("-"),
2027
+ just: ([first]) => first !== undefined && !first.startsWith("-") && !LONG_RUNNING.test(first),
1334
2028
  composer: ([sub]) => sub === "install" || sub === "update" || sub === "require",
1335
2029
  pip: ([sub]) => sub === "install",
1336
2030
  cargo: ([sub]) => ["build", "test", "check", "clippy"].includes(sub ?? ""),
@@ -1388,14 +2082,14 @@ function withoutFilters(command) {
1388
2082
  return rest;
1389
2083
  }
1390
2084
  function listed(words) {
1391
- const start = words.findIndex((word) => !ASSIGNMENT2.test(word));
2085
+ const start = words.findIndex((word) => !ASSIGNMENT3.test(word));
1392
2086
  const path = words[start];
1393
2087
  if (path === undefined)
1394
2088
  return false;
1395
2089
  return RULES[path.slice(path.lastIndexOf("/") + 1)]?.(words.slice(start + 1)) ?? false;
1396
2090
  }
1397
2091
  function listedSegment(segment) {
1398
- return !EXCLUDED.test(segment) && listed(tokenize(segment));
2092
+ return !EXCLUDED.test(segment) && !SHORT_WATCH.test(segment) && listed(tokenize(segment));
1399
2093
  }
1400
2094
  function isCd(segment) {
1401
2095
  return segment === "cd" || segment.startsWith("cd ");
@@ -1406,14 +2100,14 @@ function shouldWrap(command) {
1406
2100
  return false;
1407
2101
  return segments.every((segment) => isCd(segment) || listedSegment(segment));
1408
2102
  }
1409
- function quoted(value) {
2103
+ function quoted2(value) {
1410
2104
  return `'${value.replaceAll("'", "'\\''")}'`;
1411
2105
  }
1412
2106
  function wrap(command) {
1413
2107
  const trimmed = withoutFilters(command.trim());
1414
- if (scan(trimmed).segments.length <= 1 && !ASSIGNMENT2.test(trimmed))
2108
+ if (scan(trimmed).segments.length <= 1 && !ASSIGNMENT3.test(trimmed))
1415
2109
  return `out -- ${trimmed}`;
1416
- return `out -- sh -c ${quoted(trimmed)}`;
2110
+ return `out -- sh -c ${quoted2(trimmed)}`;
1417
2111
  }
1418
2112
 
1419
2113
  // ../pkg/src/added.ts
@@ -1422,10 +2116,18 @@ var BARE_REDIRECT = /^\d*(?:[<>]|>>)$/;
1422
2116
  var ADDS = {
1423
2117
  bun: new Set(["add", "a", "install", "i"]),
1424
2118
  npm: new Set(["install", "i", "add", "in", "isntall"]),
1425
- pnpm: new Set(["add"]),
2119
+ pnpm: new Set(["add", "i", "install"]),
1426
2120
  yarn: new Set(["add"])
1427
2121
  };
1428
- var VALUE_FLAGS = new Set(["--cwd", "--filter", "-F", "--workspace", "--registry", "--prefix", "-C", "--dir", "--config", "--cache-dir"]);
2122
+ var RUNS = {
2123
+ bun: new Set(["x"]),
2124
+ npm: new Set(["exec", "x"]),
2125
+ pnpm: new Set(["dlx"]),
2126
+ yarn: new Set(["dlx"])
2127
+ };
2128
+ var RUNNERS = new Set(["bunx", "npx"]);
2129
+ var PACKAGE_FLAGS = new Set(["-p", "--package"]);
2130
+ var VALUE_FLAGS = new Set(["--cwd", "--filter", "-F", "--workspace", "--registry", "--prefix", "-C", "--dir", "--config", "--cache-dir", "--loglevel", "--tag", "--save-prefix", "--userconfig", "--omit", "--include", "--fetch-timeout", "--fetch-retries", "--install-strategy", "--cache", ...PACKAGE_FLAGS]);
1429
2131
  var NOT_REGISTRY = /^(?:[./~]|file:|link:|portal:|workspace:|git[+:]|github:|gitlab:|bitbucket:|https?:)|\.tgz$|^[\w.-]+\/[\w.-]+(?:#.*)?$/;
1430
2132
  var NPM_ALIAS = /^(?:@[^/]+\/)?[^@]+@npm:(.+)$/;
1431
2133
  var PUBLISH = /\b(?:ft|cli\.ts)\s+release\b|\b(?:bun|npm|pnpm|yarn)\s+publish\b|\bjust\s+(?:patch|minor|major)\b/;
@@ -1435,16 +2137,38 @@ function addedPackages(command) {
1435
2137
  published ||= PUBLISH.test(piece.command);
1436
2138
  const { program, args } = parseCommand(piece.command);
1437
2139
  const [sub = "", ...rest] = args;
1438
- if (published || !ADDS[program]?.has(sub))
2140
+ if (published)
1439
2141
  return [];
1440
- return rest.flatMap((word, index) => {
1441
- const previous = rest[index - 1] ?? "";
1442
- if (word.startsWith("-") || REDIRECT2.test(word) || VALUE_FLAGS.has(previous) || BARE_REDIRECT.test(previous) || NOT_REGISTRY.test(word))
1443
- return [];
1444
- return [NPM_ALIAS.exec(word)?.[1] ?? word];
1445
- });
2142
+ if (RUNNERS.has(program))
2143
+ return runnerPackages(args);
2144
+ if (RUNS[program]?.has(sub))
2145
+ return runnerPackages(rest);
2146
+ if (!ADDS[program]?.has(sub))
2147
+ return [];
2148
+ return rest.flatMap((word, index) => isSkipped(word, rest[index - 1] ?? "") ? [] : registryName(word));
1446
2149
  });
1447
2150
  }
2151
+ function isSkipped(word, previous) {
2152
+ return word.startsWith("-") || REDIRECT2.test(word) || VALUE_FLAGS.has(previous) || BARE_REDIRECT.test(previous);
2153
+ }
2154
+ function registryName(word) {
2155
+ return NOT_REGISTRY.test(word) ? [] : [NPM_ALIAS.exec(word)?.[1] ?? word];
2156
+ }
2157
+ function runnerPackages(words) {
2158
+ const named = [];
2159
+ for (const [index, word] of words.entries()) {
2160
+ const previous = words[index - 1] ?? "";
2161
+ if (PACKAGE_FLAGS.has(previous))
2162
+ named.push(word);
2163
+ else if (word.startsWith("--package="))
2164
+ named.push(word.slice("--package=".length));
2165
+ else if (!isSkipped(word, previous) && named.length === 0)
2166
+ return registryName(word);
2167
+ else if (!isSkipped(word, previous))
2168
+ break;
2169
+ }
2170
+ return named.flatMap(registryName);
2171
+ }
1448
2172
 
1449
2173
  // ../pkg/src/pin.ts
1450
2174
  var LOCKFILES = [
@@ -1573,11 +2297,16 @@ var COMMIT_VALUE_LONGS = new Set(["--message", "--file", "--reuse-message", "--r
1573
2297
  var SHORT_CLUSTER = /^-[A-Z]+$/i;
1574
2298
  var STAGES_ALL = /[Au]/;
1575
2299
  var ALL_TAGS = new Set(["--tags", "--follow-tags"]);
2300
+ var TOP_MAGIC = /^:(?:\/|\([^)]*\))/;
2301
+ var RISKY_CONFIG = /^(?:alias\.|core\.(?:sshcommand|hookspath|pager|editor|fsmonitor|askpass|gitproxy)|credential\.|diff\.external|sequence\.editor|pager\.|filter\.|gpg\..*program)/i;
2302
+ var RISKY_ENV = /^GIT_(?:CONFIG_(?:COUNT|PARAMETERS)|EXTERNAL_DIFF|ASKPASS|PROXY_COMMAND|PAGER)=/;
2303
+ var READS_CONFIG = new Set(["--get", "--get-all", "--get-regexp", "--list", "-l", "--unset", "--unset-all", "--show-origin", "--show-scope"]);
1576
2304
  var STAGE_BY_PATH = `Stage by path: the tree carries unrelated work and peer sessions edit it live. ${USE_GC}`;
1577
2305
  var STASH = "Never `git stash`: a pop with nothing saved applies someone else's old stash. To prove a test fails without your change, `proof --keep <test> -- <cmd>` runs it at HEAD in a temporary worktree and again here; `base -- <cmd>` runs any command at HEAD (`--ref <rev>` another commit), the working tree untouched. To set edits aside before a cherry-pick, a rebase or a checkout, commit them with `gc` first, or do the other work in `git worktree add`.";
1578
2306
  var ADD_ALL = `\`git add\` over the whole tree is refused. ${STAGE_BY_PATH}`;
1579
2307
  var COMMIT_ALL = `\`git commit -a\` stages the whole tree and is refused. ${STAGE_BY_PATH}`;
1580
2308
  var PUSH = "`git push` runs only when the user asked for a push in their latest prompt: say the commit is ready and let them ask.";
2309
+ var CONFIG_OVERRIDE = "Never set git config that runs a program or defines a command (`-c alias.*`, `--config-env`, `core.sshCommand`, `core.hooksPath`, `core.pager`, credential helpers, `git config alias.*`): it sidesteps every rule here. Run the git command itself.";
1581
2310
  var TAGS_APART = "A tag goes out with its branch in one `git push --atomic origin <branch> <tag>`, never a separate `git push --tags`.";
1582
2311
  function subcommandOf(args) {
1583
2312
  for (let i = 0;i < args.length; i++) {
@@ -1589,6 +2318,35 @@ function subcommandOf(args) {
1589
2318
  }
1590
2319
  return { name: "", rest: [] };
1591
2320
  }
2321
+ function overridesConfig(args) {
2322
+ for (let i = 0;i < args.length; i++) {
2323
+ const arg = args[i] ?? "";
2324
+ if (arg === "--config-env" || arg.startsWith("--config-env="))
2325
+ return true;
2326
+ if (arg === "-c" && RISKY_CONFIG.test(args[i + 1] ?? ""))
2327
+ return true;
2328
+ if (GLOBAL_VALUE_OPTIONS.has(arg))
2329
+ i++;
2330
+ else if (!arg.startsWith("-"))
2331
+ return false;
2332
+ }
2333
+ return false;
2334
+ }
2335
+ function isWholeTree(arg) {
2336
+ if (WHOLE_TREE.has(arg))
2337
+ return true;
2338
+ if (arg.startsWith("-"))
2339
+ return SHORT_CLUSTER.test(arg) && STAGES_ALL.test(arg);
2340
+ const parts = [];
2341
+ for (const part of arg.replace(TOP_MAGIC, "").split("/")) {
2342
+ if (part === ".." && parts.length > 0 && parts.at(-1) !== "..")
2343
+ parts.pop();
2344
+ else if (part !== "" && part !== ".")
2345
+ parts.push(part);
2346
+ }
2347
+ const path = parts.join("/");
2348
+ return path === "" || path === ".." || WHOLE_TREE.has(path);
2349
+ }
1592
2350
  function commitsAll(args) {
1593
2351
  for (let i = 0;i < args.length; i++) {
1594
2352
  const arg = args[i] ?? "";
@@ -1616,13 +2374,19 @@ function commitsAll(args) {
1616
2374
  }
1617
2375
  function gitViolation(command, isPushAsked = false) {
1618
2376
  for (const piece of splitCommand(command)) {
1619
- const { program, args } = parseCommand(piece.command);
2377
+ const { program, args, assignments } = parseCommand(piece.command);
2378
+ if (assignments.some((assignment) => RISKY_ENV.test(assignment)))
2379
+ return CONFIG_OVERRIDE;
1620
2380
  if (program !== "git")
1621
2381
  continue;
2382
+ if (overridesConfig(args))
2383
+ return CONFIG_OVERRIDE;
1622
2384
  const { name, rest } = subcommandOf(args);
2385
+ if (name === "config" && rest.length > 1 && rest.some((arg) => RISKY_CONFIG.test(arg)) && !rest.some((arg) => READS_CONFIG.has(arg)))
2386
+ return CONFIG_OVERRIDE;
1623
2387
  if (name === "stash" && !READ_ONLY_STASH.has(rest[0] ?? ""))
1624
2388
  return STASH;
1625
- if (name === "add" && rest.some((arg) => WHOLE_TREE.has(arg) || SHORT_CLUSTER.test(arg) && STAGES_ALL.test(arg)))
2389
+ if (name === "add" && rest.some(isWholeTree))
1626
2390
  return ADD_ALL;
1627
2391
  if (name === "commit" && commitsAll(rest))
1628
2392
  return COMMIT_ALL;
@@ -1723,19 +2487,63 @@ function inlineMessage(parts) {
1723
2487
  }
1724
2488
 
1725
2489
  // src/remote.ts
1726
- var SHELL_PROGRAMS = new Set(["ssh", "scp", "sftp", "mosh", "autossh"]);
2490
+ var SHELL_PROGRAMS = new Set(["ssh", "scp", "sftp", "mosh", "mosh-client", "autossh"]);
2491
+ var SOCKET_PROGRAMS = new Set(["nc", "ncat", "netcat", "telnet", "socat"]);
2492
+ var URL_PROGRAMS = new Set(["git", "docker", "docker-compose"]);
2493
+ var DECLARERS2 = new Set(["export", "declare", "typeset"]);
1727
2494
  var RSYNC_REMOTE = /^(?:[\w.-]+@)?[\w.-]{2,}:/;
1728
2495
  var SSH_URL = /^ssh:\/\//;
2496
+ var SSH_URL_WORD = /^(?:[\w-]+=)?ssh:\/\//;
2497
+ var LOOPBACK = /(?:^|[:=@[])(?:localhost|127(?:\.\d+){3}|::1)(?:[:\]/]|$)/;
2498
+ var LISTENS = /^-[A-KM-Z]*L[A-Z]*$/i;
2499
+ var DOCKER_VALUE_OPTIONS2 = new Set(["-c", "--context", "-H", "--host", "-l", "--log-level", "--config", "--tlscacert", "--tlscert", "--tlskey"]);
2500
+ var CONTEXT_CHANGES = new Set(["use", "create", "update", "import"]);
2501
+ var KUBECTL_SERVER = /^(?:--server(?:=|$)|-s$)/;
2502
+ var REMOTE_VARIABLE = /^GIT_SSH(?:_COMMAND)?=/;
1729
2503
  var REMOTE = "Remote servers are off limits (no SSH, no deploys, no remote commands). Hand the user the exact command to run themselves.";
2504
+ function dockerContext(args) {
2505
+ let context = null;
2506
+ for (let i = 0;i < args.length; i++) {
2507
+ const arg = args[i] ?? "";
2508
+ if (arg.startsWith("--context="))
2509
+ context = arg.slice("--context=".length);
2510
+ else if (arg === "-c" || arg === "--context")
2511
+ context = args[++i] ?? null;
2512
+ else if (DOCKER_VALUE_OPTIONS2.has(arg))
2513
+ i++;
2514
+ else if (!arg.startsWith("-"))
2515
+ return { context, name: arg, rest: args.slice(i + 1) };
2516
+ }
2517
+ return { context, name: "", rest: [] };
2518
+ }
2519
+ function isDockerRemote(args, assignments) {
2520
+ const { context, name, rest } = dockerContext(args);
2521
+ const fromEnv = assignments.find((assignment) => assignment.startsWith("DOCKER_CONTEXT="))?.slice("DOCKER_CONTEXT=".length);
2522
+ if ([context, fromEnv].some((each) => each !== null && each !== undefined && each !== "default"))
2523
+ return true;
2524
+ return name === "context" && CONTEXT_CHANGES.has(rest[0] ?? "");
2525
+ }
2526
+ function isSocketRemote(args) {
2527
+ return !args.some((arg) => LISTENS.test(arg) || LOOPBACK.test(arg));
2528
+ }
1730
2529
  function isRemote(text) {
1731
2530
  const { program, args, assignments } = parseCommand(text);
2531
+ const declared = DECLARERS2.has(program) ? args : [];
1732
2532
  if (SHELL_PROGRAMS.has(program))
1733
2533
  return true;
2534
+ if (SOCKET_PROGRAMS.has(program))
2535
+ return isSocketRemote(args);
1734
2536
  if (program === "rsync")
1735
2537
  return args.some((arg) => !arg.startsWith("-") && RSYNC_REMOTE.test(arg));
1736
- if (assignments.some((assignment) => assignment.startsWith("DOCKER_HOST=") && SSH_URL.test(assignment.slice("DOCKER_HOST=".length))))
2538
+ if (program === "kubectl")
2539
+ return args.some((arg) => KUBECTL_SERVER.test(arg));
2540
+ if ([...assignments, ...declared].some((assignment) => REMOTE_VARIABLE.test(assignment)))
1737
2541
  return true;
1738
- return program === "docker" && tokenize(text).some((word) => SSH_URL.test(word));
2542
+ if ([...assignments, ...declared].some((assignment) => assignment.startsWith("DOCKER_HOST=") && SSH_URL.test(assignment.slice("DOCKER_HOST=".length))))
2543
+ return true;
2544
+ if (program === "docker" && isDockerRemote(args, assignments))
2545
+ return true;
2546
+ return URL_PROGRAMS.has(program) && tokenize(text).some((word) => SSH_URL_WORD.test(word));
1739
2547
  }
1740
2548
  function remoteViolation(command) {
1741
2549
  return splitCommand(command).some((piece) => isRemote(piece.command)) ? REMOTE : null;
@@ -2066,6 +2874,7 @@ function replaceViolation(command) {
2066
2874
  }
2067
2875
 
2068
2876
  // src/check.ts
2877
+ var UNREADABLE_MESSAGE = "The commit message cannot be read here (a variable, a substitution, a missing file or no heredoc on stdin), so it cannot be checked. Write it literally with `-m` or a `$(cat <<'EOF' ... EOF)` heredoc.";
2069
2878
  function absolute(path, cwd) {
2070
2879
  return path.startsWith("/") ? path : `${cwd}/${path}`;
2071
2880
  }
@@ -2081,6 +2890,8 @@ function commitViolation(command, { cwd, read }) {
2081
2890
  message = inlineMessage(source.parts);
2082
2891
  else if (file !== "-")
2083
2892
  message = read(absolute(file, cwd));
2893
+ if (message === null && (source.parts.length > 0 || file !== null))
2894
+ return UNREADABLE_MESSAGE;
2084
2895
  const violation = message === null ? null : commitMessageViolation(message);
2085
2896
  if (violation !== null)
2086
2897
  return violation;
@@ -2096,9 +2907,6 @@ function pinnedManagers(start, exists) {
2096
2907
  return null;
2097
2908
  }
2098
2909
  }
2099
- function commandsRun(command) {
2100
- return [command, ...substitutions(command).flatMap(commandsRun)];
2101
- }
2102
2910
  function mismatches(command, { cwd, home, exists }) {
2103
2911
  return installRequests(command, cwd, home).flatMap((request) => {
2104
2912
  const mismatch = mismatchFor(request, pinnedManagers(request.dir, exists));
@@ -2114,13 +2922,22 @@ async function checkCommand(command, context) {
2114
2922
  };
2115
2923
  const run = commandsRun(command);
2116
2924
  const first = (messages) => messages.find((message) => message !== null) ?? null;
2117
- add("remote", first(run.map(remoteViolation)));
2118
- add("git", first(run.map((each) => gitViolation(each, context.isPushAsked))));
2119
- const commit = commitViolation(command, context);
2925
+ const guarded = async (rule, check) => {
2926
+ try {
2927
+ return await check();
2928
+ } catch (error) {
2929
+ context.onError?.(rule, error, command);
2930
+ return `The ${rule} rule crashed on this command (${error instanceof Error ? error.message : String(error)}), so it is refused. Simplify the command or run it yourself.`;
2931
+ }
2932
+ };
2933
+ const readSql = async (path) => Promise.resolve(context.read(absolute(path, context.cwd)));
2934
+ add("remote", await guarded("remote", () => first(run.map(remoteViolation))));
2935
+ add("git", await guarded("git", () => first(run.map((each) => gitViolation(each, context.isPushAsked)))));
2936
+ const commit = await guarded("commit", () => first(run.map((each) => commitViolation(each, context))));
2120
2937
  add("commit", commit === null ? null : `${commit} ${USE_GC}`);
2121
- add("sql", first(await Promise.all(run.map(async (each) => sqlViolation(each, async (path) => Promise.resolve(context.read(absolute(path, context.cwd))))))));
2122
- add("env", touchesEnv(command) ? USE_EV : null);
2123
- add("edit", context.canSub ? first(run.map(replaceViolation)) : null);
2938
+ add("sql", await guarded("sql", async () => first(await Promise.all(run.map(async (each) => sqlViolation(each, readSql))))));
2939
+ add("env", await guarded("env", () => run.some((each) => touchesEnv(each, { hasTrackedEnv: context.hasTrackedEnv === true })) ? USE_EV : null));
2940
+ add("edit", context.canSub ? await guarded("edit", () => first(run.map(replaceViolation))) : null);
2124
2941
  const found = mismatches(command, context);
2125
2942
  const refused = found.find((mismatch) => mismatch.rewrite === null || !context.canRewrite);
2126
2943
  const rewrite = refused?.rewrite ?? null;
@@ -2133,10 +2950,10 @@ async function checkCommand(command, context) {
2133
2950
  }
2134
2951
  if (context.canRewrite && context.canWrap && shouldWrap(rewritten))
2135
2952
  rewritten = wrap(rewritten);
2136
- return { violations, command: violations.length > 0 ? command : rewritten, notes: violations.length > 0 ? [] : notes, packages: addedPackages(command) };
2953
+ return { violations, command: violations.length > 0 ? command : rewritten, notes: violations.length > 0 ? [] : notes, packages: [...new Set(run.flatMap(addedPackages))] };
2137
2954
  }
2138
2955
  function checkPath(path) {
2139
- return isEnvFile(path) ? { rule: "env", message: USE_EV } : null;
2956
+ return reachesEnvFile(path) ? { rule: "env", message: USE_EV } : null;
2140
2957
  }
2141
2958
 
2142
2959
  // src/bins.ts
@@ -2145,7 +2962,7 @@ var BINS = {
2145
2962
  fix: "`fix`: runs the repo's fixers and formatters (its fix, lint:fix and format scripts, just fmt, go fmt, cargo fmt, ruff) and lists the files they changed.",
2146
2963
  errs: "`errs [log]`, `errs -- <cmd>`: turns tsc, tsgo, eslint, bun check and bun test output into one `file:line:col: message (rule)` line per error, counting the lines it did not recognise (`--json`); use it instead of reading raw type, lint or test logs.",
2147
2964
  db: '`db "<sql>"`: one read-only statement against the Postgres in DATABASE_URL (`-e KEY` for another) or, without one, the compose project\'s running Postgres service (`-s <service>`, `-d <database>`), credentials never shown, a write printed for the user to run; `db tables`, `db describe <table>`, `db dbs`.',
2148
- ev: "`ev`: .env files read and edited with values masked (`ev list`, `ev get KEY`, `ev grep <regex>`, `ev show <rev>:<file>`, `ev set`, `ev diff`, `ev missing`); `ev audit [dir]` finds env files others can read or git tracks, and compose files writing secrets out.",
2965
+ ev: "`ev`: .env files read and edited with values masked (`ev list`, `ev get KEY`, `ev grep <regex>`, `ev show <rev>:<file>`, `ev set`, `ev diff`, `ev missing`); `ev audit [dir]` finds env files others can read or git tracks, and compose files writing secrets out; `ev pins [dir]` lists the env vars reaching outside that the tests of an app leave unpinned.",
2149
2966
  redact: "`redact`: masks secrets in piped output, `<cmd> | redact`.",
2150
2967
  pkg: "`pkg <name>[@version]`: checks an npm package before it is added: whether the name exists or is a near miss of a popular one, the newest version at least two weeks old, licence, install scripts; `bun add`, `npm install`, `pnpm add` and `yarn add` run it first and are refused on what it flags. `pkg bump <@scope/lib> [version]` waits until the registry serves a release, then `bun add --exact`s it in every consumer under ~/Workspace (`--dry-run`), `pkg consumers <name>` lists them; use it instead of a `bun info` and sleep loop and a `bun add` per repo.",
2151
2968
  stack: "`stack port <name>`: the port a service name always hashes to, for every host port a compose file publishes; `stack audit [dir]` cross-checks release.config.ts against its compose files.",
@@ -2175,9 +2992,21 @@ ${lines.join(`
2175
2992
 
2176
2993
  // src/hook.ts
2177
2994
  var EVENTS = ["pre-tool-use", "user-prompt-submit", "session-start"];
2178
- var PUSH_WORDS = /\b(?:push|pousse)/i;
2995
+ var PUSH_WORD = /\b(?:push|pousser?|poussez)\b/i;
2996
+ var PUSH_NOUN = /\bpush[\s-]+(?:notifications?|messages?|tokens?|events?|services?|apis?|subscriptions?|workers?|servers?)\b/gi;
2997
+ var NOT_A_REQUEST = /\b(?:don'?t|do not|never|not|no|without|avoid|stop|can'?t|cannot|won'?t|shouldn'?t|did|didn'?t|does|doesn'?t|already|ne|pas|jamais|sans|surtout)\b/i;
2998
+ var CLAUSE_BREAKS = /[.!?;,\n]+/;
2179
2999
  var SESSION_ID = /^[\w-]+$/;
2180
- var FILE_TOOLS = new Set(["Read", "Edit", "Write"]);
3000
+ var FILE_TOOLS = new Map([["Read", "file_path"], ["Edit", "file_path"], ["MultiEdit", "file_path"], ["Write", "file_path"], ["NotebookEdit", "notebook_path"], ["Grep", "path"], ["Glob", "path"]]);
3001
+ var GIT_WORD = /\bgit\b/;
3002
+ var GIT_PRINT_WORD = /\b(?:diff|log|show|whatchanged|format-patch|grep)\b/;
3003
+ var TRACKED_ENV = [":(glob)**/.env", ":(glob)**/.env.*", ":(glob)**/.envrc"];
3004
+ function asksForPush(prompt) {
3005
+ return prompt.split(CLAUSE_BREAKS).some((clause) => {
3006
+ const asked = clause.replace(PUSH_NOUN, "");
3007
+ return PUSH_WORD.test(asked) && !NOT_A_REQUEST.test(asked);
3008
+ });
3009
+ }
2181
3010
  function pushFile(sessionId, { stateDir }) {
2182
3011
  return sessionId !== undefined && SESSION_ID.test(sessionId) ? `${stateDir}/push-asked/${sessionId}` : null;
2183
3012
  }
@@ -2199,25 +3028,36 @@ async function bashCall(command, input, machine) {
2199
3028
  canWrap: false,
2200
3029
  canSub: machine.has("sub"),
2201
3030
  read: machine.read,
2202
- exists: machine.exists
3031
+ exists: machine.exists,
3032
+ hasTrackedEnv: GIT_WORD.test(command) && GIT_PRINT_WORD.test(command) && await hasTrackedEnv(cwd, machine),
3033
+ onError: (rule, error, each) => machine.log?.(`${new Date().toISOString()} ${rule} crashed: ${error instanceof Error ? error.stack ?? error.message : String(error)}
3034
+ command: ${each}
3035
+ `)
2203
3036
  });
2204
3037
  if (verdict.violations.length > 0 || verdict.packages.length === 0 || !machine.has("pkg"))
2205
3038
  return deny(verdict.violations);
2206
3039
  const checked = await machine.run(["pkg", "check", ...verdict.packages], cwd);
2207
- return checked.code === 1 ? deny([{ rule: "pkg", message: `pkg refused what this adds, so it was not run:
2208
- ${checked.text.trim()}` }]) : null;
3040
+ if (checked.code === 0)
3041
+ return null;
3042
+ const message = checked.code === 1 ? "pkg refused what this adds, so it was not run" : `pkg check failed (exit ${checked.code}), so what this adds was not checked and it was not run`;
3043
+ return deny([{ rule: "pkg", message: `${message}:
3044
+ ${checked.text.trim()}` }]);
3045
+ }
3046
+ async function hasTrackedEnv(cwd, machine) {
3047
+ const listed = await machine.run(["git", "ls-files", "-z", "--", ...TRACKED_ENV], cwd);
3048
+ return listed.code === 0 && listed.text.split("\x00").some(isEnvFile);
2209
3049
  }
2210
3050
  function preToolUse(input, machine) {
2211
3051
  const { tool_name: tool, tool_input: given = {} } = input;
2212
3052
  if (tool === "Bash" && typeof given.command === "string")
2213
3053
  return bashCall(given.command, input, machine);
2214
- if (tool !== undefined && FILE_TOOLS.has(tool) && typeof given.file_path === "string")
2215
- return deny([checkPath(given.file_path)].filter((each) => each !== null));
2216
- return null;
3054
+ const field = tool === undefined ? undefined : FILE_TOOLS.get(tool);
3055
+ const paths = [field === undefined ? undefined : given[field], tool === "Grep" ? given.glob : undefined];
3056
+ return deny(paths.flatMap((path) => typeof path === "string" ? [checkPath(path)] : []).filter((each) => each !== null));
2217
3057
  }
2218
3058
  function userPromptSubmit(input, machine) {
2219
3059
  const file = pushFile(input.session_id, machine);
2220
- if (file !== null && PUSH_WORDS.test(input.prompt ?? ""))
3060
+ if (file !== null && asksForPush(input.prompt ?? ""))
2221
3061
  machine.write(file, "");
2222
3062
  else if (file !== null)
2223
3063
  machine.remove(file);
@@ -2258,13 +3098,15 @@ var pathOptions = {
2258
3098
  var hookUsage = `guard hook <${EVENTS.join("|")}>
2259
3099
 
2260
3100
  Answers a Claude Code hook: reads the event JSON on stdin and prints the reply JSON, nothing when the call goes ahead untouched.
2261
- pre-tool-use refuses a Bash call breaking a rule, an install pkg refuses, and a Read, Edit or Write of an env file; user-prompt-submit remembers whether the prompt asked for a push; session-start lists the installed agent-tools bins.
3101
+ pre-tool-use refuses a Bash call breaking a rule, an install pkg refuses (or fails to check), and a file tool (Read, Edit, MultiEdit, Write, NotebookEdit, Grep, Glob) reaching an env file; user-prompt-submit remembers whether the prompt asked for a push; session-start lists the installed agent-tools bins.
2262
3102
  A failure exits 1, which Claude Code reports without blocking the call.
2263
3103
 
2264
3104
  Exits 0 when it answered, 1 on a failure or an unknown event (never 2, which would block the call).`;
3105
+ var MAX_READ = 1e6;
2265
3106
  function readText(path) {
2266
3107
  try {
2267
- return readFileSync(path, "utf8");
3108
+ const stats = statSync(path, { throwIfNoEntry: false });
3109
+ return stats?.isFile() === true && stats.size <= MAX_READ ? readFileSync(path, "utf8") : null;
2268
3110
  } catch {
2269
3111
  return null;
2270
3112
  }
@@ -2283,7 +3125,7 @@ ${checkUsage}`);
2283
3125
  const given = positionals[0] ?? "-";
2284
3126
  const command = given === "-" ? await readInput("-", { noStdin: "give the command, or pipe it on stdin" }) : given;
2285
3127
  const context = {
2286
- cwd: values.cwd ?? process6.cwd(),
3128
+ cwd: values.cwd ?? process7.cwd(),
2287
3129
  home: homedir(),
2288
3130
  isPushAsked: values["push-asked"] === true,
2289
3131
  canRewrite: true,
@@ -2317,9 +3159,10 @@ ${pathUsage}`);
2317
3159
  }
2318
3160
  function machine() {
2319
3161
  const home = homedir();
3162
+ const stateDir = `${process7.env.XDG_STATE_HOME ?? `${home}/.local/state`}/guard`;
2320
3163
  return {
2321
3164
  home,
2322
- stateDir: `${process6.env.XDG_STATE_HOME ?? `${home}/.local/state`}/guard`,
3165
+ stateDir,
2323
3166
  has: (bin) => Bun.which(bin) !== null,
2324
3167
  run: async (argv, cwd) => runCommand(argv, { cwd }),
2325
3168
  read: readText,
@@ -2328,7 +3171,13 @@ function machine() {
2328
3171
  mkdirSync(dirname(path), { recursive: true });
2329
3172
  writeFileSync(path, text);
2330
3173
  },
2331
- remove: (path) => rmSync(path, { force: true })
3174
+ remove: (path) => rmSync(path, { force: true }),
3175
+ log: (text) => {
3176
+ try {
3177
+ mkdirSync(stateDir, { recursive: true });
3178
+ appendFileSync(`${stateDir}/errors.log`, text);
3179
+ } catch {}
3180
+ }
2332
3181
  };
2333
3182
  }
2334
3183
  async function hookCommand(args) {
@@ -2343,7 +3192,7 @@ ${hookUsage}`, 1);
2343
3192
  console.log(JSON.stringify(reply));
2344
3193
  return 0;
2345
3194
  }
2346
- process6.exitCode = await runCli({
3195
+ process7.exitCode = await runCli({
2347
3196
  name: "guard",
2348
3197
  version,
2349
3198
  about: "the safety rules a Claude Code Bash call is held to, each refusal naming the route",