@kaynooo/forgejo-tools 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.js +1112 -734
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // @bun
3
3
 
4
4
  // src/cli.ts
5
- import process14 from "process";
5
+ import process15 from "process";
6
6
 
7
7
  // ../kit/src/args.ts
8
8
  import process4 from "process";
@@ -395,6 +395,26 @@ function printJson(value) {
395
395
  console.log(JSON.stringify(value, undefined, 2));
396
396
  }
397
397
 
398
+ // ../kit/src/text.ts
399
+ var ANSI = new RegExp(`${String.fromCharCode(27)}\\[[\\d;?]*[a-z]`, "gi");
400
+ function distance(a, b) {
401
+ let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
402
+ for (const [i, left] of [...a].entries()) {
403
+ const current = [i + 1];
404
+ for (const [j, right] of [...b].entries())
405
+ current.push(Math.min((previous[j + 1] ?? 0) + 1, (current[j] ?? 0) + 1, (previous[j] ?? 0) + (left === right ? 0 : 1)));
406
+ previous = current;
407
+ }
408
+ return previous[b.length] ?? 0;
409
+ }
410
+ function closest(name, names) {
411
+ const started = names.find((known) => known.startsWith(name));
412
+ if (started !== undefined)
413
+ return started;
414
+ const [best] = names.map((known) => ({ known, far: distance(name, known) })).sort((a, b) => a.far - b.far);
415
+ return best !== undefined && best.far <= (name.length < 4 ? 1 : 2) ? best.known : undefined;
416
+ }
417
+
398
418
  // ../kit/src/args.ts
399
419
  function jsonOption(holds) {
400
420
  return { type: "boolean", description: holds === undefined ? "print JSON" : `print JSON: ${holds}` };
@@ -414,8 +434,8 @@ function fail(message, code = 2, hint) {
414
434
  }
415
435
 
416
436
  class UsageError extends Exit {
417
- constructor(message) {
418
- super(message, 2);
437
+ constructor(message, hint) {
438
+ super(message, 2, hint);
419
439
  }
420
440
  }
421
441
  function badUsage(message) {
@@ -438,6 +458,7 @@ var completion = { active: false };
438
458
  var OPTIONAL = /\[[^\]]+\]/g;
439
459
  var USAGE_PREFIX = /^usage:\s*/i;
440
460
  var FULL_STOP = /\.$/;
461
+ var DASHES = /^-+/;
441
462
  function usageLine(usage, colors = paint(process4.stdout)) {
442
463
  return `${colors.bold("Usage:")} ${usage.replace(USAGE_PREFIX, "").replaceAll(OPTIONAL, (part) => colors.dim(part))}`;
443
464
  }
@@ -474,10 +495,10 @@ function optionRows(options, colors = paint(process4.stdout)) {
474
495
  const rows = Object.entries(options).map(([name, option]) => {
475
496
  const short = option.short === undefined ? " " : `${colors.cyan(`-${option.short}`)}, `;
476
497
  const value = option.type === "string" ? ` ${colors.dim("<value>")}` : "";
477
- return { flag: `${short}${colors.cyan(`--${name}`)}${value}`, width: `-x, --${name}`.length + (option.type === "string" ? " <value>".length : 0), description: option.description };
498
+ return { flag: `${short}${colors.cyan(`--${name}`)}${value}`, width: `-x, --${name}`.length + (option.type === "string" ? " <value>".length : 0), description: option.description ?? "" };
478
499
  });
479
500
  const width = Math.max(0, ...rows.map((row) => row.width));
480
- return rows.map((row) => ` ${row.flag}${" ".repeat(width - row.width)} ${colors.dim(row.description)}`);
501
+ return rows.map((row) => row.description === "" ? ` ${row.flag}` : ` ${row.flag}${" ".repeat(width - row.width)} ${colors.dim(row.description)}`);
481
502
  }
482
503
  var OUTPUT_OPTIONS = {
483
504
  color: { type: "string", description: "auto (on a terminal alone), always or never" },
@@ -509,7 +530,8 @@ function parse(args, usage, options, settings = {}) {
509
530
  try {
510
531
  parsed = parseArgs({ args: joinDashValues(own, all), options: all, allowPositionals: true, allowNegative: settings.allowNegative === true, strict: true });
511
532
  } catch (error) {
512
- badUsage(parseError(error));
533
+ const message = parseError(error);
534
+ throw new UsageError(message, nearestOption(message, all, settings.allowNegative === true));
513
535
  }
514
536
  const values = parsed.values;
515
537
  applyOutput("color" in output ? values.color : undefined, "plain" in output ? values.plain : undefined);
@@ -552,12 +574,29 @@ function parseError(error) {
552
574
  const message = upTo(error instanceof Error ? error.message : String(error), ". ");
553
575
  return `${message.charAt(0).toLowerCase()}${message.slice(1)}`;
554
576
  }
577
+ var UNKNOWN_OPTION = /^unknown option '(-[^']+)'/;
578
+ function nearestOption(message, options, allowNegative) {
579
+ const typed = UNKNOWN_OPTION.exec(message)?.[1];
580
+ if (typed === undefined)
581
+ return;
582
+ const word = typed.replace(DASHES, "");
583
+ if (word.length < 2 && !typed.startsWith("--"))
584
+ return;
585
+ const known = Object.entries(options).flatMap(([name, option]) => [
586
+ ...option.short === undefined ? [] : [`-${option.short}`],
587
+ `--${name}`,
588
+ ...allowNegative && option.type === "boolean" ? [`--no-${name}`] : []
589
+ ]);
590
+ const meant = closest(word, known.map((flag) => flag.replace(DASHES, "")));
591
+ return meant === undefined ? undefined : `did you mean ${known.find((flag) => flag.replace(DASHES, "") === meant)}?`;
592
+ }
593
+ var WHOLE = /^\d+$/;
555
594
  function wholeNumber(value, name, fallback, min = 1) {
556
595
  if (value === undefined)
557
596
  return fallback;
558
597
  const number = Number(value);
559
- if (!Number.isInteger(number) || number < min)
560
- badUsage(`--${name} takes a whole number from ${min}, not ${value}`);
598
+ if (!WHOLE.test(value) || !Number.isSafeInteger(number) || number < min)
599
+ badUsage(`${name.startsWith("<") ? name : `--${name}`} takes a whole number from ${min}, not ${value}`);
561
600
  return number;
562
601
  }
563
602
  var DURATION = /^(\d+(?:\.\d+)?)(ms|[smh])?$/;
@@ -582,229 +621,14 @@ async function readInput(path, { noStdin = "nothing is piped on stdin" } = {}) {
582
621
  fail(`no file ${path}`);
583
622
  return file.text();
584
623
  }
585
- // ../kit/src/cli.ts
586
- import process5 from "process";
587
-
588
- // ../kit/src/text.ts
589
- var ANSI = new RegExp(`${String.fromCharCode(27)}\\[[\\d;?]*[a-z]`, "gi");
590
- function distance(a, b) {
591
- let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
592
- for (const [i, left] of [...a].entries()) {
593
- const current = [i + 1];
594
- for (const [j, right] of [...b].entries())
595
- current.push(Math.min((previous[j + 1] ?? 0) + 1, (current[j] ?? 0) + 1, (previous[j] ?? 0) + (left === right ? 0 : 1)));
596
- previous = current;
597
- }
598
- return previous[b.length] ?? 0;
599
- }
624
+ // ../kit/src/progress.ts
625
+ import process6 from "process";
600
626
 
601
- // ../kit/src/cli.ts
602
- var LEVEL_OPTIONS = {
603
- ...OUTPUT_OPTIONS,
604
- help: { type: "boolean", short: "h", description: "show this help" }
605
- };
606
- var TOP_OPTIONS = { ...LEVEL_OPTIONS, version: { type: "boolean", short: "v", description: "print the version" } };
607
- function commandRows(commands, colors) {
608
- const width = Math.max(...Object.keys(commands).map((name) => name.length));
609
- const groups = new Map([["Commands:", []]]);
610
- for (const [name, { summary, group }] of Object.entries(commands)) {
611
- const heading = group === undefined ? "Commands:" : `${group}:`;
612
- const rows = groups.get(heading) ?? [];
613
- rows.push(` ${colors.cyan(name.padEnd(width))} ${colors.dim(summary)}`);
614
- groups.set(heading, rows);
615
- }
616
- return [...groups].filter(([, rows]) => rows.length > 0).flatMap(([heading, rows], index) => [...index === 0 ? [] : [""], colors.bold(heading), ...rows]);
617
- }
618
- function takeOutputFlags(argv) {
619
- let index = 0;
620
- for (;index < argv.length; index++) {
621
- const word = argv[index];
622
- if (word === "--plain")
623
- applyOutput(undefined, true);
624
- else if (word.startsWith("--color="))
625
- applyOutput(word.slice("--color=".length), undefined);
626
- else if (word === "--color" && argv[index + 1] !== undefined)
627
- applyOutput(argv[++index], undefined);
628
- else
629
- break;
630
- }
631
- return argv.slice(index);
632
- }
633
- function closest(name, names) {
634
- const started = names.find((known) => known.startsWith(name));
635
- if (started !== undefined)
636
- return started;
637
- const [best] = names.map((known) => ({ known, far: distance(name, known) })).sort((a, b) => a.far - b.far);
638
- return best !== undefined && best.far <= (name.length < 4 ? 1 : 2) ? best.known : undefined;
639
- }
640
- async function help({ path, header, summary, commands, defaultCommand, examples, replaces }, stream) {
641
- const colors = paint(stream);
642
- const prefix = path.join(" ");
643
- if (wantsHelpJson())
644
- return JSON.stringify(description(`${prefix} <command> [options]: ${summary}`, { examples, replaces }));
645
- const fallback = defaultCommand === undefined ? undefined : commands[defaultCommand];
646
- const options = fallback !== undefined && "run" in fallback ? await optionsOf(fallback.run) : undefined;
647
- const own = Object.entries(options ?? {}).filter(([name]) => !(name in TOP_OPTIONS));
648
- return [
649
- header(colors),
650
- "",
651
- usageLine(`${prefix} ${defaultCommand === undefined ? colors.cyan("<command>") : `[${colors.cyan("<command>")}]`} [options]`, colors),
652
- ...replacesRows(replaces, colors),
653
- "",
654
- ...commandRows(commands, colors),
655
- ...own.length === 0 ? [] : ["", `${colors.bold(`Options of ${defaultCommand}:`)} ${colors.dim("the command run when none is named")}`, ...optionRows(Object.fromEntries(own), colors)],
656
- "",
657
- colors.bold("Options:"),
658
- ...optionRows(path.length === 1 ? TOP_OPTIONS : LEVEL_OPTIONS, colors),
659
- ...exampleRows(examples ?? [], colors),
660
- "",
661
- `${colors.dim("Run")} ${prefix} ${colors.cyan("<command>")} --help ${colors.dim("for its options.")}`
662
- ].join(`
663
- `);
664
- }
665
- function printError(prefix, message, hint, cause) {
666
- const colors = paint(process5.stderr);
667
- const styled = takesControlCodes(process5.stderr);
668
- const head = styled ? `${colors.red.bold("error")} ${colors.dim(`${prefix}:`)}` : `error: ${prefix}:`;
669
- const under = (label, text, tint) => text === undefined ? "" : `
670
- ${styled ? tint(label) : `${label}:`} ${text}`;
671
- console.error(`${head} ${message}${under("cause", cause, colors.dim)}${under("hint", hint, colors.yellow)}`);
672
- }
673
- function runHint(command, effect) {
674
- return `run \`${command}\`${effect === undefined ? "" : ` to ${effect}`}`;
675
- }
676
- function printHint(hint) {
677
- const colors = paint(process5.stderr);
678
- console.error(` ${takesControlCodes(process5.stderr) ? colors.yellow("hint") : "hint:"} ${hint}`);
679
- }
680
- function printJsonError(message, hint, cause) {
681
- console.error(JSON.stringify({ error: message, ...hint === undefined ? {} : { hint }, ...cause === undefined ? {} : { cause } }));
682
- }
683
- function wantsJson(argv) {
684
- const end = argv.indexOf("--");
685
- return (end === -1 ? argv : argv.slice(0, end)).includes("--json");
686
- }
687
- function reportThrown(prefix, error, redact = (text) => text, json = false) {
688
- if (error instanceof HelpShown)
689
- return 0;
690
- const help = error instanceof UsageError ? `run ${prefix} --help for its options` : undefined;
691
- const named = error instanceof Error && !(error instanceof Exit) && error.name !== "Error" ? `${error.name}: ${error.message}` : undefined;
692
- const cause = error instanceof Error && error.cause instanceof Error ? redact(error.cause.message) : undefined;
693
- const report = json ? printJsonError : (message, hint, causeText) => printError(prefix, message, hint, causeText);
694
- report(redact(named ?? (error instanceof Error ? error.message : String(error))), error instanceof Exit ? error.hint ?? help : undefined, cause);
695
- return error instanceof Exit ? error.code : 1;
696
- }
697
- async function dispatch(level, typed, { version, redact }) {
698
- const prefix = level.path.join(" ");
699
- let argv;
700
- try {
701
- argv = takeOutputFlags(typed);
702
- } catch (error) {
703
- return reportThrown(prefix, error);
704
- }
705
- const [name, ...args] = argv;
706
- if (name === undefined && level.path.length > 1) {
707
- console.error(await help(level, process5.stderr));
708
- return 2;
709
- }
710
- if (name === undefined || name === "help" || name === "--help" || name === "-h") {
711
- console.log(await help(level, process5.stdout));
712
- return 0;
713
- }
714
- if (level.path.length === 1 && (name === "--version" || name === "-v")) {
715
- console.log(version);
716
- return 0;
717
- }
718
- let command = level.commands[level.aliases?.[name] ?? name];
719
- let rest = args;
720
- if (command === undefined && level.defaultCommand !== undefined) {
721
- command = level.commands[level.defaultCommand];
722
- rest = argv;
723
- }
724
- if (command === undefined) {
725
- const meant = closest(name, Object.keys(level.commands));
726
- printError(prefix, `no command ${name}`, meant === undefined ? `run ${prefix} --help for its commands` : `did you mean ${meant}?`);
727
- return 2;
728
- }
729
- if ("commands" in command) {
730
- const { summary } = command;
731
- 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 });
732
- }
733
- try {
734
- return await command.run(rest);
735
- } catch (error) {
736
- return reportThrown(`${prefix} ${name}`, error, redact, wantsJson(rest));
737
- }
738
- }
739
- var COMPLETE = "__complete";
740
- var FILES = "__files__";
741
- async function optionsOf(run) {
742
- completion.active = true;
743
- try {
744
- await run([]);
745
- } catch (error) {
746
- if (error instanceof Completing)
747
- return error.options;
748
- } finally {
749
- completion.active = false;
750
- }
751
- return {};
752
- }
753
- async function leafCandidates(run, words) {
754
- if (!(words.at(-1) ?? "").startsWith("-"))
755
- return [FILES];
756
- return Object.entries(await optionsOf(run)).map(([name, option]) => `--${name} ${option.description}`);
757
- }
758
- async function candidates(commands, aliases, defaultCommand, words) {
759
- const [word = "", ...rest] = words;
760
- const command = commands[aliases?.[word] ?? word];
761
- if (rest.length === 0 && !word.startsWith("-"))
762
- return Object.entries(commands).map(([name, { summary }]) => `${name} ${summary}`);
763
- if (command === undefined) {
764
- const fallback = defaultCommand === undefined ? undefined : commands[defaultCommand];
765
- return fallback !== undefined && "run" in fallback ? leafCandidates(fallback.run, words) : [];
766
- }
767
- return "commands" in command ? candidates(command.commands, command.aliases, undefined, rest) : leafCandidates(command.run, rest);
768
- }
769
- async function printCandidates(found) {
770
- for (const line of await found)
771
- console.log(line);
772
- return 0;
773
- }
774
- async function runCli(cli, argv = process5.argv.slice(2)) {
775
- if (argv[0] === COMPLETE)
776
- return printCandidates(candidates(cli.commands, cli.aliases, cli.defaultCommand, argv.slice(1)));
777
- 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 });
778
- }
779
- // ../kit/src/limit.ts
780
- function limitOptions(fallback) {
781
- return {
782
- limit: { type: "string", description: `show at most this many rows, the rest counted (${fallback})` },
783
- full: { type: "boolean", description: "show every row" }
784
- };
785
- }
786
- function limitOf(values, fallback) {
787
- if (values.full === true && values.limit !== undefined)
788
- badUsage("--limit and --full exclude each other");
789
- if (values.full === true || values.json === true && values.limit === undefined)
790
- return Number.POSITIVE_INFINITY;
791
- return wholeNumber(values.limit, "limit", fallback);
792
- }
793
- function moreFooter(more) {
794
- return `${more} more, rerun with --full`;
795
- }
796
- function capped(rows, limit) {
797
- const more = rows.length - limit;
798
- return more > 0 ? { shown: rows.slice(0, limit), footer: moreFooter(more) } : { shown: [...rows], footer: undefined };
799
- }
800
627
  // ../kit/src/process.ts
801
628
  import { constants } from "os";
802
- import process6 from "process";
629
+ import process5 from "process";
630
+ var KILL_GRACE_MS = 2000;
803
631
  var SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
804
- async function collect(stream, name, chunks) {
805
- for await (const data of stream)
806
- chunks.push({ stream: name, data });
807
- }
808
632
  function exitCodeOf(code, signal) {
809
633
  if (code !== null)
810
634
  return code;
@@ -824,7 +648,7 @@ function holdSignals(cleanup) {
824
648
  },
825
649
  release: () => {
826
650
  for (const signal of SIGNALS)
827
- process6.off(signal, onSignal);
651
+ process5.off(signal, onSignal);
828
652
  }
829
653
  };
830
654
  function onSignal(signal) {
@@ -837,54 +661,684 @@ function holdSignals(cleanup) {
837
661
  return;
838
662
  held.release();
839
663
  cleanup(signal);
840
- process6.kill(process6.pid, signal);
664
+ process5.kill(process5.pid, signal);
841
665
  }
842
666
  for (const signal of SIGNALS)
843
- process6.on(signal, onSignal);
667
+ process5.on(signal, onSignal);
844
668
  return held;
845
669
  }
846
- async function runCommand(argv, { cwd, env, stdin = "ignore" } = {}) {
670
+ async function runCommand(argv, { cwd, env, stdin = "ignore", timeoutMs, signal, maxBytes = Infinity, onChunk, onStart } = {}) {
847
671
  const chunks = [];
848
- const proc = Bun.spawn(argv, { ...cwd === undefined ? {} : { cwd }, ...env === undefined ? {} : { env }, stdin, stdout: "pipe", stderr: "pipe" });
849
- await Promise.all([collect(proc.stdout, "stdout", chunks), collect(proc.stderr, "stderr", chunks)]);
850
- await proc.exited;
851
- const output = Buffer.concat(chunks.map(({ data }) => data));
852
- return { code: exitCodeOf(proc.exitCode, proc.signalCode), chunks, output, text: output.toString() };
672
+ const grouped = timeoutMs !== undefined || signal !== undefined || onStart !== undefined || onChunk !== undefined;
673
+ const proc = Bun.spawn(argv, { ...cwd === undefined ? {} : { cwd }, ...env === undefined ? {} : { env }, stdin, stdout: "pipe", stderr: "pipe", ...grouped ? { detached: true } : {} });
674
+ const state = { kept: 0, truncated: false, timedOut: false, failure: undefined, done: false };
675
+ const timers = [];
676
+ const killGroup = (killing) => {
677
+ if (state.done)
678
+ return;
679
+ try {
680
+ if (grouped)
681
+ process5.kill(-proc.pid, killing);
682
+ else
683
+ proc.kill(killing);
684
+ } catch {}
685
+ };
686
+ const stop = () => {
687
+ killGroup("SIGTERM");
688
+ timers.push(setTimeout(killGroup, KILL_GRACE_MS, "SIGKILL"));
689
+ };
690
+ const onAbort = () => stop();
691
+ if (timeoutMs !== undefined) {
692
+ timers.push(setTimeout(() => {
693
+ state.timedOut = true;
694
+ stop();
695
+ }, timeoutMs));
696
+ }
697
+ if (signal?.aborted === true)
698
+ onAbort();
699
+ else
700
+ signal?.addEventListener("abort", onAbort, { once: true });
701
+ onStart?.({ kill: (killing) => killGroup(killing) });
702
+ const drain = async (stream, name) => {
703
+ for await (const data of stream) {
704
+ if (state.failure !== undefined)
705
+ continue;
706
+ try {
707
+ onChunk?.({ stream: name, data });
708
+ } catch (error) {
709
+ state.failure = error;
710
+ stop();
711
+ continue;
712
+ }
713
+ if (state.kept + data.length > maxBytes) {
714
+ state.truncated = true;
715
+ continue;
716
+ }
717
+ state.kept += data.length;
718
+ chunks.push({ stream: name, data });
719
+ }
720
+ };
721
+ try {
722
+ await Promise.all([drain(proc.stdout, "stdout"), drain(proc.stderr, "stderr")]);
723
+ await proc.exited;
724
+ } finally {
725
+ state.done = true;
726
+ for (const timer of timers)
727
+ clearTimeout(timer);
728
+ signal?.removeEventListener("abort", onAbort);
729
+ }
730
+ if (state.failure !== undefined)
731
+ throw state.failure;
732
+ const joined = () => Buffer.concat(chunks.map(({ data }) => data));
733
+ let output;
734
+ let text;
735
+ return {
736
+ code: exitCodeOf(proc.exitCode, proc.signalCode),
737
+ chunks,
738
+ timedOut: state.timedOut,
739
+ truncated: state.truncated,
740
+ get output() {
741
+ return output ??= joined();
742
+ },
743
+ get text() {
744
+ return text ??= (output ??= joined()).toString();
745
+ }
746
+ };
853
747
  }
854
- // package.json
855
- var version = "0.6.0";
856
-
857
- // src/args.ts
858
- var repoOption = {
859
- repo: { type: "string", short: "r", description: "owner/name of the repository, instead of the one origin points at" }
860
- };
861
- var ISSUE_HASH = /^#/;
862
- function issueNumber(value, usage) {
863
- if (value === undefined)
864
- fail(`an issue number is needed
865
748
 
866
- ${usage}`);
867
- return wholeNumber(value.replace(ISSUE_HASH, ""), "issue", 0);
868
- }
869
- function issueNumbers(values, usage) {
749
+ // ../kit/src/progress.ts
750
+ var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
751
+ var FRAME_MS = 80;
752
+ var CLEAR_LINE = "\r\x1B[2K";
753
+ var LINE_UP = "\x1B[1A\x1B[2K";
754
+ var ESC = String.fromCharCode(27);
755
+ var ESCAPE_CODE = new RegExp(`${ESC}(?:\\[[\\x30-\\x3F]*[\\x20-\\x2F]*[\\x40-\\x7E]|\\][^\\u0007${ESC}]*(?:\\u0007|${ESC}\\\\))`, "y");
756
+ var RESET_STYLE = `${ESC}[0m`;
757
+ var CLOSE_LINK = `${ESC}]8;;${ESC}\\`;
758
+ var CONSOLE_METHODS = ["log", "info", "warn", "error", "debug", "dir", "dirxml", "table", "trace", "group", "groupCollapsed", "count", "timeEnd", "timeLog", "assert"];
759
+ function liveRegion(render, stream = process6.stderr, env = process6.env) {
760
+ if (!takesControlCodes(stream, env))
761
+ return { redraw: () => {
762
+ return;
763
+ }, print: (text) => void stream.write(`${text}
764
+ `), stop: () => {
765
+ return;
766
+ } };
767
+ const write = stream.write.bind(stream);
768
+ let frame = 0;
769
+ let drawn = 0;
770
+ const clear = () => {
771
+ if (drawn > 0)
772
+ write(`${CLEAR_LINE}${LINE_UP.repeat(drawn - 1)}`);
773
+ drawn = 0;
774
+ };
775
+ const draw = () => {
776
+ const width = (stream.columns === undefined || stream.columns === 0 ? 80 : stream.columns) - 1;
777
+ const height = stream.rows === undefined || stream.rows === 0 ? 24 : stream.rows;
778
+ const lines = render(FRAMES[frame++ % FRAMES.length]).slice(0, Math.max(1, height - 1)).map((line) => cutToWidth(line, width));
779
+ if (lines.length === 0) {
780
+ clear();
781
+ return;
782
+ }
783
+ write(`${drawn > 0 ? `${CLEAR_LINE}${LINE_UP.repeat(drawn - 1)}` : CLEAR_LINE}${lines.join(`
784
+ `)}`);
785
+ drawn = lines.length;
786
+ };
787
+ const timer = setInterval(draw, FRAME_MS);
788
+ const restore = interceptOutput(clear);
789
+ const onExit = () => clear();
790
+ const signals = holdSignals(stop);
791
+ function stop() {
792
+ clearInterval(timer);
793
+ clear();
794
+ restore();
795
+ process6.off("exit", onExit);
796
+ signals.release();
797
+ }
798
+ timer.unref();
799
+ process6.on("exit", onExit);
800
+ return {
801
+ redraw: draw,
802
+ print: (text) => {
803
+ clear();
804
+ write(`${text}
805
+ `);
806
+ draw();
807
+ },
808
+ stop
809
+ };
810
+ }
811
+ function cutToWidth(line, width) {
812
+ if (Bun.stringWidth(line) <= width)
813
+ return line;
814
+ let room = width;
815
+ let kept = "";
816
+ let index = 0;
817
+ let linked = false;
818
+ while (index < line.length) {
819
+ ESCAPE_CODE.lastIndex = index;
820
+ const escape = ESCAPE_CODE.exec(line);
821
+ if (escape !== null) {
822
+ kept += escape[0];
823
+ linked ||= escape[0].startsWith(`${ESC}]`);
824
+ index = ESCAPE_CODE.lastIndex;
825
+ continue;
826
+ }
827
+ const char = String.fromCodePoint(line.codePointAt(index));
828
+ const columns = Bun.stringWidth(char);
829
+ if (columns > room)
830
+ break;
831
+ kept += char;
832
+ room -= columns;
833
+ index += char.length;
834
+ }
835
+ return `${kept}${RESET_STYLE}${linked ? CLOSE_LINK : ""}`;
836
+ }
837
+ function interceptOutput(clear) {
838
+ const targets = [...CONSOLE_METHODS.map((name) => [console, name]), [process6.stdout, "write"], [process6.stderr, "write"]];
839
+ const wrapped = targets.map(([target, name]) => {
840
+ const original = Reflect.get(target, name);
841
+ const wrapper = (...args) => {
842
+ clear();
843
+ return original.apply(target, args);
844
+ };
845
+ Reflect.set(target, name, wrapper);
846
+ return { target, name, original, wrapper };
847
+ });
848
+ return () => {
849
+ for (const { target, name, original, wrapper } of wrapped) {
850
+ if (Reflect.get(target, name) === wrapper)
851
+ Reflect.set(target, name, original);
852
+ }
853
+ };
854
+ }
855
+
856
+ // ../kit/src/tool.ts
857
+ import process7 from "process";
858
+ var PERMANENT = /\bHTTP (?:401|403|404)\b|unauthorized|forbidden|not found|couldn't be found|token is required/i;
859
+ function cwdGone() {
860
+ try {
861
+ process7.cwd();
862
+ return false;
863
+ } catch {
864
+ return true;
865
+ }
866
+ }
867
+
868
+ class SpawnError extends Error {
869
+ missing;
870
+ constructor(message, missing) {
871
+ super(message);
872
+ this.missing = missing;
873
+ }
874
+ }
875
+ function spawnFailure(command, error) {
876
+ const gone = cwdGone();
877
+ const missing = !gone && error?.code === "ENOENT";
878
+ return new SpawnError(`${command}: ${error instanceof Error ? error.message : String(error)}${gone ? " (the working directory was removed)" : ""}`, missing);
879
+ }
880
+ function isPermanentFailure(error) {
881
+ if (!(error instanceof Error))
882
+ return false;
883
+ return error instanceof SpawnError && error.missing || PERMANENT.test(error.message);
884
+ }
885
+ async function runTool(label, argv, { timeoutMs, cwd, onStart } = {}) {
886
+ let ran;
887
+ try {
888
+ ran = await runCommand(argv, { env: process7.env, ...timeoutMs === undefined ? {} : { timeoutMs }, ...cwd === undefined ? {} : { cwd }, ...onStart === undefined ? {} : { onStart } });
889
+ } catch (error) {
890
+ throw spawnFailure(label, error);
891
+ }
892
+ if (ran.timedOut)
893
+ throw new Error(`${label}: no answer in ${formatDuration(timeoutMs ?? 0)}`);
894
+ const decoder = new TextDecoder;
895
+ const streams = { stdout: "", stderr: "" };
896
+ for (const { stream, data } of ran.chunks)
897
+ streams[stream] += decoder.decode(data, { stream: true });
898
+ return { code: ran.code, ...streams };
899
+ }
900
+
901
+ // ../kit/src/ci.ts
902
+ var POLL = 15000;
903
+ async function waitForCi(wait) {
904
+ const { tool, short, giveUpMs, noneMs, stalledMs, pollMs = POLL } = wait;
905
+ const started = Date.now();
906
+ let seen = started;
907
+ let moving = started;
908
+ let followed = "";
909
+ let found;
910
+ const region = wait.lines === undefined ? undefined : liveRegion((spinner) => wait.lines?.(spinner, found, started) ?? []);
911
+ const suffix = () => found === undefined ? "" : `: ${wait.links(found)}`;
912
+ async function stallOf(current) {
913
+ try {
914
+ return await wait.stall?.(current);
915
+ } catch (error) {
916
+ if (error instanceof Exit || isPermanentFailure(error))
917
+ throw error;
918
+ console.warn(`${tool}: ${error instanceof Error ? error.message : String(error)}, polling again`);
919
+ return "unknown";
920
+ }
921
+ }
922
+ try {
923
+ for (;; ) {
924
+ if (Date.now() - started > giveUpMs)
925
+ fail(`CI has not finished on ${short} in ${formatDuration(giveUpMs)}${suffix()}`);
926
+ try {
927
+ found = await wait.covering();
928
+ } catch (error) {
929
+ if (error instanceof Exit)
930
+ throw error;
931
+ if (cwdGone())
932
+ fail(`the working directory was removed while waiting for CI on ${short}${suffix()}`);
933
+ if (isPermanentFailure(error))
934
+ fail(error instanceof Error ? error.message : String(error));
935
+ console.warn(`${tool}: ${error instanceof Error ? error.message : String(error)}, polling again`);
936
+ const latest = found === undefined ? undefined : await wait.settle(found);
937
+ if (latest !== undefined)
938
+ return latest;
939
+ await Bun.sleep(pollMs);
940
+ continue;
941
+ }
942
+ if (found !== undefined) {
943
+ seen = Date.now();
944
+ const key = wait.key(found);
945
+ if (key !== followed) {
946
+ followed = key;
947
+ moving = Date.now();
948
+ wait.announce(found);
949
+ }
950
+ if (wait.finished(found))
951
+ return found;
952
+ const stall = await stallOf(found);
953
+ if (stall === undefined)
954
+ moving = Date.now();
955
+ else if (stall !== "unknown" && (stall.certain === true || Date.now() - moving > stalledMs))
956
+ fail(stall.message);
957
+ } else if (Date.now() - seen > noneMs) {
958
+ fail(wait.noneMessage);
959
+ }
960
+ await Bun.sleep(pollMs);
961
+ }
962
+ } finally {
963
+ region?.stop();
964
+ }
965
+ }
966
+ // ../kit/src/cli.ts
967
+ import process8 from "process";
968
+ var LEVEL_OPTIONS = {
969
+ ...OUTPUT_OPTIONS,
970
+ help: { type: "boolean", short: "h", description: "show this help" }
971
+ };
972
+ var TOP_OPTIONS = { ...LEVEL_OPTIONS, version: { type: "boolean", short: "v", description: "print the version" } };
973
+ async function commandRows(commands, colors) {
974
+ const width = Math.max(...Object.keys(commands).map((name) => name.length));
975
+ const groups = new Map([["Commands:", []]]);
976
+ const summaries = await Promise.all(Object.values(commands).map(async ({ summary }) => resolveSummary(summary)));
977
+ for (const [index, [name, { group }]] of Object.entries(commands).entries()) {
978
+ const heading = group === undefined ? "Commands:" : `${group}:`;
979
+ const rows = groups.get(heading) ?? [];
980
+ rows.push(` ${colors.cyan(name.padEnd(width))} ${colors.dim(summaries[index])}`);
981
+ groups.set(heading, rows);
982
+ }
983
+ return [...groups].filter(([, rows]) => rows.length > 0).flatMap(([heading, rows], index) => [...index === 0 ? [] : [""], colors.bold(heading), ...rows]);
984
+ }
985
+ function takeOutputFlags(argv) {
986
+ let index = 0;
987
+ for (;index < argv.length; index++) {
988
+ const word = argv[index];
989
+ if (word === "--plain")
990
+ applyOutput(undefined, true);
991
+ else if (word.startsWith("--color="))
992
+ applyOutput(word.slice("--color=".length), undefined);
993
+ else if (word === "--color" && argv[index + 1] !== undefined)
994
+ applyOutput(argv[++index], undefined);
995
+ else
996
+ break;
997
+ }
998
+ return argv.slice(index);
999
+ }
1000
+ async function resolveSummary(summary) {
1001
+ return typeof summary === "string" ? summary : summary();
1002
+ }
1003
+ function scopeOf(commands, extra, aliases) {
1004
+ return { commands: typeof commands === "function" ? {} : commands, lazy: [...typeof commands === "function" ? [commands] : [], ...extra === undefined ? [] : [extra]], aliases };
1005
+ }
1006
+ async function expand(scope, words) {
1007
+ if (scope.lazy.length === 0)
1008
+ return scope;
1009
+ const commands = { ...scope.commands };
1010
+ for (const added of await Promise.all(scope.lazy.map(async (lazy) => lazy(words)))) {
1011
+ for (const [name, command] of Object.entries(added))
1012
+ commands[name] ??= command;
1013
+ }
1014
+ return { ...scope, commands, lazy: [] };
1015
+ }
1016
+ async function help({ path, header, summary: lazySummary, commands, defaultCommand, examples, replaces }, stream) {
1017
+ const colors = paint(stream);
1018
+ const prefix = path.join(" ");
1019
+ const summary = await resolveSummary(lazySummary);
1020
+ if (wantsHelpJson())
1021
+ return JSON.stringify(description(`${prefix} <command> [options]: ${summary}`, { examples, replaces }));
1022
+ const fallback = defaultCommand === undefined ? undefined : commands[defaultCommand];
1023
+ const options = fallback !== undefined && "run" in fallback ? await optionsOf(fallback.run) : undefined;
1024
+ const own = Object.entries(options ?? {}).filter(([name]) => !(name in TOP_OPTIONS));
1025
+ return [
1026
+ header(colors, summary),
1027
+ "",
1028
+ usageLine(`${prefix} ${defaultCommand === undefined ? colors.cyan("<command>") : `[${colors.cyan("<command>")}]`} [options]`, colors),
1029
+ ...replacesRows(replaces, colors),
1030
+ "",
1031
+ ...await commandRows(commands, colors),
1032
+ ...own.length === 0 ? [] : ["", `${colors.bold(`Options of ${defaultCommand}:`)} ${colors.dim("the command run when none is named")}`, ...optionRows(Object.fromEntries(own), colors)],
1033
+ "",
1034
+ colors.bold("Options:"),
1035
+ ...optionRows(path.length === 1 ? TOP_OPTIONS : LEVEL_OPTIONS, colors),
1036
+ ...exampleRows(examples ?? [], colors),
1037
+ "",
1038
+ `${colors.dim("Run")} ${prefix} ${colors.cyan("<command>")} --help ${colors.dim("for its options.")}`
1039
+ ].join(`
1040
+ `);
1041
+ }
1042
+ function printError(prefix, message, hint, cause) {
1043
+ const colors = paint(process8.stderr);
1044
+ const styled = takesControlCodes(process8.stderr);
1045
+ const head = styled ? `${colors.red.bold("error")} ${colors.dim(`${prefix}:`)}` : `error: ${prefix}:`;
1046
+ const under = (label, text, tint) => text === undefined ? "" : `
1047
+ ${styled ? tint(label) : `${label}:`} ${text}`;
1048
+ console.error(`${head} ${message}${under("cause", cause, colors.dim)}${under("hint", hint, colors.yellow)}`);
1049
+ }
1050
+ function runHint(command, effect) {
1051
+ return `run \`${command}\`${effect === undefined ? "" : ` to ${effect}`}`;
1052
+ }
1053
+ function printHint(hint) {
1054
+ const colors = paint(process8.stderr);
1055
+ console.error(` ${takesControlCodes(process8.stderr) ? colors.yellow("hint") : "hint:"} ${hint}`);
1056
+ }
1057
+ function printJsonError(message, hint, cause) {
1058
+ console.error(JSON.stringify({ error: message, ...hint === undefined ? {} : { hint }, ...cause === undefined ? {} : { cause } }));
1059
+ }
1060
+ function wantsJson(argv) {
1061
+ const end = argv.indexOf("--");
1062
+ return (end === -1 ? argv : argv.slice(0, end)).includes("--json");
1063
+ }
1064
+ function reportThrown(prefix, error, redact = (text) => text, json = false) {
1065
+ if (error instanceof HelpShown)
1066
+ return 0;
1067
+ const help = error instanceof UsageError ? `run ${prefix} --help for its options` : undefined;
1068
+ const named = error instanceof Error && !(error instanceof Exit) && error.name !== "Error" ? `${error.name}: ${error.message}` : undefined;
1069
+ const cause = error instanceof Error && error.cause instanceof Error ? redact(error.cause.message) : undefined;
1070
+ const report = json ? printJsonError : (message, hint, causeText) => printError(prefix, message, hint, causeText);
1071
+ report(redact(named ?? (error instanceof Error ? error.message : String(error))), error instanceof Exit ? error.hint ?? help : undefined, cause);
1072
+ return error instanceof Exit ? error.code : 1;
1073
+ }
1074
+ async function dispatch(level, typed, { version, redact }) {
1075
+ const prefix = level.path.join(" ");
1076
+ let argv;
1077
+ try {
1078
+ argv = takeOutputFlags(typed);
1079
+ } catch (error) {
1080
+ return reportThrown(prefix, error);
1081
+ }
1082
+ const [name, ...args] = argv;
1083
+ if (name === undefined && level.path.length > 1) {
1084
+ console.error(await help(await expand(level, [...level.path.slice(1), ...argv]), process8.stderr));
1085
+ return 2;
1086
+ }
1087
+ if (name === undefined || name === "help" || name === "--help" || name === "-h") {
1088
+ console.log(await help(await expand(level, [...level.path.slice(1), ...argv]), process8.stdout));
1089
+ return 0;
1090
+ }
1091
+ if (level.path.length === 1 && (name === "--version" || name === "-v")) {
1092
+ console.log(version);
1093
+ return 0;
1094
+ }
1095
+ let command = level.commands[level.aliases?.[name] ?? name];
1096
+ let rest = args;
1097
+ if (command === undefined && level.lazy.length > 0) {
1098
+ level = await expand(level, [...level.path.slice(1), ...argv]);
1099
+ command = level.commands[level.aliases?.[name] ?? name];
1100
+ }
1101
+ if (command === undefined && level.defaultCommand !== undefined) {
1102
+ command = level.commands[level.defaultCommand];
1103
+ rest = argv;
1104
+ }
1105
+ if (command === undefined) {
1106
+ const meant = closest(name, Object.keys(level.commands));
1107
+ printError(prefix, `no command ${name}`, meant === undefined ? `run ${prefix} --help for its commands` : `did you mean ${meant}?`);
1108
+ return 2;
1109
+ }
1110
+ if ("commands" in command) {
1111
+ const { summary } = command;
1112
+ 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 });
1113
+ }
1114
+ try {
1115
+ return await command.run(rest);
1116
+ } catch (error) {
1117
+ return reportThrown(`${prefix} ${name}`, error, redact, wantsJson(rest));
1118
+ }
1119
+ }
1120
+ var COMPLETE = "__complete";
1121
+ var FILES = "__files__";
1122
+ async function optionsOf(run) {
1123
+ completion.active = true;
1124
+ try {
1125
+ await run([]);
1126
+ } catch (error) {
1127
+ if (error instanceof Completing)
1128
+ return error.options;
1129
+ } finally {
1130
+ completion.active = false;
1131
+ }
1132
+ return {};
1133
+ }
1134
+ async function leafCandidates(run, words) {
1135
+ if (!(words.at(-1) ?? "").startsWith("-"))
1136
+ return [FILES];
1137
+ return Object.entries(await optionsOf(run)).map(([name, option]) => option.description === undefined ? `--${name}` : `--${name} ${option.description}`);
1138
+ }
1139
+ async function candidates(scope, defaultCommand, words, before = []) {
1140
+ const [word = "", ...rest] = words;
1141
+ const listing = rest.length === 0 && !word.startsWith("-");
1142
+ let { commands, aliases } = scope;
1143
+ let command = commands[aliases?.[word] ?? word];
1144
+ if (listing || command === undefined) {
1145
+ ({ commands, aliases } = await expand(scope, [...before, ...words]));
1146
+ command = commands[aliases?.[word] ?? word];
1147
+ }
1148
+ if (listing)
1149
+ return Promise.all(Object.entries(commands).map(async ([name, { summary }]) => `${name} ${await resolveSummary(summary)}`));
1150
+ if (command === undefined) {
1151
+ const fallback = defaultCommand === undefined ? undefined : commands[defaultCommand];
1152
+ return fallback !== undefined && "run" in fallback ? leafCandidates(fallback.run, words) : [];
1153
+ }
1154
+ return "commands" in command ? candidates(scopeOf(command.commands, command.extraCommands, command.aliases), undefined, rest, [...before, word]) : leafCandidates(command.run, rest);
1155
+ }
1156
+ async function printCandidates(found) {
1157
+ for (const line of await found)
1158
+ console.log(line);
1159
+ return 0;
1160
+ }
1161
+ async function runCli(cli, argv = process8.argv.slice(2)) {
1162
+ if (argv[0] === COMPLETE)
1163
+ return printCandidates(candidates(scopeOf(cli.commands, cli.extraCommands, cli.aliases), cli.defaultCommand, argv.slice(1)));
1164
+ 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 });
1165
+ }
1166
+ // ../kit/src/git.ts
1167
+ import { basename, dirname } from "path";
1168
+ import process9 from "process";
1169
+ var ORIGIN_PREFIX = /^origin\//;
1170
+ var GIT_NETWORK_MS = 60000;
1171
+ async function git(args, timeoutMs) {
1172
+ const result = await runTool(`git ${args.join(" ")}`, ["git", ...args], { timeoutMs });
1173
+ if (result.code !== 0)
1174
+ throw new Error(`git ${args.join(" ")}: ${result.stderr.trim() || `exit ${result.code}`}${cwdGone() ? " (the working directory was removed)" : ""}`);
1175
+ return result.stdout.trim();
1176
+ }
1177
+ async function gitOk(args) {
1178
+ return (await runTool(`git ${args.join(" ")}`, ["git", ...args])).code === 0;
1179
+ }
1180
+ async function resolveCommit(ref) {
1181
+ const sha = (await runTool("git rev-parse", ["git", "rev-parse", "--verify", "-q", "--end-of-options", `${ref}^{commit}`])).stdout.trim();
1182
+ if (sha === "")
1183
+ fail(`no commit ${ref}`);
1184
+ return sha;
1185
+ }
1186
+ async function fetchBranch(branch) {
1187
+ if (branch.startsWith("-") || !await gitOk(["check-ref-format", "--branch", branch]))
1188
+ fail(`${branch} is not a branch name`);
1189
+ await git(["fetch", "-q", "origin", `refs/heads/${branch}`], GIT_NETWORK_MS);
1190
+ }
1191
+ async function defaultBranch() {
1192
+ const head = (await runTool("git symbolic-ref", ["git", "symbolic-ref", "--short", "-q", "refs/remotes/origin/HEAD"])).stdout.trim();
1193
+ return head === "" ? "main" : head.replace(ORIGIN_PREFIX, "");
1194
+ }
1195
+ async function leaveWorktree() {
1196
+ const common = await git(["rev-parse", "--path-format=absolute", "--git-common-dir"]);
1197
+ if (basename(common) === ".git")
1198
+ process9.chdir(dirname(common));
1199
+ }
1200
+ // ../kit/src/limit.ts
1201
+ function limitOptions(fallback) {
1202
+ return {
1203
+ limit: { type: "string", description: `show at most this many rows, the rest counted (${fallback})` },
1204
+ full: { type: "boolean", description: "show every row" }
1205
+ };
1206
+ }
1207
+ function limitOf(values, fallback) {
1208
+ if (values.full === true && values.limit !== undefined)
1209
+ badUsage("--limit and --full exclude each other");
1210
+ if (values.full === true || values.json === true && values.limit === undefined)
1211
+ return Number.POSITIVE_INFINITY;
1212
+ return wholeNumber(values.limit, "limit", fallback);
1213
+ }
1214
+ function moreFooter(more) {
1215
+ return `${more} more, rerun with --full`;
1216
+ }
1217
+ function capped(rows, limit) {
1218
+ const more = rows.length - limit;
1219
+ return more > 0 ? { shown: rows.slice(0, limit), footer: moreFooter(more) } : { shown: [...rows], footer: undefined };
1220
+ }
1221
+ // ../kit/src/logs.ts
1222
+ var DAY_MS = 24 * 60 * 60 * 1000;
1223
+ // ../kit/src/secrets.ts
1224
+ var SECRET_WORDS = new Set(["key", "pass", "pwd", "auth", "salt", "cookie", "session", "jwt", "ssn", "iban", "card", "cvv", "otp"]);
1225
+ // ../kit/src/sessions.ts
1226
+ import { homedir } from "os";
1227
+ import { join } from "path";
1228
+ import process10 from "process";
1229
+ var {Glob } = globalThis.Bun;
1230
+ var SESSION_FILES = new Glob("*.json");
1231
+ var ACCOUNT_DIRS = new Glob("*/sessions");
1232
+ async function sessionDirs() {
1233
+ const home = homedir();
1234
+ const dirs = new Set([join(home, ".claude", "sessions")]);
1235
+ if (process10.env.CLAUDE_CONFIG_DIR !== undefined)
1236
+ dirs.add(join(process10.env.CLAUDE_CONFIG_DIR, "sessions"));
1237
+ try {
1238
+ for await (const dir of ACCOUNT_DIRS.scan({ cwd: join(home, ".claude-accounts"), onlyFiles: false, absolute: true }))
1239
+ dirs.add(dir);
1240
+ } catch {}
1241
+ return [...dirs];
1242
+ }
1243
+ async function procStat(pid) {
1244
+ try {
1245
+ const stat = await Bun.file(`/proc/${pid}/stat`).text();
1246
+ return stat.slice(stat.lastIndexOf(")") + 2).split(" ");
1247
+ } catch {
1248
+ return;
1249
+ }
1250
+ }
1251
+ async function isAlive(pid, procStart) {
1252
+ const stat = await procStat(pid);
1253
+ if (stat === undefined)
1254
+ return false;
1255
+ return procStart === undefined || stat[19] === procStart;
1256
+ }
1257
+ async function liveSessions() {
1258
+ const sessions = new Map;
1259
+ for (const dir of await sessionDirs()) {
1260
+ let files;
1261
+ try {
1262
+ files = await Array.fromAsync(SESSION_FILES.scan({ cwd: dir, absolute: true }));
1263
+ } catch {
1264
+ continue;
1265
+ }
1266
+ for (const file of files) {
1267
+ let entry;
1268
+ try {
1269
+ entry = await Bun.file(file).json();
1270
+ } catch {
1271
+ continue;
1272
+ }
1273
+ if (entry.name === undefined || entry.pid === undefined || sessions.has(entry.pid) || !await isAlive(entry.pid, entry.procStart))
1274
+ continue;
1275
+ sessions.set(entry.pid, { name: entry.name, pid: entry.pid, status: entry.status ?? "unknown", cwd: entry.cwd ?? "" });
1276
+ }
1277
+ }
1278
+ return [...sessions.values()];
1279
+ }
1280
+ async function currentSession(sessions) {
1281
+ const byPid = new Map(sessions.map((session) => [session.pid, session]));
1282
+ let pid = process10.ppid;
1283
+ for (let depth = 0;depth < 32 && pid > 1; depth++) {
1284
+ const session = byPid.get(pid);
1285
+ if (session !== undefined)
1286
+ return session;
1287
+ const parent = Number((await procStat(pid))?.[1]);
1288
+ if (!Number.isInteger(parent))
1289
+ return;
1290
+ pid = parent;
1291
+ }
1292
+ return;
1293
+ }
1294
+ // package.json
1295
+ var version = "0.7.1";
1296
+
1297
+ // src/args.ts
1298
+ var repoOption = {
1299
+ repo: { type: "string", short: "r", description: "owner/name of the repository, instead of the one origin points at" }
1300
+ };
1301
+ var ISSUE_HASH = /^#/;
1302
+ function issueNumber(value, usage) {
1303
+ if (value === undefined)
1304
+ fail(`an issue number is needed
1305
+
1306
+ ${usage}`);
1307
+ return wholeNumber(value.replace(ISSUE_HASH, ""), "<issue>", 0);
1308
+ }
1309
+ function issueNumbers(values, usage) {
870
1310
  return values.length === 0 ? [issueNumber(undefined, usage)] : values.map((value) => issueNumber(value, usage));
871
1311
  }
872
1312
 
873
1313
  // src/checklist.ts
874
- var BOX = /^(\s*[-*+] \[)([ x])(\]\s)(.*)\r?$/i;
875
- var FENCE = /^\s*(?:```|~~~)/;
1314
+ var BOX = /^(\s*[-*+] \[)([ x])(\]\s)(.*?)\r?$/is;
1315
+ var FENCE = /^\s*(`{3,}|~{3,})/;
1316
+ var LIST_ITEM = /^\s*(?:[-*+]|\d+[.)])\s/;
1317
+ var INDENT_CODE = /^(?: {4}|\t)/;
876
1318
  function boxes(body) {
877
1319
  const found = [];
878
- let fenced = false;
1320
+ let fence;
1321
+ let inList = false;
879
1322
  for (const [line, text] of body.split(`
880
1323
  `).entries()) {
881
- if (FENCE.test(text)) {
882
- fenced = !fenced;
1324
+ const marker = FENCE.exec(text)?.[1];
1325
+ if (fence !== undefined) {
1326
+ if (marker?.startsWith(fence[0] ?? "") === true && marker.length >= fence.length && text.trim() === marker)
1327
+ fence = undefined;
1328
+ continue;
1329
+ }
1330
+ if (marker !== undefined) {
1331
+ fence = marker;
883
1332
  continue;
884
1333
  }
885
- const match = fenced ? null : BOX.exec(text);
1334
+ const blank = text.trim() === "";
1335
+ const match = INDENT_CODE.test(text) && !inList ? null : BOX.exec(text);
886
1336
  if (match !== null)
887
1337
  found.push({ number: found.length + 1, line, done: match[2] !== " ", text: match[4] ?? "" });
1338
+ if (LIST_ITEM.test(text))
1339
+ inList = true;
1340
+ else if (!blank && !text.startsWith(" ") && !text.startsWith("\t"))
1341
+ inList = false;
888
1342
  }
889
1343
  return found;
890
1344
  }
@@ -917,13 +1371,17 @@ function numberedBody(body) {
917
1371
  return lines.join(`
918
1372
  `).trim();
919
1373
  }
1374
+ var NUMBERED_PICK = /^(\d+):(.+)$/s;
1375
+ var WHOLE_PICK = /^\d+$/;
920
1376
  function setBoxes(body, picks, done) {
921
1377
  const all = boxes(body);
922
1378
  const chosen = new Map;
923
1379
  for (const pick of picks) {
924
- const number = Number(pick);
925
- const needle = pick.toLowerCase();
926
- const matches = Number.isInteger(number) && String(number) === pick ? all.filter((box) => box.number === number) : all.filter((box) => box.text.toLowerCase().includes(needle));
1380
+ if (pick.trim() === "")
1381
+ throw new Error("an empty box pick matches every box");
1382
+ const guarded = NUMBERED_PICK.exec(pick);
1383
+ const needle = (guarded?.[2] ?? pick).trim().toLowerCase();
1384
+ const matches = guarded === null && !WHOLE_PICK.test(pick) ? all.filter((box) => box.text.toLowerCase().includes(needle)) : all.filter((box) => box.number === Number(guarded?.[1] ?? pick) && (guarded === null || box.text.toLowerCase().startsWith(needle)));
927
1385
  if (matches.length === 0)
928
1386
  throw new Error(`no box matches ${pick}`);
929
1387
  if (matches.length > 1)
@@ -961,8 +1419,8 @@ function tickReferences(body, wanted) {
961
1419
  }
962
1420
 
963
1421
  // src/claims.ts
964
- var TAKEN = /^\s*taken by session ([\w.@:-]+?)\.?\s*$/im;
965
- var RELEASED = /^\s*released by session ([\w.@:-]+?)\.?\s*$/im;
1422
+ var TAKEN = /^\s*taken by session ([\w.@:-]+?)\.?\s*$/i;
1423
+ var RELEASED = /^\s*released by session ([\w.@:-]+?)\.?\s*$/i;
966
1424
  var LANE = /^lane-([^-]+)-/;
967
1425
  function takenComment(name) {
968
1426
  return `Taken by session ${name}.`;
@@ -970,9 +1428,12 @@ function takenComment(name) {
970
1428
  function releasedComment(name) {
971
1429
  return `Released by session ${name}.`;
972
1430
  }
973
- function latestClaim(comments) {
1431
+ function isClaimant(comment, author) {
1432
+ return author === undefined || comment.user === undefined || comment.user.login === author;
1433
+ }
1434
+ function latestClaim(comments, author) {
974
1435
  let claim;
975
- for (const comment of [...comments].sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at))) {
1436
+ for (const comment of comments.filter((each) => isClaimant(each, author)).sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at))) {
976
1437
  const taken = TAKEN.exec(comment.body)?.[1];
977
1438
  if (taken !== undefined) {
978
1439
  claim = { holder: taken, at: comment.created_at };
@@ -999,80 +1460,15 @@ function holderSession(holder, sessions) {
999
1460
  }
1000
1461
 
1001
1462
  // src/tea.ts
1002
- var {$: $2 } = globalThis.Bun;
1003
-
1004
- // src/git.ts
1005
- import { basename, dirname } from "path";
1006
- import process8 from "process";
1007
- var {$ } = globalThis.Bun;
1008
- var ORIGIN_PREFIX = /^origin\//;
1009
- function spawnFailure(command, error) {
1010
- return new Error(`${command}: ${error instanceof Error ? error.message : String(error)}${cwdGone() ? " (the working directory was removed)" : ""}`);
1011
- }
1012
- async function git(args) {
1013
- let result;
1014
- try {
1015
- result = await $`git ${args}`.quiet().nothrow();
1016
- } catch (error) {
1017
- throw spawnFailure(`git ${args.join(" ")}`, error);
1018
- }
1019
- if (result.exitCode !== 0)
1020
- throw new Error(`git ${args.join(" ")}: ${result.stderr.toString().trim() || `exit ${result.exitCode}`}`);
1021
- return result.stdout.toString().trim();
1022
- }
1023
- async function gitOk(args) {
1024
- return (await $`git ${args}`.quiet().nothrow()).exitCode === 0;
1025
- }
1026
- async function resolveCommit(ref) {
1027
- const sha = (await $`git rev-parse --verify -q ${`${ref}^{commit}`}`.quiet().nothrow().text()).trim();
1028
- if (sha === "")
1029
- fail(`no commit ${ref}`);
1030
- return sha;
1031
- }
1032
- async function defaultBranch() {
1033
- const head = (await $`git symbolic-ref --short -q refs/remotes/origin/HEAD`.quiet().nothrow().text()).trim();
1034
- return head === "" ? "main" : head.replace(ORIGIN_PREFIX, "");
1035
- }
1036
- async function tagsAt(commit) {
1037
- return (await git(["tag", "--points-at", commit])).split(`
1038
- `).filter(Boolean);
1039
- }
1040
- async function commitsNaming(issue, ref) {
1041
- const out = await git(["log", ref, "--reverse", "--extended-regexp", `--grep=#${issue}([^0-9]|$)`, "--format=%H %s"]);
1042
- return out.split(`
1043
- `).filter(Boolean).map((line) => {
1044
- const space = line.indexOf(" ");
1045
- return { sha: line.slice(0, space), subject: line.slice(space + 1) };
1046
- });
1047
- }
1048
- function cwdGone() {
1049
- try {
1050
- process8.cwd();
1051
- return false;
1052
- } catch {
1053
- return true;
1054
- }
1055
- }
1056
- async function leaveWorktree() {
1057
- const common = await git(["rev-parse", "--path-format=absolute", "--git-common-dir"]);
1058
- if (basename(common) === ".git")
1059
- process8.chdir(dirname(common));
1060
- }
1061
-
1062
- // src/tea.ts
1463
+ var TEA_MS = 60000;
1063
1464
  function repoFlag(repo) {
1064
1465
  return repo === undefined ? [] : ["--repo", repo];
1065
1466
  }
1066
1467
  async function request(path, repo) {
1067
- let result;
1068
- try {
1069
- result = await $2`tea api ${repoFlag(repo)} ${path}`.quiet().nothrow();
1070
- } catch (error) {
1071
- throw spawnFailure(`tea api ${path}`, error);
1072
- }
1073
- if (result.exitCode !== 0)
1074
- throw new Error(`tea api ${path}: ${result.stderr.toString().trim() || `exit ${result.exitCode}`}`);
1075
- return result.stdout.toString();
1468
+ const result = await runTool(`tea api ${path}`, ["tea", "api", ...repoFlag(repo), path], { timeoutMs: TEA_MS });
1469
+ if (result.code !== 0)
1470
+ throw new Error(`tea api ${path}: ${result.stderr.trim() || `exit ${result.code}`}`);
1471
+ return result.stdout;
1076
1472
  }
1077
1473
  function parseJson(path, text) {
1078
1474
  let value;
@@ -1082,10 +1478,16 @@ function parseJson(path, text) {
1082
1478
  throw new Error(`tea api ${path}: ${upTo(text.trim(), `
1083
1479
  `) || "empty answer"}`);
1084
1480
  }
1085
- if (value !== null && typeof value === "object" && !Array.isArray(value) && "message" in value && typeof value.message === "string" && "url" in value)
1086
- throw new Error(`tea api ${path}: ${value.message}`);
1481
+ const failure = apiError(value);
1482
+ if (failure !== undefined)
1483
+ throw new Error(`tea api ${path}: ${failure}`);
1087
1484
  return value;
1088
1485
  }
1486
+ function apiError(value) {
1487
+ if (value !== null && typeof value === "object" && !Array.isArray(value) && "message" in value && typeof value.message === "string" && "url" in value)
1488
+ return value.message;
1489
+ return;
1490
+ }
1089
1491
  async function api(path, repo) {
1090
1492
  return parseJson(path, await request(path, repo));
1091
1493
  }
@@ -1095,29 +1497,30 @@ async function repoApi(path, repo) {
1095
1497
  async function repoWrite(method, path, fields, repo) {
1096
1498
  const full = `repos/{owner}/{repo}/${path}`;
1097
1499
  const flags = Object.entries(fields).flatMap(([name, value]) => ["-f", `${name}=${value}`]);
1098
- let result;
1099
- try {
1100
- result = await $2`tea api ${repoFlag(repo)} -X ${method} ${flags} ${full}`.quiet().nothrow();
1101
- } catch (error) {
1102
- throw spawnFailure(`tea api ${full}`, error);
1103
- }
1104
- if (result.exitCode !== 0)
1105
- throw new Error(`tea api ${full}: ${result.stderr.toString().trim() || `exit ${result.exitCode}`}`);
1106
- return parseJson(full, result.stdout.toString());
1500
+ const result = await runTool(`tea api ${full}`, ["tea", "api", ...repoFlag(repo), "-X", method, ...flags, full], { timeoutMs: TEA_MS });
1501
+ if (result.code !== 0)
1502
+ throw new Error(`tea api ${full}: ${result.stderr.trim() || `exit ${result.code}`}`);
1503
+ return parseJson(full, result.stdout);
1107
1504
  }
1108
1505
  async function repoApiText(path, repo) {
1109
- return request(`repos/{owner}/{repo}/${path}`, repo);
1110
- }
1111
- async function tea(args, repo) {
1112
- let result;
1506
+ const full = `repos/{owner}/{repo}/${path}`;
1507
+ const text = await request(full, repo);
1508
+ let value;
1113
1509
  try {
1114
- result = await $2`tea ${args} ${repoFlag(repo)}`.quiet().nothrow();
1115
- } catch (error) {
1116
- throw spawnFailure(`tea ${args[0] ?? ""}`, error);
1510
+ value = JSON.parse(text);
1511
+ } catch {
1512
+ return text;
1117
1513
  }
1118
- if (result.exitCode !== 0)
1119
- throw new Error(`tea ${args[0] ?? ""}: ${result.stderr.toString().trim() || result.stdout.toString().trim() || `exit ${result.exitCode}`}`);
1120
- return result.stdout.toString();
1514
+ const failure = apiError(value);
1515
+ if (failure !== undefined)
1516
+ throw new Error(`tea api ${full}: ${failure}`);
1517
+ return text;
1518
+ }
1519
+ async function tea(command, args, repo) {
1520
+ const result = await runTool(`tea ${command.join(" ")}`, ["tea", ...command, ...repoFlag(repo), ...args], { timeoutMs: TEA_MS });
1521
+ if (result.code !== 0)
1522
+ throw new Error(`tea ${command.join(" ")}: ${result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`}`);
1523
+ return result.stdout;
1121
1524
  }
1122
1525
 
1123
1526
  // src/issues.ts
@@ -1155,46 +1558,75 @@ async function commentsOfAll(issues, repo) {
1155
1558
  return result;
1156
1559
  }
1157
1560
  async function editIssue(number, change, repo) {
1158
- const args = ["issues", "edit", String(number)];
1561
+ const args = [String(number)];
1159
1562
  if (change.title !== undefined)
1160
1563
  args.push("-t", change.title);
1161
1564
  if (change.body !== undefined)
1162
1565
  args.push("-d", change.body);
1163
1566
  if (change.milestone !== undefined)
1164
1567
  args.push("-m", change.milestone);
1165
- await tea(args, repo);
1568
+ await tea(["issues", "edit"], args, repo);
1166
1569
  }
1167
1570
  async function setBody(number, body, repo) {
1168
1571
  await editIssue(number, { body }, repo);
1169
1572
  }
1170
1573
  async function createIssue(title, body, labels, milestone, repo) {
1171
- const args = ["issues", "create", "-t", title, "-d", body];
1574
+ const args = ["-t", title, "-d", body];
1575
+ const same = (found) => found.title === title && sameText(found.body, body);
1576
+ const known = new Set((await openIssues(undefined, repo)).filter(same).map((found) => found.number));
1172
1577
  if (labels !== undefined)
1173
1578
  args.push("-L", labels);
1174
1579
  if (milestone !== undefined)
1175
1580
  args.push("-m", milestone);
1176
- await tea(args, repo);
1177
- const created = (await openIssues(undefined, repo)).filter((found) => found.title === title).sort((a, b) => b.number - a.number)[0];
1178
- if (created === undefined)
1581
+ await tea(["issues", "create"], args, repo);
1582
+ const created = (await openIssues(undefined, repo)).filter((found) => same(found) && !known.has(found.number));
1583
+ if (created.length > 1)
1584
+ throw new Error(`created "${title}" but ${created.map((found) => `#${found.number}`).join(" and ")} both match: another session created the same issue at once`);
1585
+ if (created[0] === undefined)
1179
1586
  throw new Error(`created "${title}" but cannot find it among the open issues`);
1180
- return created;
1587
+ return created[0];
1588
+ }
1589
+ function sameText(a, b) {
1590
+ return a.replaceAll(`\r
1591
+ `, `
1592
+ `).trimEnd() === b.replaceAll(`\r
1593
+ `, `
1594
+ `).trimEnd();
1181
1595
  }
1596
+ var EDIT_TRIES = 3;
1182
1597
  async function editBody(number, change, repo) {
1183
- const { body } = await getIssue(number, repo);
1184
- const next = change(body);
1185
- if (next === body)
1186
- return false;
1187
- await setBody(number, next, repo);
1188
- return true;
1598
+ let wrote = false;
1599
+ for (let attempt = 0;attempt < EDIT_TRIES; attempt++) {
1600
+ const { body } = await getIssue(number, repo);
1601
+ const next = change(body);
1602
+ if (next === body)
1603
+ return wrote;
1604
+ await setBody(number, next, repo);
1605
+ wrote = true;
1606
+ if (sameText((await getIssue(number, repo)).body, next))
1607
+ return true;
1608
+ }
1609
+ throw new Error(`#${number}: its description keeps changing under the edit, so it was not written reliably`);
1610
+ }
1611
+ var login;
1612
+ async function sessionAuthor() {
1613
+ if (login !== undefined)
1614
+ return login;
1615
+ try {
1616
+ login = (await api("user")).login;
1617
+ } catch {
1618
+ return;
1619
+ }
1620
+ return login;
1189
1621
  }
1190
1622
  async function comment(number, body, repo) {
1191
- await tea(["comment", String(number), body], repo);
1623
+ await repoWrite("POST", `issues/${number}/comments`, { body }, repo);
1192
1624
  }
1193
1625
  async function close(number, repo) {
1194
- await tea(["issues", "close", String(number)], repo);
1626
+ await tea(["issues", "close"], [String(number)], repo);
1195
1627
  }
1196
1628
  async function reopen(number, repo) {
1197
- await tea(["issues", "reopen", String(number)], repo);
1629
+ await tea(["issues", "reopen"], [String(number)], repo);
1198
1630
  }
1199
1631
  async function repoLabels(repo) {
1200
1632
  const names = [];
@@ -1206,12 +1638,12 @@ async function repoLabels(repo) {
1206
1638
  }
1207
1639
  }
1208
1640
  async function editLabels(number, add, remove, repo) {
1209
- const args = ["issues", "edit", String(number)];
1641
+ const args = [String(number)];
1210
1642
  if (add.length > 0)
1211
1643
  args.push("--add-labels", add.join(","));
1212
1644
  if (remove.length > 0)
1213
1645
  args.push("--remove-labels", remove.join(","));
1214
- await tea(args, repo);
1646
+ await tea(["issues", "edit"], args, repo);
1215
1647
  }
1216
1648
 
1217
1649
  // src/milestones.ts
@@ -1237,99 +1669,29 @@ async function findMilestone(reference, repo) {
1237
1669
  function dueOn(value) {
1238
1670
  const match = DATE.exec(value);
1239
1671
  const date = new Date(`${value}T00:00:00Z`);
1240
- if (match === null || Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== value || date.getUTCFullYear() < 2000)
1241
- fail(`--due takes a date as YYYY-MM-DD, not ${value}`);
1242
- return date.toISOString();
1243
- }
1244
- function dueDate(milestone) {
1245
- if (milestone.due_on === null || new Date(milestone.due_on).getUTCFullYear() >= NO_DUE_YEAR)
1246
- return "";
1247
- return milestone.due_on.slice(0, 10);
1248
- }
1249
- function progress(milestone) {
1250
- const total = milestone.open_issues + milestone.closed_issues;
1251
- return total === 0 ? "" : `${Math.round(milestone.closed_issues / total * 100)}%`;
1252
- }
1253
- async function createMilestone(title, description, due, repo) {
1254
- const fields = { title };
1255
- if (description !== undefined)
1256
- fields.description = description;
1257
- if (due !== undefined)
1258
- fields.due_on = due;
1259
- return repoWrite("POST", "milestones", fields, repo);
1260
- }
1261
- async function closeMilestone(id, repo) {
1262
- await repoWrite("PATCH", `milestones/${id}`, { state: "closed" }, repo);
1263
- }
1264
-
1265
- // src/sessions.ts
1266
- import { homedir } from "os";
1267
- import { join } from "path";
1268
- import process9 from "process";
1269
- var {Glob } = globalThis.Bun;
1270
- var SESSION_FILES = new Glob("*.json");
1271
- var ACCOUNT_DIRS = new Glob("*/sessions");
1272
- async function sessionDirs() {
1273
- const home = homedir();
1274
- const dirs = new Set([join(home, ".claude", "sessions")]);
1275
- if (process9.env.CLAUDE_CONFIG_DIR !== undefined)
1276
- dirs.add(join(process9.env.CLAUDE_CONFIG_DIR, "sessions"));
1277
- try {
1278
- for await (const dir of ACCOUNT_DIRS.scan({ cwd: join(home, ".claude-accounts"), onlyFiles: false, absolute: true }))
1279
- dirs.add(dir);
1280
- } catch {}
1281
- return [...dirs];
1282
- }
1283
- async function procStat(pid) {
1284
- try {
1285
- const stat = await Bun.file(`/proc/${pid}/stat`).text();
1286
- return stat.slice(stat.lastIndexOf(")") + 2).split(" ");
1287
- } catch {
1288
- return;
1289
- }
1290
- }
1291
- async function isAlive(pid, procStart) {
1292
- const stat = await procStat(pid);
1293
- if (stat === undefined)
1294
- return false;
1295
- return procStart === undefined || stat[19] === procStart;
1296
- }
1297
- async function liveSessions() {
1298
- const sessions = new Map;
1299
- for (const dir of await sessionDirs()) {
1300
- let files;
1301
- try {
1302
- files = await Array.fromAsync(SESSION_FILES.scan({ cwd: dir, absolute: true }));
1303
- } catch {
1304
- continue;
1305
- }
1306
- for (const file of files) {
1307
- let entry;
1308
- try {
1309
- entry = await Bun.file(file).json();
1310
- } catch {
1311
- continue;
1312
- }
1313
- if (entry.name === undefined || entry.pid === undefined || sessions.has(entry.pid) || !await isAlive(entry.pid, entry.procStart))
1314
- continue;
1315
- sessions.set(entry.pid, { name: entry.name, pid: entry.pid, status: entry.status ?? "unknown", cwd: entry.cwd ?? "" });
1316
- }
1317
- }
1318
- return [...sessions.values()];
1672
+ if (match === null || Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== value || date.getUTCFullYear() < 2000)
1673
+ fail(`--due takes a date as YYYY-MM-DD, not ${value}`);
1674
+ return date.toISOString();
1319
1675
  }
1320
- async function currentSession(sessions) {
1321
- const byPid = new Map(sessions.map((session) => [session.pid, session]));
1322
- let pid = process9.ppid;
1323
- for (let depth = 0;depth < 32 && pid > 1; depth++) {
1324
- const session = byPid.get(pid);
1325
- if (session !== undefined)
1326
- return session;
1327
- const parent = Number((await procStat(pid))?.[1]);
1328
- if (!Number.isInteger(parent))
1329
- return;
1330
- pid = parent;
1331
- }
1332
- return;
1676
+ function dueDate(milestone) {
1677
+ if (milestone.due_on === null || new Date(milestone.due_on).getUTCFullYear() >= NO_DUE_YEAR)
1678
+ return "";
1679
+ return milestone.due_on.slice(0, 10);
1680
+ }
1681
+ function progress(milestone) {
1682
+ const total = milestone.open_issues + milestone.closed_issues;
1683
+ return total === 0 ? "" : `${Math.round(milestone.closed_issues / total * 100)}%`;
1684
+ }
1685
+ async function createMilestone(title, description, due, repo) {
1686
+ const fields = { title };
1687
+ if (description !== undefined)
1688
+ fields.description = description;
1689
+ if (due !== undefined)
1690
+ fields.due_on = due;
1691
+ return repoWrite("POST", "milestones", fields, repo);
1692
+ }
1693
+ async function closeMilestone(id, repo) {
1694
+ await repoWrite("PATCH", `milestones/${id}`, { state: "closed" }, repo);
1333
1695
  }
1334
1696
 
1335
1697
  // src/commands/board.ts
@@ -1381,9 +1743,10 @@ ${lines.join(`
1381
1743
  }
1382
1744
  async function boardRows(issues, sessions, repo) {
1383
1745
  const comments = await commentsOfAll(issues, repo);
1746
+ const author = await sessionAuthor();
1384
1747
  return issues.map((issue) => {
1385
1748
  const own = comments.get(issue.number) ?? [];
1386
- return { issue, standing: standing(latestClaim(own), sessions), comments: own };
1749
+ return { issue, standing: standing(latestClaim(own, author), sessions), comments: own };
1387
1750
  });
1388
1751
  }
1389
1752
  function boardJson(rows) {
@@ -1499,12 +1862,13 @@ async function take(args) {
1499
1862
  const numbers = issueNumbers(positionals, takeUsage);
1500
1863
  const sessions = await liveSessions();
1501
1864
  const nameFor = await claimant(values, sessions);
1865
+ const author = await sessionAuthor();
1502
1866
  return eachIssue("take", numbers, async (number) => {
1503
1867
  const name = nameFor(number);
1504
1868
  const [found, comments] = await Promise.all([getIssue(number, values.repo), commentsOf(number, values.repo)]);
1505
1869
  if (found.state === "closed")
1506
1870
  fail(`#${number} is closed`, 1, runHint(`ft reopen ${number}`, "reopen it"));
1507
- const claim = latestClaim(comments);
1871
+ const claim = latestClaim(comments, author);
1508
1872
  const current = standing(claim, sessions);
1509
1873
  if (claim?.holder === name) {
1510
1874
  console.log(`#${number} is already taken by ${name}`);
@@ -1515,6 +1879,9 @@ async function take(args) {
1515
1879
  if (current.state === "away" && values["take-stale"] !== true)
1516
1880
  fail(`#${number} was taken by ${current.claim.holder} ${formatAge(current.claim.at)}, which no session here answers to: it may run on another machine`, 1, runHint(`ft take ${number} --take-stale`, "take it over"));
1517
1881
  await comment(number, takenComment(name), values.repo);
1882
+ const standingNow = latestClaim(await commentsOf(number, values.repo), author);
1883
+ if (standingNow?.holder !== name)
1884
+ fail(`#${number} was taken by ${standingNow?.holder ?? "another session"} at the same moment`, 1);
1518
1885
  console.log(`took #${number} as ${name}: ${found.title}`);
1519
1886
  });
1520
1887
  }
@@ -1533,9 +1900,10 @@ async function drop(args) {
1533
1900
  const { values, positionals } = parsed;
1534
1901
  const numbers = issueNumbers(positionals, dropUsage);
1535
1902
  const nameFor = await claimant(values, await liveSessions());
1903
+ const author = await sessionAuthor();
1536
1904
  return eachIssue("drop", numbers, async (number) => {
1537
1905
  const name = nameFor(number);
1538
- const claim = latestClaim(await commentsOf(number, values.repo));
1906
+ const claim = latestClaim(await commentsOf(number, values.repo), author);
1539
1907
  if (claim?.holder !== name)
1540
1908
  fail(`#${number} is ${claim === undefined ? "not taken" : `taken by ${claim.holder}, not ${name}`}`, 1);
1541
1909
  await comment(number, releasedComment(name), values.repo);
@@ -1544,114 +1912,20 @@ async function drop(args) {
1544
1912
  }
1545
1913
 
1546
1914
  // src/commands/ci.ts
1547
- import process11 from "process";
1915
+ import process12 from "process";
1548
1916
 
1549
- // ../kit/src/progress.ts
1550
- import process10 from "process";
1551
- var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
1552
- var FRAME_MS = 80;
1553
- var CLEAR_LINE = "\r\x1B[2K";
1554
- var LINE_UP = "\x1B[1A\x1B[2K";
1555
- var ESC = String.fromCharCode(27);
1556
- var ESCAPE_CODE = new RegExp(`${ESC}(?:\\[[\\x30-\\x3F]*[\\x20-\\x2F]*[\\x40-\\x7E]|\\][^\\u0007${ESC}]*(?:\\u0007|${ESC}\\\\))`, "y");
1557
- var RESET_STYLE = `${ESC}[0m`;
1558
- var CLOSE_LINK = `${ESC}]8;;${ESC}\\`;
1559
- var CONSOLE_METHODS = ["log", "info", "warn", "error", "debug", "dir", "dirxml", "table", "trace", "group", "groupCollapsed", "count", "timeEnd", "timeLog", "assert"];
1560
- function liveRegion(render, stream = process10.stderr, env = process10.env) {
1561
- if (!takesControlCodes(stream, env))
1562
- return { redraw: () => {
1563
- return;
1564
- }, print: (text) => void stream.write(`${text}
1565
- `), stop: () => {
1566
- return;
1567
- } };
1568
- const write = stream.write.bind(stream);
1569
- let frame = 0;
1570
- let drawn = 0;
1571
- const clear = () => {
1572
- if (drawn > 0)
1573
- write(`${CLEAR_LINE}${LINE_UP.repeat(drawn - 1)}`);
1574
- drawn = 0;
1575
- };
1576
- const draw = () => {
1577
- const width = (stream.columns === undefined || stream.columns === 0 ? 80 : stream.columns) - 1;
1578
- const height = stream.rows === undefined || stream.rows === 0 ? 24 : stream.rows;
1579
- const lines = render(FRAMES[frame++ % FRAMES.length]).slice(0, Math.max(1, height - 1)).map((line) => cutToWidth(line, width));
1580
- if (lines.length === 0) {
1581
- clear();
1582
- return;
1583
- }
1584
- write(`${drawn > 0 ? `${CLEAR_LINE}${LINE_UP.repeat(drawn - 1)}` : CLEAR_LINE}${lines.join(`
1585
- `)}`);
1586
- drawn = lines.length;
1587
- };
1588
- const timer = setInterval(draw, FRAME_MS);
1589
- const restore = interceptOutput(clear);
1590
- const onExit = () => clear();
1591
- const signals = holdSignals(stop);
1592
- function stop() {
1593
- clearInterval(timer);
1594
- clear();
1595
- restore();
1596
- process10.off("exit", onExit);
1597
- signals.release();
1598
- }
1599
- timer.unref();
1600
- process10.on("exit", onExit);
1601
- return {
1602
- redraw: draw,
1603
- print: (text) => {
1604
- clear();
1605
- write(`${text}
1606
- `);
1607
- draw();
1608
- },
1609
- stop
1610
- };
1611
- }
1612
- function cutToWidth(line, width) {
1613
- if (Bun.stringWidth(line) <= width)
1614
- return line;
1615
- let room = width;
1616
- let kept = "";
1617
- let index = 0;
1618
- let linked = false;
1619
- while (index < line.length) {
1620
- ESCAPE_CODE.lastIndex = index;
1621
- const escape = ESCAPE_CODE.exec(line);
1622
- if (escape !== null) {
1623
- kept += escape[0];
1624
- linked ||= escape[0].startsWith(`${ESC}]`);
1625
- index = ESCAPE_CODE.lastIndex;
1626
- continue;
1627
- }
1628
- const char = String.fromCodePoint(line.codePointAt(index));
1629
- const columns = Bun.stringWidth(char);
1630
- if (columns > room)
1631
- break;
1632
- kept += char;
1633
- room -= columns;
1634
- index += char.length;
1635
- }
1636
- return `${kept}${RESET_STYLE}${linked ? CLOSE_LINK : ""}`;
1917
+ // src/git.ts
1918
+ async function tagsAt(commit) {
1919
+ return (await git(["tag", "--points-at", commit])).split(`
1920
+ `).filter(Boolean);
1637
1921
  }
1638
- function interceptOutput(clear) {
1639
- const targets = [...CONSOLE_METHODS.map((name) => [console, name]), [process10.stdout, "write"], [process10.stderr, "write"]];
1640
- const wrapped = targets.map(([target, name]) => {
1641
- const original = Reflect.get(target, name);
1642
- const wrapper = (...args) => {
1643
- clear();
1644
- return original.apply(target, args);
1645
- };
1646
- Reflect.set(target, name, wrapper);
1647
- return { target, name, original, wrapper };
1922
+ async function commitsNaming(issue, ref) {
1923
+ const out = await git(["log", ref, "--reverse", "--extended-regexp", `--grep=#${issue}([^0-9]|$)`, "--format=%H %s"]);
1924
+ return out.split(`
1925
+ `).filter(Boolean).map((line) => {
1926
+ const space = line.indexOf(" ");
1927
+ return { sha: line.slice(0, space), subject: line.slice(space + 1) };
1648
1928
  });
1649
- return () => {
1650
- for (const { target, name, original, wrapper } of wrapped) {
1651
- if (Reflect.get(target, name) === wrapper)
1652
- Reflect.set(target, name, original);
1653
- }
1654
- };
1655
1929
  }
1656
1930
 
1657
1931
  // src/log.ts
@@ -1728,7 +2002,13 @@ async function recentRuns(limit, repo, workflow) {
1728
2002
  return (await runsListing(limit, repo, workflow)).runs;
1729
2003
  }
1730
2004
  async function runsOn(sha, repo) {
1731
- return (await repoApi(`actions/runs?head_sha=${sha}`, repo)).workflow_runs ?? [];
2005
+ const runs = [];
2006
+ for (let page = 1;; page++) {
2007
+ const list = (await repoApi(`actions/runs?head_sha=${sha}&page=${page}&limit=${RUNS_PAGE}`, repo)).workflow_runs ?? [];
2008
+ runs.push(...list);
2009
+ if (list.length < RUNS_PAGE)
2010
+ return runs;
2011
+ }
1732
2012
  }
1733
2013
  async function runById(id, repo) {
1734
2014
  return repoApi(`actions/runs/${id}`, repo);
@@ -1814,9 +2094,9 @@ var options = {
1814
2094
  job: { type: "string", short: "j", description: "a job whose skipping means the run left its work to a newer push" },
1815
2095
  timeout: { type: "string", short: "t", description: "how long before giving up (30m), a bare number in minutes" }
1816
2096
  };
1817
- var POLL = 15000;
1818
2097
  var NO_RUN = 3 * 60000;
1819
2098
  var STALLED = 3 * 60000;
2099
+ var MAX_LOOKUPS = 20;
1820
2100
  function judge(runs, jobSkipped, tagged) {
1821
2101
  if (runs.length === 0)
1822
2102
  return { kind: "none" };
@@ -1828,7 +2108,13 @@ function judge(runs, jobSkipped, tagged) {
1828
2108
  return { kind: "runs", runs };
1829
2109
  return tagged ? { kind: "runs", runs: runs.map((run) => ({ ...run, status: "skipped" })) } : { kind: "passed-over" };
1830
2110
  }
1831
- async function ci(args) {
2111
+ function commitsToAsk(commits, passedOver, max = MAX_LOOKUPS) {
2112
+ return commits.filter((commit) => !passedOver.has(commit)).slice(0, max);
2113
+ }
2114
+ function settled(latest) {
2115
+ return latest !== undefined && latest.every((run) => FINISHED.has(run.status)) && judge(latest, false, false).kind === "runs" ? latest : undefined;
2116
+ }
2117
+ async function ci2(args) {
1832
2118
  const parsed = parse(args, usage, options);
1833
2119
  const { values, positionals } = parsed;
1834
2120
  const branch = values.branch ?? await defaultBranch();
@@ -1852,71 +2138,44 @@ async function ci(args) {
1852
2138
  return coverage;
1853
2139
  }
1854
2140
  async function covering() {
1855
- await git(["fetch", "-q", "origin", branch]);
2141
+ await fetchBranch(branch);
1856
2142
  if (!await gitOk(["merge-base", "--is-ancestor", sha, `origin/${branch}`]))
1857
2143
  fail(`${short} is not on origin/${branch}`, 2, runHint(`git push origin ${branch}`));
1858
2144
  const later = (await git(["rev-list", "--reverse", "--first-parent", `${sha}..origin/${branch}`])).split(`
1859
2145
  `).filter(Boolean);
1860
- for (const commit of [sha, ...later]) {
2146
+ for (const commit of commitsToAsk([sha, ...later], passedOver)) {
1861
2147
  const coverage = await coverageOf(commit);
1862
2148
  if (coverage.kind === "runs")
1863
2149
  return coverage.runs;
1864
2150
  }
1865
2151
  return;
1866
2152
  }
1867
- const started = Date.now();
1868
- let seen = started;
1869
- let moving = started;
1870
- let followed = "";
1871
- let runs;
1872
- const { dim } = paint(process11.stderr);
1873
- const region = liveRegion((spinner) => [
1874
- `${spinner} CI on ${short} ${dim(formatDuration(Date.now() - started))}`,
1875
- ...(runs ?? []).map((run) => ` ${badge(run.status, process11.stderr)} ${run.workflow_id} ${dim(`run ${run.id}`)}`)
1876
- ]);
1877
- try {
1878
- for (;; ) {
1879
- if (Date.now() - started > giveUp)
1880
- fail(`CI has not finished on ${short} in ${formatDuration(giveUp)}${runs === undefined ? "" : `: ${runs.map((run) => run.html_url).join(" ")}`}`);
1881
- try {
1882
- runs = await covering();
1883
- } catch (error) {
1884
- if (error instanceof Exit)
1885
- throw error;
1886
- if (cwdGone())
1887
- fail(`the working directory was removed while waiting for CI on ${short}${runs === undefined ? "" : `: ${runs.map((run) => run.html_url).join(" ")}`}`);
1888
- console.warn(`ft ci: ${error instanceof Error ? error.message : String(error)}, polling again`);
1889
- const latest = runs === undefined ? undefined : await refreshed(runs);
1890
- if (latest?.every((run) => FINISHED.has(run.status)) === true) {
1891
- runs = latest;
1892
- break;
1893
- }
1894
- await Bun.sleep(POLL);
1895
- continue;
1896
- }
1897
- if (runs !== undefined) {
1898
- seen = Date.now();
1899
- const ids = runs.map((run) => run.id).join(",");
1900
- if (ids !== followed) {
1901
- followed = ids;
1902
- for (const run of runs)
1903
- console.log(`following ${run.workflow_id} run ${run.id} on ${run.commit_sha.slice(0, 7)}${run.commit_sha === sha ? "" : `, which covers ${short}`}: ${run.html_url}`);
1904
- }
1905
- if (runs.every((run) => FINISHED.has(run.status)))
1906
- break;
1907
- const stall = await stallReason(runs);
1908
- if (stall === undefined)
1909
- moving = Date.now();
1910
- else if (stall.certain || Date.now() - moving > STALLED)
1911
- fail(`runs queued on ${short} are not moving: ${stall.reason}`);
1912
- } else if (Date.now() - seen > NO_RUN) {
1913
- fail(`no run covers ${short} yet: its own was cancelled by a later push or never started, so the next push's will`);
1914
- }
1915
- await Bun.sleep(POLL);
1916
- }
1917
- } finally {
1918
- region.stop();
1919
- }
2153
+ const { dim } = paint(process12.stderr);
2154
+ const runs = await waitForCi({
2155
+ tool: "ft ci",
2156
+ short,
2157
+ giveUpMs: giveUp,
2158
+ noneMs: NO_RUN,
2159
+ stalledMs: STALLED,
2160
+ noneMessage: `no run covers ${short} yet: its own was cancelled by a later push or never started, so the next push's will`,
2161
+ covering,
2162
+ finished: (found) => found.every((run) => FINISHED.has(run.status)),
2163
+ key: (found) => found.map((run) => run.id).join(","),
2164
+ announce: (found) => {
2165
+ for (const run of found)
2166
+ console.log(`following ${run.workflow_id} run ${run.id} on ${run.commit_sha.slice(0, 7)}${run.commit_sha === sha ? "" : `, which covers ${short}`}: ${run.html_url}`);
2167
+ },
2168
+ links: (found) => found.map((run) => run.html_url).join(" "),
2169
+ settle: async (found) => settled(await refreshed(found)),
2170
+ stall: async (found) => {
2171
+ const stall = await stallReason(found);
2172
+ return stall === undefined ? undefined : { message: `runs queued on ${short} are not moving: ${stall.reason}`, certain: stall.certain };
2173
+ },
2174
+ lines: (spinner, found, started) => [
2175
+ `${spinner} CI on ${short} ${dim(formatDuration(Date.now() - started))}`,
2176
+ ...(found ?? []).map((run) => ` ${badge(run.status, process12.stderr)} ${run.workflow_id} ${dim(`run ${run.id}`)}`)
2177
+ ]
2178
+ });
1920
2179
  const status = overall(runs);
1921
2180
  if (status !== "failure") {
1922
2181
  console.log(`CI ${status === "success" ? "passed" : "skipped its tests, a release tag's workflow testing them"} on ${short}`);
@@ -1933,7 +2192,7 @@ run ${run.id} failed${run.commit_sha === sha ? "" : `, testing every commit from
1933
2192
  }
1934
2193
 
1935
2194
  // src/commands/digest.ts
1936
- import process12 from "process";
2195
+ import process13 from "process";
1937
2196
  var RUNS_SCANNED = 50;
1938
2197
  var TITLE_WIDTH2 = 70;
1939
2198
  var SHOWN = 8;
@@ -1983,7 +2242,7 @@ async function quietly(read) {
1983
2242
  }
1984
2243
  }
1985
2244
  async function staleTodos() {
1986
- const bin = Bun.which("todo-scan", { PATH: process12.env.PATH ?? "" });
2245
+ const bin = Bun.which("todo-scan", { PATH: process13.env.PATH ?? "" });
1987
2246
  if (bin === null)
1988
2247
  return [];
1989
2248
  let found;
@@ -2044,7 +2303,8 @@ async function digest(args) {
2044
2303
  const [issues, sessions, branch, runs] = await Promise.all([openIssues(undefined, values.repo), liveSessions(), defaultBranch(), recentRuns(RUNS_SCANNED, values.repo)]);
2045
2304
  const comments = await commentsOfAll(issues, values.repo);
2046
2305
  const me = await currentSession(sessions);
2047
- const rows = issues.map((issue) => ({ issue, claim: latestClaim(comments.get(issue.number) ?? []) }));
2306
+ const author = await sessionAuthor();
2307
+ const rows = issues.map((issue) => ({ issue, claim: latestClaim(comments.get(issue.number) ?? [], author) }));
2048
2308
  const [unpushed, dirty, stale] = await Promise.all([quietly(unpushedCommits), quietly(dirtyTree), staleTodos()]);
2049
2309
  const summary = { ...claims(rows, sessions, me), ci: unsettled(runs, branch), unpushed, dirty, stale };
2050
2310
  if (values.json === true)
@@ -2161,7 +2421,7 @@ var issueOptions = {
2161
2421
  ...repoOption
2162
2422
  };
2163
2423
  function renderIssue(found, comments, view, now = Date.now()) {
2164
- const claim = latestClaim(comments);
2424
+ const claim = latestClaim(comments, view.author);
2165
2425
  const list = boxes(found.body);
2166
2426
  const left = list.filter((box) => !box.done);
2167
2427
  const facts = [found.state];
@@ -2189,14 +2449,14 @@ ${each.body.replaceAll(`\r
2189
2449
 
2190
2450
  `);
2191
2451
  }
2192
- function issueJson(found, comments) {
2452
+ function issueJson(found, comments, author) {
2193
2453
  return {
2194
2454
  number: found.number,
2195
2455
  title: found.title,
2196
2456
  state: found.state,
2197
2457
  url: found.html_url,
2198
2458
  labels: found.labels.map((label) => label.name),
2199
- claim: latestClaim(comments) ?? null,
2459
+ claim: latestClaim(comments, author) ?? null,
2200
2460
  boxes: boxes(found.body).map(({ number, done, text }) => ({ number, done, text })),
2201
2461
  body: found.body,
2202
2462
  comments: comments.map((each) => ({ author: each.user.login, at: each.created_at, body: each.body }))
@@ -2216,12 +2476,13 @@ async function issue(args) {
2216
2476
  return 0;
2217
2477
  }
2218
2478
  const read = await Promise.all(numbers.map(async (number) => Promise.all([getIssue(number, values.repo), commentsOf(number, values.repo)])));
2479
+ const author = await sessionAuthor();
2219
2480
  if (values.json === true) {
2220
- const json = read.map(([found, comments]) => issueJson(found, comments));
2481
+ const json = read.map(([found, comments]) => issueJson(found, comments, author));
2221
2482
  printJson(json.length === 1 ? json[0] : json);
2222
2483
  return 0;
2223
2484
  }
2224
- const view = { todo: values.todo === true, comments: values.comments === true };
2485
+ const view = { todo: values.todo === true, comments: values.comments === true, author };
2225
2486
  console.log(read.map(([found, comments]) => renderIssue(found, comments, view)).join(view.todo ? `
2226
2487
 
2227
2488
  ` : `
@@ -2240,11 +2501,14 @@ async function noteText(values) {
2240
2501
  const text = values.file === undefined ? values.message : await readInput(values.file);
2241
2502
  return text?.trim() === "" ? undefined : text?.trim();
2242
2503
  }
2504
+ var NOT_FOUND = /not exist|not found|couldn't be found/i;
2243
2505
  async function isClosed(number, repo) {
2244
2506
  try {
2245
2507
  return (await getIssue(number, repo)).state === "closed";
2246
- } catch {
2247
- return false;
2508
+ } catch (error) {
2509
+ if (error instanceof Error && NOT_FOUND.test(error.message))
2510
+ return false;
2511
+ throw error;
2248
2512
  }
2249
2513
  }
2250
2514
  async function closedReferences(body, repo) {
@@ -2317,17 +2581,21 @@ ${editUsage}`);
2317
2581
  const milestone = await milestoneTitle(values.milestone, values.repo);
2318
2582
  if (milestone !== undefined)
2319
2583
  change.milestone = milestone;
2320
- if (text !== undefined && values.append !== true)
2584
+ const grows = text !== undefined && values.append === true || added.length > 0;
2585
+ if (text !== undefined && !grows)
2321
2586
  change.body = text;
2322
- else if (text !== undefined || added.length > 0)
2323
- change.body = (await getIssue(number, values.repo)).body;
2324
- if (text !== undefined && values.append === true)
2325
- change.body = `${(change.body ?? "").trimEnd()}
2326
-
2327
- ${text}`;
2328
- if (added.length > 0)
2329
- change.body = appendBoxes(change.body ?? "", added);
2330
- await editIssue(number, change, values.repo);
2587
+ if (Object.keys(change).length > 0)
2588
+ await editIssue(number, change, values.repo);
2589
+ if (grows) {
2590
+ await editBody(number, (body) => {
2591
+ const appended = text !== undefined && values.append === true && !body.trimEnd().endsWith(text) ? `${body.trimEnd()}
2592
+
2593
+ ${text}` : body;
2594
+ const present = new Set(boxes(appended).map((box) => box.text));
2595
+ const missing = added.filter((box) => !present.has(box));
2596
+ return missing.length > 0 ? appendBoxes(appended, missing) : appended;
2597
+ }, values.repo);
2598
+ }
2331
2599
  console.log(`edited #${number}`);
2332
2600
  return 0;
2333
2601
  }
@@ -2343,6 +2611,13 @@ var tickOptions = {
2343
2611
  ...noteOptions,
2344
2612
  ...repoOption
2345
2613
  };
2614
+ function tickedOrFail(number, run) {
2615
+ try {
2616
+ return run();
2617
+ } catch (error) {
2618
+ fail(`#${number}: ${error instanceof Error ? error.message : String(error)}`);
2619
+ }
2620
+ }
2346
2621
  async function tick(args) {
2347
2622
  const parsed = parse(args, tickUsage, tickOptions);
2348
2623
  const { values, positionals } = parsed;
@@ -2360,17 +2635,13 @@ ${tickUsage}`);
2360
2635
  const found = await getIssue(number, values.repo);
2361
2636
  const done = values.undo !== true;
2362
2637
  const closed = values.closed === true ? await closedReferences(found.body, values.repo) : undefined;
2363
- let result;
2364
- try {
2365
- result = closed === undefined ? setBoxes(found.body, picks, done) : tickReferences(found.body, (issue) => closed.has(issue));
2366
- } catch (error) {
2367
- fail(`#${number}: ${error instanceof Error ? error.message : String(error)}`);
2368
- }
2638
+ const apply = (body) => closed === undefined ? setBoxes(body, picks, done) : tickReferences(body, (issue) => closed.has(issue));
2639
+ let result = tickedOrFail(number, () => apply(found.body));
2369
2640
  if (result.changed.length > 0) {
2370
- if (closed === undefined)
2371
- await setBody(number, result.body, values.repo);
2372
- else
2373
- await editBody(number, (body) => tickReferences(body, (issue) => closed.has(issue)).body, values.repo);
2641
+ await editBody(number, (body) => {
2642
+ result = tickedOrFail(number, () => apply(body));
2643
+ return result.body;
2644
+ }, values.repo);
2374
2645
  }
2375
2646
  for (const box of result.changed)
2376
2647
  console.log(`${done ? "ticked" : "unticked"} ${box.number}: ${box.text}`);
@@ -2461,7 +2732,7 @@ async function fixed(args) {
2461
2732
  fail(`#${number} is already closed`, 1, runHint(`ft reopen ${number}`, "reopen it"));
2462
2733
  const left = boxes(found.body).filter((box) => !box.done);
2463
2734
  if (left.length > 0 && values.tick === true) {
2464
- await setBody(number, setBoxes(found.body, left.map((box) => String(box.number)), true).body, values.repo);
2735
+ await editBody(number, (body) => setBoxes(body, boxes(body).filter((box) => !box.done).map((box) => String(box.number)), true).body, values.repo);
2465
2736
  console.log(`ticked ${left.length} box${left.length === 1 ? "" : "es"} of #${number}`);
2466
2737
  } else if (left.length > 0 && values.force !== true) {
2467
2738
  fail(`#${number} has ${left.length} boxes left:
@@ -2469,7 +2740,9 @@ ${left.map((box) => `${String(box.number).padStart(3)} [ ] ${box.text}`).join(`
2469
2740
  `)}`, 1, `${runHint(`ft fixed ${number} --tick`, "tick them and close")}, or ${runHint(`ft fixed ${number} --force`, "close with them left")}`);
2470
2741
  }
2471
2742
  const shas = joinShas(commits.map((each) => each.sha.slice(0, 7)));
2472
- await comment(number, `Fixed in ${shas}: ${values.message ?? commits[0]?.subject ?? ""}`, values.repo);
2743
+ const line = `Fixed in ${shas}: ${values.message ?? commits[0]?.subject ?? ""}`;
2744
+ if ((await commentsOf(number, values.repo)).at(-1)?.body.trim() !== line.trim())
2745
+ await comment(number, line, values.repo);
2473
2746
  await close(number, values.repo);
2474
2747
  console.log(`closed #${number}: fixed in ${shas}`);
2475
2748
  await tickListings(number, values.repo);
@@ -2484,22 +2757,22 @@ With no failed job it prints every job's last lines; --full prints whole logs.
2484
2757
 
2485
2758
  Exits 0 when it printed the logs, 1 when Forgejo cannot be read, 2 on a usage error or a job the run lacks.`;
2486
2759
  var options3 = {
2487
- job: { type: "string", short: "j", description: "only this job, by name" },
2760
+ job: { type: "string", short: "j", description: "only this job, by name or id" },
2488
2761
  full: { type: "boolean", short: "f", description: "the whole log, timestamps stripped" },
2489
2762
  ...repoOption
2490
2763
  };
2491
- function pickJobs(jobs, name) {
2492
- if (name !== undefined)
2493
- return jobs.filter((job) => job.name === name);
2764
+ function pickJobs(jobs, wanted) {
2765
+ if (wanted !== undefined)
2766
+ return jobs.filter((job) => job.name === wanted || String(job.id) === wanted);
2494
2767
  const failed = jobs.filter((job) => job.status === "failure");
2495
2768
  return failed.length > 0 ? failed : jobs;
2496
2769
  }
2497
- async function logs(args) {
2770
+ async function logs2(args) {
2498
2771
  const parsed = parse(args, usage3, options3);
2499
2772
  const { values, positionals } = parsed;
2500
2773
  if (positionals[0] === undefined)
2501
2774
  fail("a run id is needed", 2, runHint("ft runs", "list them"));
2502
- const run = await runById(wholeNumber(positionals[0], "run id", 0), values.repo);
2775
+ const run = await runById(wholeNumber(positionals[0], "<run id>", 0), values.repo);
2503
2776
  const jobs = await jobsOf(run.id, values.repo);
2504
2777
  const picked = pickJobs(jobs, values.job);
2505
2778
  if (picked.length === 0)
@@ -2776,8 +3049,8 @@ function report(plan, added, skipped) {
2776
3049
 
2777
3050
  // src/commands/release.ts
2778
3051
  import { basename as basename2, join as join2, relative, resolve } from "path";
2779
- import process13 from "process";
2780
- var {$: $3 } = globalThis.Bun;
3052
+ import process14 from "process";
3053
+ var {$ } = globalThis.Bun;
2781
3054
 
2782
3055
  // src/commands/watch.ts
2783
3056
  var usage4 = `ft watch [--after <id>]
@@ -2795,18 +3068,15 @@ var STALLED2 = 3 * 60000;
2795
3068
  async function watchRuns(pick, timeout, repo, idle) {
2796
3069
  const started = Date.now();
2797
3070
  const reported = new Map;
3071
+ const followed = new Set;
3072
+ const failed = new Set;
2798
3073
  let moving = started;
2799
- for (;; ) {
2800
- await Bun.sleep(timing.poll);
2801
- if (Date.now() - started > timeout)
2802
- fail(`the runs did not finish in ${formatDuration(timeout)}`);
2803
- let runs;
2804
- try {
2805
- runs = pick(await recentRuns(LIMIT, repo));
2806
- } catch (error) {
2807
- console.warn(`ft watch: ${error instanceof Error ? error.message : String(error)}, polling again`);
2808
- continue;
2809
- }
3074
+ async function poll() {
3075
+ const newest = await recentRuns(LIMIT, repo);
3076
+ const gone = await Promise.all([...followed].filter((id) => !newest.some((run) => run.id === id)).map(async (id) => runById(id, repo)));
3077
+ const runs = [...pick(newest), ...gone];
3078
+ for (const run of runs)
3079
+ followed.add(run.id);
2810
3080
  for (const run of runs) {
2811
3081
  if (reported.get(run.id) !== run.status)
2812
3082
  console.log(`${run.workflow_id} run ${run.id} on ${run.prettyref}: ${badge(run.status, process.stdout)}`);
@@ -2817,26 +3087,44 @@ async function watchRuns(pick, timeout, repo, idle) {
2817
3087
  console.log("no run started, so nothing was waited for");
2818
3088
  return 0;
2819
3089
  }
2820
- continue;
3090
+ return;
2821
3091
  }
2822
- const status = overall(runs);
2823
- if (status === "failure" || status === "cancelled") {
2824
- for (const run of runs) {
2825
- if (run.status === "failure")
2826
- console.error(`
3092
+ for (const run of runs) {
3093
+ if (run.status === "failure" && !failed.has(run.id)) {
3094
+ console.error(`
2827
3095
  ${await failureReport(run, repo)}
2828
3096
 
2829
3097
  ${run.html_url}`);
3098
+ failed.add(run.id);
2830
3099
  }
2831
- return 1;
2832
3100
  }
2833
- if (runs.every((run) => FINISHED.has(run.status)))
2834
- return 0;
3101
+ if (runs.every((run) => FINISHED.has(run.status))) {
3102
+ const status = overall(runs);
3103
+ return status === "failure" || status === "cancelled" ? 1 : 0;
3104
+ }
2835
3105
  const stall = await stallReason(runs, repo);
2836
3106
  if (stall === undefined)
2837
3107
  moving = Date.now();
2838
3108
  else if (stall.certain || Date.now() - moving > STALLED2)
2839
3109
  fail(`queued runs are not moving: ${stall.reason}`);
3110
+ return;
3111
+ }
3112
+ for (;; ) {
3113
+ await Bun.sleep(timing.poll);
3114
+ if (Date.now() - started > timeout)
3115
+ fail(`the runs did not finish in ${formatDuration(timeout)}`);
3116
+ try {
3117
+ const code = await poll();
3118
+ if (code !== undefined)
3119
+ return code;
3120
+ } catch (error) {
3121
+ if (error instanceof Exit)
3122
+ throw error;
3123
+ const message = error instanceof Error ? error.message : String(error);
3124
+ if (isPermanentFailure(error))
3125
+ fail(message);
3126
+ console.warn(`ft watch: ${message}, polling again`);
3127
+ }
2840
3128
  }
2841
3129
  }
2842
3130
  async function watch(args) {
@@ -2862,7 +3150,7 @@ ft release --tagged
2862
3150
  Cuts a release from the default branch: first the commit it sits on is pushed and its runs waited for, so failing tests stop it before anything is bumped or tagged. Then \`bun pm version\` commits and tags the bump, one atomic push sends the branch and its tag, and it waits for the runs the push started and for the registry to serve the version.
2863
3151
  --tagged releases a version commit already tagged at HEAD instead, for repos bumping with their own script; the runs waited for first are then those of the commit below it.
2864
3152
  --package releases one workspace package of a monorepo on its own, tagged \`<folder>-v<version>\` (\`ft release patch -p ft\` tags packages/ft as ft-v0.3.0).
2865
- A refused push drops the bump commit and its tag, so a retry starts clean.
3153
+ A refused or interrupted push drops the bump commit and its tag, so a retry starts clean; with --tagged the commit and tag are yours and stay.
2866
3154
  Once released, an open milestone titled like the version (\`1.2.0\`, \`v1.2.0\` or the tag) has its closed issues listed and is closed; a failure there only warns.
2867
3155
  --dry-run checks the same and prints the plan: the version bump, the tag, the pushes and the runs and registry it would wait for, changing nothing.
2868
3156
  Exits 0 once released or planned, 1 when a run fails, origin refuses a push or the registry never serves the version, 2 on a timeout, a stalled queue, a refusal before anything ran (another branch, uncommitted changes, origin ahead) or a usage error.`;
@@ -2879,6 +3167,8 @@ var PARTS = /^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/;
2879
3167
  var LAST_NUMBER = /\d+$/;
2880
3168
  var REGISTRY_TRIES = 12;
2881
3169
  var REGISTRY_POLL = 5000;
3170
+ var limits = { pushMs: 5 * 60000 };
3171
+ var REGISTRY_INFO_MS = 30000;
2882
3172
  var NO_RUN2 = 3 * 60000;
2883
3173
  function taggedVersion(tag, prefix) {
2884
3174
  const version = tag.slice(prefix.length);
@@ -2921,7 +3211,7 @@ async function commitPackageBump(dir, prefix) {
2921
3211
  const { version } = await Bun.file(join2(dir, "package.json")).json();
2922
3212
  const root = await git(["rev-parse", "--show-toplevel"]);
2923
3213
  const tag = `${prefix}${version}`;
2924
- const lock = await $3`bun install --lockfile-only`.cwd(root).quiet().nothrow();
3214
+ const lock = await $`bun install --lockfile-only`.cwd(root).quiet().nothrow();
2925
3215
  if (lock.exitCode !== 0)
2926
3216
  fail(`bun install --lockfile-only after the bump: ${lock.stderr.toString().trim()}`);
2927
3217
  await git(["add", join2(dir, "package.json"), join2(root, "bun.lock")]);
@@ -2930,8 +3220,14 @@ async function commitPackageBump(dir, prefix) {
2930
3220
  }
2931
3221
  async function waitForRegistry(name, version) {
2932
3222
  for (let attempt = 0;attempt < REGISTRY_TRIES; attempt++) {
2933
- const served = (await $3`bun info ${name} version --no-cache`.quiet().nothrow().text()).trim().split(`
3223
+ let served;
3224
+ try {
3225
+ served = (await runTool("bun info", ["bun", "info", name, "version", "--no-cache"], { timeoutMs: REGISTRY_INFO_MS })).stdout.trim().split(`
2934
3226
  `).at(-1);
3227
+ } catch (error) {
3228
+ if (isPermanentFailure(error))
3229
+ throw error;
3230
+ }
2935
3231
  if (served === version)
2936
3232
  return true;
2937
3233
  await Bun.sleep(REGISTRY_POLL);
@@ -2940,10 +3236,18 @@ async function waitForRegistry(name, version) {
2940
3236
  }
2941
3237
  async function testsPass(sha, branch, timeout) {
2942
3238
  if (!await gitOk(["merge-base", "--is-ancestor", sha, `origin/${branch}`])) {
2943
- const push = await $3`git push origin ${`${sha}:refs/heads/${branch}`}`.quiet().nothrow();
2944
- if (push.exitCode !== 0)
3239
+ const held = holdSignals();
3240
+ let push;
3241
+ try {
3242
+ push = await runTool("git push", ["git", "push", "origin", `${sha}:refs/heads/${branch}`], { timeoutMs: limits.pushMs, onStart: (child) => held.forward(child) });
3243
+ } finally {
3244
+ held.release();
3245
+ }
3246
+ if (held.signal !== undefined)
3247
+ fail(`interrupted by ${held.signal} while pushing ${sha.slice(0, 7)}, so nothing was bumped`, exitCodeOf(null, held.signal));
3248
+ if (push.code !== 0)
2945
3249
  fail(`origin refused the push of ${sha.slice(0, 7)}, so nothing was bumped:
2946
- ${push.stderr.toString().trim()}`, 1);
3250
+ ${push.stderr.trim()}`, 1);
2947
3251
  console.log(`pushed ${branch} at ${sha.slice(0, 7)}, waiting for its runs before the bump`);
2948
3252
  }
2949
3253
  return await watchRuns((runs) => newestPerWorkflow(runs.filter((run) => run.commit_sha === sha)), timeout, undefined, NO_RUN2) === 0;
@@ -2951,8 +3255,8 @@ ${push.stderr.toString().trim()}`, 1);
2951
3255
  async function printPlan({ level, dir, prefix, branch, base, timeout, registry }) {
2952
3256
  const file = Bun.file(join2(dir ?? ".", "package.json"));
2953
3257
  const pkg = await file.exists() ? await file.json() : undefined;
2954
- const name = pkg?.name ?? basename2(dir ?? process13.cwd());
2955
- const where = dir === undefined ? "" : ` in ${relative(process13.cwd(), dir) || "."}`;
3258
+ const name = pkg?.name ?? basename2(dir ?? process14.cwd());
3259
+ const where = dir === undefined ? "" : ` in ${relative(process14.cwd(), dir) || "."}`;
2956
3260
  const sha = base.slice(0, 7);
2957
3261
  let version;
2958
3262
  let bump;
@@ -2983,6 +3287,17 @@ async function printPlan({ level, dir, prefix, branch, base, timeout, registry }
2983
3287
  console.log("dry run: nothing was pushed, committed or tagged");
2984
3288
  return 0;
2985
3289
  }
3290
+ async function rollBack(preBump, tagsBefore) {
3291
+ try {
3292
+ for (const name of await tagsAt("HEAD")) {
3293
+ if (!tagsBefore.has(name))
3294
+ await git(["tag", "-d", name]);
3295
+ }
3296
+ await git(["reset", "--keep", preBump]);
3297
+ } catch (error) {
3298
+ warn(`could not undo the bump, back to ${preBump.slice(0, 7)} by hand`, error instanceof Error ? error.message : String(error));
3299
+ }
3300
+ }
2986
3301
  async function release(args) {
2987
3302
  const parsed = parse(args, usage5, options6);
2988
3303
  const { values, positionals } = parsed;
@@ -3003,34 +3318,61 @@ ${usage5}`);
3003
3318
  fail(`a release is cut from ${branch}, not ${current || "a detached HEAD"}`, 2, runHint(`git switch ${branch}`));
3004
3319
  if (await git(["status", "--porcelain"]) !== "")
3005
3320
  fail("the tree has uncommitted changes", 2, runHint("gst", "see them, then commit them with gc"));
3006
- await git(["fetch", "-q", "origin", branch]);
3007
- if ((await $3`git merge-base --is-ancestor ${`origin/${branch}`} HEAD`.quiet().nothrow()).exitCode !== 0)
3321
+ await fetchBranch(branch);
3322
+ if ((await $`git merge-base --is-ancestor ${`origin/${branch}`} HEAD`.quiet().nothrow()).exitCode !== 0)
3008
3323
  fail(`origin/${branch} has commits HEAD lacks`, 2, runHint(`git pull --rebase origin ${branch}`));
3009
3324
  const base = await git(["rev-parse", tagged ? "HEAD~1" : "HEAD"]);
3010
3325
  if (values["dry-run"] === true)
3011
3326
  return printPlan({ level, dir, prefix, branch, base, timeout, registry: values["no-registry"] !== true });
3012
3327
  if (!await testsPass(base, branch, timeout))
3013
3328
  fail(`the runs on ${base.slice(0, 7)} failed, so nothing was bumped or tagged`, 1, runHint(`ft runs --sha ${base.slice(0, 7)}`, "list the runs, then ft logs <id> reads the failure"));
3014
- if (level !== undefined) {
3015
- const bump = dir === undefined ? await $3`bun pm version ${level}`.quiet().nothrow() : await $3`bun pm version ${level} --no-git-tag-version`.cwd(dir).quiet().nothrow();
3016
- if (bump.exitCode !== 0)
3017
- fail(`bun pm version ${level}: ${bump.stderr.toString().trim() || bump.stdout.toString().trim()}`);
3018
- if (dir !== undefined)
3019
- await commitPackageBump(dir, prefix);
3020
- }
3021
- const tag = (await tagsAt("HEAD")).find((name) => taggedVersion(name, prefix) !== undefined);
3022
- if (tag === undefined)
3023
- fail(tagged ? `HEAD carries no ${prefix}* tag` : `the bump made no ${prefix}* tag`);
3329
+ const preBump = await git(["rev-parse", "HEAD"]);
3330
+ const tagsBefore = new Set(await tagsAt("HEAD"));
3024
3331
  const before = (await recentRuns(1))[0]?.id ?? 0;
3025
- const push = await $3`git push --atomic origin ${`HEAD:refs/heads/${branch}`} ${tag}`.quiet().nothrow();
3026
- if (push.exitCode !== 0) {
3027
- await git(["tag", "-d", tag]);
3028
- await git(["reset", "--keep", "HEAD~1"]);
3029
- fail(`origin refused the push, so ${tag} and its commit were dropped for a clean retry:
3030
- ${push.stderr.toString().trim()}`, 1);
3332
+ const held = holdSignals();
3333
+ const signalOf = () => held.signal;
3334
+ let tag = "";
3335
+ let head = "";
3336
+ let pushed = false;
3337
+ let isPushUnknown = false;
3338
+ try {
3339
+ if (level !== undefined) {
3340
+ const bump = dir === undefined ? await $`bun pm version ${level}`.quiet().nothrow() : await $`bun pm version ${level} --no-git-tag-version`.cwd(dir).quiet().nothrow();
3341
+ if (bump.exitCode !== 0)
3342
+ fail(`bun pm version ${level}: ${bump.stderr.toString().trim() || bump.stdout.toString().trim()}`);
3343
+ if (dir !== undefined)
3344
+ await commitPackageBump(dir, prefix);
3345
+ }
3346
+ const found = (await tagsAt("HEAD")).find((name) => taggedVersion(name, prefix) !== undefined);
3347
+ if (found === undefined)
3348
+ fail(tagged ? `HEAD carries no ${prefix}* tag` : `the bump made no ${prefix}* tag`);
3349
+ tag = found;
3350
+ head = await git(["rev-parse", "HEAD"]);
3351
+ if (held.signal !== undefined)
3352
+ fail(`interrupted by ${held.signal} before the push`, exitCodeOf(null, held.signal));
3353
+ let push;
3354
+ try {
3355
+ push = await runTool("git push", ["git", "push", "--atomic", "origin", `HEAD:refs/heads/${branch}`, tag], { timeoutMs: limits.pushMs, onStart: (child) => held.forward(child) });
3356
+ } catch (error) {
3357
+ isPushUnknown = true;
3358
+ fail(`${error instanceof Error ? error.message : String(error)}, so ${tag} and its commit are kept: the push may have landed`, 2, `git ls-remote origin refs/tags/${tag} tells; if it is there, ft release --tagged waits for its runs`);
3359
+ }
3360
+ if (push.code !== 0) {
3361
+ const signal = signalOf();
3362
+ fail(`origin refused the push${signal === undefined ? "" : `, or ${signal} interrupted it`}, so ${level === undefined ? `${tag} and its commit are kept for a retry` : `${tag} and its commit were dropped for a clean retry`}:
3363
+ ${push.stderr.trim()}`, signal === undefined ? 1 : exitCodeOf(null, signal));
3364
+ }
3365
+ pushed = true;
3366
+ } finally {
3367
+ held.release();
3368
+ if (!pushed && !isPushUnknown && level !== undefined)
3369
+ await rollBack(preBump, tagsBefore);
3031
3370
  }
3371
+ const late = signalOf();
3372
+ if (late !== undefined)
3373
+ fail(`interrupted by ${late} after ${tag} was pushed: its runs are not waited for`, exitCodeOf(null, late));
3032
3374
  console.log(`pushed ${branch} and ${tag}, waiting for their runs`);
3033
- const code = await watchRuns((runs) => runs.filter((run) => run.id > before), timeout);
3375
+ const code = await watchRuns((runs) => runs.filter((run) => run.id > before && run.commit_sha === head), timeout, undefined, NO_RUN2);
3034
3376
  if (code !== 0)
3035
3377
  return code;
3036
3378
  const file = Bun.file(join2(dir ?? ".", "package.json"));
@@ -3069,7 +3411,7 @@ ${done.map((issue) => ` #${issue.number} ${issue.title}`).join(`
3069
3411
  // src/commands/remote.ts
3070
3412
  import { lookup } from "dns/promises";
3071
3413
  import { homedir as homedir3 } from "os";
3072
- var {$: $4 } = globalThis.Bun;
3414
+ var {$: $2 } = globalThis.Bun;
3073
3415
 
3074
3416
  // src/config.ts
3075
3417
  import { homedir as homedir2 } from "os";
@@ -3078,30 +3420,53 @@ function settingsPath(home = homedir2()) {
3078
3420
  return join3(home, ".config", "ft", "config.json");
3079
3421
  }
3080
3422
  async function loadSettings(home = homedir2()) {
3081
- const file = Bun.file(settingsPath(home));
3423
+ const path = settingsPath(home);
3424
+ const file = Bun.file(path);
3082
3425
  if (!await file.exists())
3083
3426
  return {};
3084
3427
  let raw;
3085
3428
  try {
3086
3429
  raw = await file.json();
3087
- } catch {
3430
+ } catch (error) {
3431
+ warn(`${path} is not valid JSON, so its settings are ignored`, error instanceof Error ? error.message : String(error));
3432
+ return {};
3433
+ }
3434
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
3435
+ warn(`${path} is not a JSON object, so its settings are ignored`);
3088
3436
  return {};
3089
3437
  }
3090
- const host = typeof raw === "object" && raw !== null && "remoteHost" in raw ? raw.remoteHost : undefined;
3438
+ const host = "remoteHost" in raw ? raw.remoteHost : undefined;
3439
+ if (host !== undefined && (typeof host !== "string" || host === ""))
3440
+ warn(`remoteHost in ${path} must be a non-empty string, so it is ignored`);
3091
3441
  return typeof host === "string" && host !== "" ? { remoteHost: host } : {};
3092
3442
  }
3093
3443
 
3094
3444
  // src/remote.ts
3095
3445
  var SCP_LIKE = /^(?:([^@/]+)@)?([^:/]+):(?!\/)(.+?)(?:\.git)?\/?$/;
3096
- var SSH_URL = /^ssh:\/\/(?:([^@/]+)@)?([^:/]+)(?::\d+)?\/(.+?)(?:\.git)?\/?$/;
3446
+ var SSH_URL = /^ssh:\/\/(?:([^@/]+)@)?([^:/]+)(?::(\d+))?\/(.+?)(?:\.git)?\/?$/;
3447
+ var USERINFO = /^([a-z][a-z\d+.-]*:\/\/)[^@/]*@/i;
3448
+ var DEFAULT_PORT = "22";
3097
3449
  function parseSshRemote(url) {
3098
- const match = SSH_URL.exec(url) ?? (url.includes("://") ? null : SCP_LIKE.exec(url));
3099
- return match === null ? undefined : { user: match[1] ?? "git", host: match[2] ?? "", path: match[3] ?? "" };
3450
+ const ssh = SSH_URL.exec(url);
3451
+ const scp = ssh === null && !url.includes("://") ? SCP_LIKE.exec(url) : null;
3452
+ const match = ssh ?? scp;
3453
+ if (match === null || match[2]?.startsWith("-") === true)
3454
+ return;
3455
+ const login = match[1] ?? "git";
3456
+ const user = login.includes(":") ? login.slice(0, login.indexOf(":")) : login;
3457
+ if (ssh === null)
3458
+ return { user, host: match[2] ?? "", path: match[3] ?? "" };
3459
+ return { user, host: match[2] ?? "", path: match[4] ?? "", ...ssh[3] === undefined ? {} : { port: ssh[3] } };
3460
+ }
3461
+ function redactUrl(url) {
3462
+ return url.replace(USERINFO, "$1");
3100
3463
  }
3101
3464
  function canonicalRemote(url, host, reaches) {
3102
3465
  const remote = parseSshRemote(url);
3103
3466
  if (remote === undefined || remote.host === host || !reaches(remote.host))
3104
3467
  return;
3468
+ if (remote.port !== undefined && remote.port !== DEFAULT_PORT)
3469
+ return `ssh://${remote.user}@${host}:${remote.port}/${remote.path}.git`;
3105
3470
  return `${remote.user}@${host}:${remote.path}.git`;
3106
3471
  }
3107
3472
  async function reachesHost(hostname, host, hostAddresses, addressesOf) {
@@ -3124,7 +3489,7 @@ var options7 = {
3124
3489
  };
3125
3490
  var HOSTNAME = /^hostname (.+)$/m;
3126
3491
  async function sshHostname(alias) {
3127
- return HOSTNAME.exec(await $4`ssh -G ${alias}`.quiet().nothrow().text())?.[1];
3492
+ return HOSTNAME.exec(await $2`ssh -G -- ${alias}`.quiet().nothrow().text())?.[1];
3128
3493
  }
3129
3494
  async function addresses(name) {
3130
3495
  try {
@@ -3133,6 +3498,14 @@ async function addresses(name) {
3133
3498
  return [];
3134
3499
  }
3135
3500
  }
3501
+ async function restoreOrigin(fetchUrl, pushUrls, changes) {
3502
+ if (changes.some(([from]) => from === fetchUrl))
3503
+ await git(["remote", "set-url", "origin", fetchUrl]);
3504
+ for (const [from, to] of changes) {
3505
+ if (pushUrls.includes(from))
3506
+ await git(["remote", "set-url", "--push", "origin", from, to]);
3507
+ }
3508
+ }
3136
3509
  function originHost(url) {
3137
3510
  return parseSshRemote(url)?.host ?? (URL.canParse(url) ? new URL(url).hostname : undefined);
3138
3511
  }
@@ -3144,7 +3517,7 @@ async function remote(args, home = homedir3()) {
3144
3517
  return 0;
3145
3518
  }
3146
3519
  const fetchUrl = await git(["remote", "get-url", "origin"]);
3147
- const pushUrls = (await $4`git config --get-all remote.origin.pushurl`.quiet().nothrow().text()).split(`
3520
+ const pushUrls = (await $2`git config --get-all remote.origin.pushurl`.quiet().nothrow().text()).split(`
3148
3521
  `).filter(Boolean);
3149
3522
  const hostAddresses = new Set(await addresses(host));
3150
3523
  const hostnames = new Map;
@@ -3162,15 +3535,15 @@ async function remote(args, home = homedir3()) {
3162
3535
  if (changes.length === 0) {
3163
3536
  const named = originHost(fetchUrl);
3164
3537
  if (named === host) {
3165
- console.log(`origin already names ${host}: ${fetchUrl}`);
3538
+ console.log(`origin already names ${host}: ${redactUrl(fetchUrl)}`);
3166
3539
  return 0;
3167
3540
  }
3168
3541
  const hostname = named === undefined ? undefined : hostnames.get(named);
3169
- console.log(`origin ${fetchUrl} does not reach ${host}${hostname === undefined || hostname === named ? "" : `: ssh connects ${named} to ${hostname}`}, pass --host if Forgejo lives elsewhere`);
3542
+ console.log(`origin ${redactUrl(fetchUrl)} does not reach ${host}${hostname === undefined || hostname === named ? "" : `: ssh connects ${named} to ${hostname}`}, pass --host if Forgejo lives elsewhere`);
3170
3543
  return 1;
3171
3544
  }
3172
3545
  for (const [from, to] of changes)
3173
- console.log(`${from} -> ${to}`);
3546
+ console.log(`${redactUrl(from)} -> ${redactUrl(to)}`);
3174
3547
  if (values.fix !== true) {
3175
3548
  console.log("run `ft remote --fix` to switch them");
3176
3549
  return 1;
@@ -3182,7 +3555,12 @@ async function remote(args, home = homedir3()) {
3182
3555
  if (pushUrls.includes(from))
3183
3556
  await git(["remote", "set-url", "--push", "origin", to, from]);
3184
3557
  }
3185
- await git(["ls-remote", "origin", "HEAD"]);
3558
+ try {
3559
+ await git(["ls-remote", "origin", "HEAD"]);
3560
+ } catch (error) {
3561
+ await restoreOrigin(fetchUrl, pushUrls, changes);
3562
+ return fail(`the switched origin does not answer, so the old URLs were put back: ${redactUrl(error instanceof Error ? error.message : String(error))}`, 1);
3563
+ }
3186
3564
  console.log("origin switched, and git ls-remote reaches it");
3187
3565
  return 0;
3188
3566
  }
@@ -3234,7 +3612,7 @@ async function runs(args) {
3234
3612
  }
3235
3613
 
3236
3614
  // src/cli.ts
3237
- process14.exitCode = await runCli({
3615
+ process15.exitCode = await runCli({
3238
3616
  name: "ft",
3239
3617
  version,
3240
3618
  about: "Forgejo tools over tea, run from a repository whose origin is on Forgejo",
@@ -3242,9 +3620,9 @@ process14.exitCode = await runCli({
3242
3620
  examples: [["ft ci", "wait for the runs of origin's tip, failures printed"], ["ft issue 12 -c", "an issue with its boxes numbered and its comments"]],
3243
3621
  commands: {
3244
3622
  runs: { run: runs, summary: "recent runs with workflow, ref, duration and age" },
3245
- ci: { run: ci, summary: "wait for the runs covering a commit, printing what failed" },
3623
+ ci: { run: ci2, summary: "wait for the runs covering a commit, printing what failed" },
3246
3624
  watch: { run: watch, summary: "wait for runs newer than an id, or those in progress" },
3247
- logs: { run: logs, summary: "what the failed jobs of a run say went wrong" },
3625
+ logs: { run: logs2, summary: "what the failed jobs of a run say went wrong" },
3248
3626
  release: { run: release, summary: "bump, push branch and tag atomically, wait for runs and registry" },
3249
3627
  board: { run: board, summary: "open issues: available, taken by a live session, or claimed by one gone" },
3250
3628
  digest: { run: digest, summary: "repo state for a cold start: held and abandoned claims, red CI, unpushed commits, dirty files, stale TODOs" },