@kaynooo/forgejo-tools 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.js +881 -502
  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,35 +621,363 @@ 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
624
+ // ../kit/src/progress.ts
625
+ import process6 from "process";
626
+
627
+ // ../kit/src/process.ts
628
+ import { constants } from "os";
586
629
  import process5 from "process";
630
+ var KILL_GRACE_MS = 2000;
631
+ var SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
632
+ function exitCodeOf(code, signal) {
633
+ if (code !== null)
634
+ return code;
635
+ if (typeof signal === "number")
636
+ return 128 + signal;
637
+ return signal === null ? 129 : 128 + constants.signals[signal];
638
+ }
639
+ function holdSignals(cleanup) {
640
+ let received;
641
+ let child;
642
+ const held = {
643
+ get signal() {
644
+ return received;
645
+ },
646
+ forward: (to) => {
647
+ child = to;
648
+ },
649
+ release: () => {
650
+ for (const signal of SIGNALS)
651
+ process5.off(signal, onSignal);
652
+ }
653
+ };
654
+ function onSignal(signal) {
655
+ received = signal;
656
+ if (child !== undefined) {
657
+ child.kill(signal);
658
+ return;
659
+ }
660
+ if (cleanup === undefined)
661
+ return;
662
+ held.release();
663
+ cleanup(signal);
664
+ process5.kill(process5.pid, signal);
665
+ }
666
+ for (const signal of SIGNALS)
667
+ process5.on(signal, onSignal);
668
+ return held;
669
+ }
670
+ async function runCommand(argv, { cwd, env, stdin = "ignore", timeoutMs, signal, maxBytes = Infinity, onChunk, onStart } = {}) {
671
+ const chunks = [];
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
+ };
747
+ }
587
748
 
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;
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;
597
865
  }
598
- return previous[b.length] ?? 0;
599
866
  }
600
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
+ }
601
966
  // ../kit/src/cli.ts
967
+ import process8 from "process";
602
968
  var LEVEL_OPTIONS = {
603
969
  ...OUTPUT_OPTIONS,
604
970
  help: { type: "boolean", short: "h", description: "show this help" }
605
971
  };
606
972
  var TOP_OPTIONS = { ...LEVEL_OPTIONS, version: { type: "boolean", short: "v", description: "print the version" } };
607
- function commandRows(commands, colors) {
973
+ async function commandRows(commands, colors) {
608
974
  const width = Math.max(...Object.keys(commands).map((name) => name.length));
609
975
  const groups = new Map([["Commands:", []]]);
610
- for (const [name, { summary, group }] of Object.entries(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()) {
611
978
  const heading = group === undefined ? "Commands:" : `${group}:`;
612
979
  const rows = groups.get(heading) ?? [];
613
- rows.push(` ${colors.cyan(name.padEnd(width))} ${colors.dim(summary)}`);
980
+ rows.push(` ${colors.cyan(name.padEnd(width))} ${colors.dim(summaries[index])}`);
614
981
  groups.set(heading, rows);
615
982
  }
616
983
  return [...groups].filter(([, rows]) => rows.length > 0).flatMap(([heading, rows], index) => [...index === 0 ? [] : [""], colors.bold(heading), ...rows]);
@@ -630,28 +997,38 @@ function takeOutputFlags(argv) {
630
997
  }
631
998
  return argv.slice(index);
632
999
  }
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;
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: [] };
639
1015
  }
640
- async function help({ path, header, summary, commands, defaultCommand, examples, replaces }, stream) {
1016
+ async function help({ path, header, summary: lazySummary, commands, defaultCommand, examples, replaces }, stream) {
641
1017
  const colors = paint(stream);
642
1018
  const prefix = path.join(" ");
1019
+ const summary = await resolveSummary(lazySummary);
643
1020
  if (wantsHelpJson())
644
1021
  return JSON.stringify(description(`${prefix} <command> [options]: ${summary}`, { examples, replaces }));
645
1022
  const fallback = defaultCommand === undefined ? undefined : commands[defaultCommand];
646
1023
  const options = fallback !== undefined && "run" in fallback ? await optionsOf(fallback.run) : undefined;
647
1024
  const own = Object.entries(options ?? {}).filter(([name]) => !(name in TOP_OPTIONS));
648
1025
  return [
649
- header(colors),
1026
+ header(colors, summary),
650
1027
  "",
651
1028
  usageLine(`${prefix} ${defaultCommand === undefined ? colors.cyan("<command>") : `[${colors.cyan("<command>")}]`} [options]`, colors),
652
1029
  ...replacesRows(replaces, colors),
653
1030
  "",
654
- ...commandRows(commands, colors),
1031
+ ...await commandRows(commands, colors),
655
1032
  ...own.length === 0 ? [] : ["", `${colors.bold(`Options of ${defaultCommand}:`)} ${colors.dim("the command run when none is named")}`, ...optionRows(Object.fromEntries(own), colors)],
656
1033
  "",
657
1034
  colors.bold("Options:"),
@@ -663,8 +1040,8 @@ async function help({ path, header, summary, commands, defaultCommand, examples,
663
1040
  `);
664
1041
  }
665
1042
  function printError(prefix, message, hint, cause) {
666
- const colors = paint(process5.stderr);
667
- const styled = takesControlCodes(process5.stderr);
1043
+ const colors = paint(process8.stderr);
1044
+ const styled = takesControlCodes(process8.stderr);
668
1045
  const head = styled ? `${colors.red.bold("error")} ${colors.dim(`${prefix}:`)}` : `error: ${prefix}:`;
669
1046
  const under = (label, text, tint) => text === undefined ? "" : `
670
1047
  ${styled ? tint(label) : `${label}:`} ${text}`;
@@ -674,8 +1051,8 @@ function runHint(command, effect) {
674
1051
  return `run \`${command}\`${effect === undefined ? "" : ` to ${effect}`}`;
675
1052
  }
676
1053
  function printHint(hint) {
677
- const colors = paint(process5.stderr);
678
- console.error(` ${takesControlCodes(process5.stderr) ? colors.yellow("hint") : "hint:"} ${hint}`);
1054
+ const colors = paint(process8.stderr);
1055
+ console.error(` ${takesControlCodes(process8.stderr) ? colors.yellow("hint") : "hint:"} ${hint}`);
679
1056
  }
680
1057
  function printJsonError(message, hint, cause) {
681
1058
  console.error(JSON.stringify({ error: message, ...hint === undefined ? {} : { hint }, ...cause === undefined ? {} : { cause } }));
@@ -704,11 +1081,11 @@ async function dispatch(level, typed, { version, redact }) {
704
1081
  }
705
1082
  const [name, ...args] = argv;
706
1083
  if (name === undefined && level.path.length > 1) {
707
- console.error(await help(level, process5.stderr));
1084
+ console.error(await help(await expand(level, [...level.path.slice(1), ...argv]), process8.stderr));
708
1085
  return 2;
709
1086
  }
710
1087
  if (name === undefined || name === "help" || name === "--help" || name === "-h") {
711
- console.log(await help(level, process5.stdout));
1088
+ console.log(await help(await expand(level, [...level.path.slice(1), ...argv]), process8.stdout));
712
1089
  return 0;
713
1090
  }
714
1091
  if (level.path.length === 1 && (name === "--version" || name === "-v")) {
@@ -717,6 +1094,10 @@ async function dispatch(level, typed, { version, redact }) {
717
1094
  }
718
1095
  let command = level.commands[level.aliases?.[name] ?? name];
719
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
+ }
720
1101
  if (command === undefined && level.defaultCommand !== undefined) {
721
1102
  command = level.commands[level.defaultCommand];
722
1103
  rest = argv;
@@ -728,7 +1109,7 @@ async function dispatch(level, typed, { version, redact }) {
728
1109
  }
729
1110
  if ("commands" in command) {
730
1111
  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 });
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 });
732
1113
  }
733
1114
  try {
734
1115
  return await command.run(rest);
@@ -753,28 +1134,68 @@ async function optionsOf(run) {
753
1134
  async function leafCandidates(run, words) {
754
1135
  if (!(words.at(-1) ?? "").startsWith("-"))
755
1136
  return [FILES];
756
- return Object.entries(await optionsOf(run)).map(([name, option]) => `--${name} ${option.description}`);
1137
+ return Object.entries(await optionsOf(run)).map(([name, option]) => option.description === undefined ? `--${name}` : `--${name} ${option.description}`);
757
1138
  }
758
- async function candidates(commands, aliases, defaultCommand, words) {
1139
+ async function candidates(scope, defaultCommand, words, before = []) {
759
1140
  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}`);
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)}`));
763
1150
  if (command === undefined) {
764
1151
  const fallback = defaultCommand === undefined ? undefined : commands[defaultCommand];
765
1152
  return fallback !== undefined && "run" in fallback ? leafCandidates(fallback.run, words) : [];
766
1153
  }
767
- return "commands" in command ? candidates(command.commands, command.aliases, undefined, rest) : leafCandidates(command.run, rest);
1154
+ return "commands" in command ? candidates(scopeOf(command.commands, command.extraCommands, command.aliases), undefined, rest, [...before, word]) : leafCandidates(command.run, rest);
768
1155
  }
769
1156
  async function printCandidates(found) {
770
1157
  for (const line of await found)
771
1158
  console.log(line);
772
1159
  return 0;
773
1160
  }
774
- async function runCli(cli, argv = process5.argv.slice(2)) {
1161
+ async function runCli(cli, argv = process8.argv.slice(2)) {
775
1162
  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 });
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));
778
1199
  }
779
1200
  // ../kit/src/limit.ts
780
1201
  function limitOptions(fallback) {
@@ -797,62 +1218,12 @@ function capped(rows, limit) {
797
1218
  const more = rows.length - limit;
798
1219
  return more > 0 ? { shown: rows.slice(0, limit), footer: moreFooter(more) } : { shown: [...rows], footer: undefined };
799
1220
  }
800
- // ../kit/src/process.ts
801
- import { constants } from "os";
802
- import process6 from "process";
803
- 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
- function exitCodeOf(code, signal) {
809
- if (code !== null)
810
- return code;
811
- if (typeof signal === "number")
812
- return 128 + signal;
813
- return signal === null ? 129 : 128 + constants.signals[signal];
814
- }
815
- function holdSignals(cleanup) {
816
- let received;
817
- let child;
818
- const held = {
819
- get signal() {
820
- return received;
821
- },
822
- forward: (to) => {
823
- child = to;
824
- },
825
- release: () => {
826
- for (const signal of SIGNALS)
827
- process6.off(signal, onSignal);
828
- }
829
- };
830
- function onSignal(signal) {
831
- received = signal;
832
- if (child !== undefined) {
833
- child.kill(signal);
834
- return;
835
- }
836
- if (cleanup === undefined)
837
- return;
838
- held.release();
839
- cleanup(signal);
840
- process6.kill(process6.pid, signal);
841
- }
842
- for (const signal of SIGNALS)
843
- process6.on(signal, onSignal);
844
- return held;
845
- }
846
- async function runCommand(argv, { cwd, env, stdin = "ignore" } = {}) {
847
- 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() };
853
- }
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"]);
854
1225
  // package.json
855
- var version = "0.6.0";
1226
+ var version = "0.7.0";
856
1227
 
857
1228
  // src/args.ts
858
1229
  var repoOption = {
@@ -864,27 +1235,41 @@ function issueNumber(value, usage) {
864
1235
  fail(`an issue number is needed
865
1236
 
866
1237
  ${usage}`);
867
- return wholeNumber(value.replace(ISSUE_HASH, ""), "issue", 0);
1238
+ return wholeNumber(value.replace(ISSUE_HASH, ""), "<issue>", 0);
868
1239
  }
869
1240
  function issueNumbers(values, usage) {
870
1241
  return values.length === 0 ? [issueNumber(undefined, usage)] : values.map((value) => issueNumber(value, usage));
871
1242
  }
872
1243
 
873
1244
  // src/checklist.ts
874
- var BOX = /^(\s*[-*+] \[)([ x])(\]\s)(.*)\r?$/i;
875
- var FENCE = /^\s*(?:```|~~~)/;
1245
+ var BOX = /^(\s*[-*+] \[)([ x])(\]\s)(.*?)\r?$/is;
1246
+ var FENCE = /^\s*(`{3,}|~{3,})/;
1247
+ var LIST_ITEM = /^\s*(?:[-*+]|\d+[.)])\s/;
1248
+ var INDENT_CODE = /^(?: {4}|\t)/;
876
1249
  function boxes(body) {
877
1250
  const found = [];
878
- let fenced = false;
1251
+ let fence;
1252
+ let inList = false;
879
1253
  for (const [line, text] of body.split(`
880
1254
  `).entries()) {
881
- if (FENCE.test(text)) {
882
- fenced = !fenced;
1255
+ const marker = FENCE.exec(text)?.[1];
1256
+ if (fence !== undefined) {
1257
+ if (marker?.startsWith(fence[0] ?? "") === true && marker.length >= fence.length && text.trim() === marker)
1258
+ fence = undefined;
883
1259
  continue;
884
1260
  }
885
- const match = fenced ? null : BOX.exec(text);
1261
+ if (marker !== undefined) {
1262
+ fence = marker;
1263
+ continue;
1264
+ }
1265
+ const blank = text.trim() === "";
1266
+ const match = INDENT_CODE.test(text) && !inList ? null : BOX.exec(text);
886
1267
  if (match !== null)
887
1268
  found.push({ number: found.length + 1, line, done: match[2] !== " ", text: match[4] ?? "" });
1269
+ if (LIST_ITEM.test(text))
1270
+ inList = true;
1271
+ else if (!blank && !text.startsWith(" ") && !text.startsWith("\t"))
1272
+ inList = false;
888
1273
  }
889
1274
  return found;
890
1275
  }
@@ -917,13 +1302,17 @@ function numberedBody(body) {
917
1302
  return lines.join(`
918
1303
  `).trim();
919
1304
  }
1305
+ var NUMBERED_PICK = /^(\d+):(.+)$/s;
1306
+ var WHOLE_PICK = /^\d+$/;
920
1307
  function setBoxes(body, picks, done) {
921
1308
  const all = boxes(body);
922
1309
  const chosen = new Map;
923
1310
  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));
1311
+ if (pick.trim() === "")
1312
+ throw new Error("an empty box pick matches every box");
1313
+ const guarded = NUMBERED_PICK.exec(pick);
1314
+ const needle = (guarded?.[2] ?? pick).trim().toLowerCase();
1315
+ 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
1316
  if (matches.length === 0)
928
1317
  throw new Error(`no box matches ${pick}`);
929
1318
  if (matches.length > 1)
@@ -961,8 +1350,8 @@ function tickReferences(body, wanted) {
961
1350
  }
962
1351
 
963
1352
  // src/claims.ts
964
- var TAKEN = /^\s*taken by session ([\w.@:-]+?)\.?\s*$/im;
965
- var RELEASED = /^\s*released by session ([\w.@:-]+?)\.?\s*$/im;
1353
+ var TAKEN = /^\s*taken by session ([\w.@:-]+?)\.?\s*$/i;
1354
+ var RELEASED = /^\s*released by session ([\w.@:-]+?)\.?\s*$/i;
966
1355
  var LANE = /^lane-([^-]+)-/;
967
1356
  function takenComment(name) {
968
1357
  return `Taken by session ${name}.`;
@@ -970,9 +1359,12 @@ function takenComment(name) {
970
1359
  function releasedComment(name) {
971
1360
  return `Released by session ${name}.`;
972
1361
  }
973
- function latestClaim(comments) {
1362
+ function isClaimant(comment, author) {
1363
+ return author === undefined || comment.user === undefined || comment.user.login === author;
1364
+ }
1365
+ function latestClaim(comments, author) {
974
1366
  let claim;
975
- for (const comment of [...comments].sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at))) {
1367
+ for (const comment of comments.filter((each) => isClaimant(each, author)).sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at))) {
976
1368
  const taken = TAKEN.exec(comment.body)?.[1];
977
1369
  if (taken !== undefined) {
978
1370
  claim = { holder: taken, at: comment.created_at };
@@ -992,87 +1384,22 @@ function latestNote(comments) {
992
1384
  }
993
1385
  function holderSession(holder, sessions) {
994
1386
  const own = sessions.find((session) => session.name === holder);
995
- if (own !== undefined)
996
- return own;
997
- const lane = LANE.exec(holder)?.[1];
998
- return lane === undefined ? undefined : sessions.find((session) => session.name.endsWith(`-${lane}`));
999
- }
1000
-
1001
- // 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));
1387
+ if (own !== undefined)
1388
+ return own;
1389
+ const lane = LANE.exec(holder)?.[1];
1390
+ return lane === undefined ? undefined : sessions.find((session) => session.name.endsWith(`-${lane}`));
1060
1391
  }
1061
1392
 
1062
1393
  // src/tea.ts
1394
+ var TEA_MS = 60000;
1063
1395
  function repoFlag(repo) {
1064
1396
  return repo === undefined ? [] : ["--repo", repo];
1065
1397
  }
1066
1398
  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();
1399
+ const result = await runTool(`tea api ${path}`, ["tea", "api", ...repoFlag(repo), path], { timeoutMs: TEA_MS });
1400
+ if (result.code !== 0)
1401
+ throw new Error(`tea api ${path}: ${result.stderr.trim() || `exit ${result.code}`}`);
1402
+ return result.stdout;
1076
1403
  }
1077
1404
  function parseJson(path, text) {
1078
1405
  let value;
@@ -1082,10 +1409,16 @@ function parseJson(path, text) {
1082
1409
  throw new Error(`tea api ${path}: ${upTo(text.trim(), `
1083
1410
  `) || "empty answer"}`);
1084
1411
  }
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}`);
1412
+ const failure = apiError(value);
1413
+ if (failure !== undefined)
1414
+ throw new Error(`tea api ${path}: ${failure}`);
1087
1415
  return value;
1088
1416
  }
1417
+ function apiError(value) {
1418
+ if (value !== null && typeof value === "object" && !Array.isArray(value) && "message" in value && typeof value.message === "string" && "url" in value)
1419
+ return value.message;
1420
+ return;
1421
+ }
1089
1422
  async function api(path, repo) {
1090
1423
  return parseJson(path, await request(path, repo));
1091
1424
  }
@@ -1095,29 +1428,30 @@ async function repoApi(path, repo) {
1095
1428
  async function repoWrite(method, path, fields, repo) {
1096
1429
  const full = `repos/{owner}/{repo}/${path}`;
1097
1430
  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());
1431
+ const result = await runTool(`tea api ${full}`, ["tea", "api", ...repoFlag(repo), "-X", method, ...flags, full], { timeoutMs: TEA_MS });
1432
+ if (result.code !== 0)
1433
+ throw new Error(`tea api ${full}: ${result.stderr.trim() || `exit ${result.code}`}`);
1434
+ return parseJson(full, result.stdout);
1107
1435
  }
1108
1436
  async function repoApiText(path, repo) {
1109
- return request(`repos/{owner}/{repo}/${path}`, repo);
1110
- }
1111
- async function tea(args, repo) {
1112
- let result;
1437
+ const full = `repos/{owner}/{repo}/${path}`;
1438
+ const text = await request(full, repo);
1439
+ let value;
1113
1440
  try {
1114
- result = await $2`tea ${args} ${repoFlag(repo)}`.quiet().nothrow();
1115
- } catch (error) {
1116
- throw spawnFailure(`tea ${args[0] ?? ""}`, error);
1441
+ value = JSON.parse(text);
1442
+ } catch {
1443
+ return text;
1117
1444
  }
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();
1445
+ const failure = apiError(value);
1446
+ if (failure !== undefined)
1447
+ throw new Error(`tea api ${full}: ${failure}`);
1448
+ return text;
1449
+ }
1450
+ async function tea(command, args, repo) {
1451
+ const result = await runTool(`tea ${command.join(" ")}`, ["tea", ...command, ...repoFlag(repo), ...args], { timeoutMs: TEA_MS });
1452
+ if (result.code !== 0)
1453
+ throw new Error(`tea ${command.join(" ")}: ${result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`}`);
1454
+ return result.stdout;
1121
1455
  }
1122
1456
 
1123
1457
  // src/issues.ts
@@ -1155,46 +1489,75 @@ async function commentsOfAll(issues, repo) {
1155
1489
  return result;
1156
1490
  }
1157
1491
  async function editIssue(number, change, repo) {
1158
- const args = ["issues", "edit", String(number)];
1492
+ const args = [String(number)];
1159
1493
  if (change.title !== undefined)
1160
1494
  args.push("-t", change.title);
1161
1495
  if (change.body !== undefined)
1162
1496
  args.push("-d", change.body);
1163
1497
  if (change.milestone !== undefined)
1164
1498
  args.push("-m", change.milestone);
1165
- await tea(args, repo);
1499
+ await tea(["issues", "edit"], args, repo);
1166
1500
  }
1167
1501
  async function setBody(number, body, repo) {
1168
1502
  await editIssue(number, { body }, repo);
1169
1503
  }
1170
1504
  async function createIssue(title, body, labels, milestone, repo) {
1171
- const args = ["issues", "create", "-t", title, "-d", body];
1505
+ const args = ["-t", title, "-d", body];
1506
+ const same = (found) => found.title === title && sameText(found.body, body);
1507
+ const known = new Set((await openIssues(undefined, repo)).filter(same).map((found) => found.number));
1172
1508
  if (labels !== undefined)
1173
1509
  args.push("-L", labels);
1174
1510
  if (milestone !== undefined)
1175
1511
  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)
1512
+ await tea(["issues", "create"], args, repo);
1513
+ const created = (await openIssues(undefined, repo)).filter((found) => same(found) && !known.has(found.number));
1514
+ if (created.length > 1)
1515
+ throw new Error(`created "${title}" but ${created.map((found) => `#${found.number}`).join(" and ")} both match: another session created the same issue at once`);
1516
+ if (created[0] === undefined)
1179
1517
  throw new Error(`created "${title}" but cannot find it among the open issues`);
1180
- return created;
1518
+ return created[0];
1519
+ }
1520
+ function sameText(a, b) {
1521
+ return a.replaceAll(`\r
1522
+ `, `
1523
+ `).trimEnd() === b.replaceAll(`\r
1524
+ `, `
1525
+ `).trimEnd();
1181
1526
  }
1527
+ var EDIT_TRIES = 3;
1182
1528
  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;
1529
+ let wrote = false;
1530
+ for (let attempt = 0;attempt < EDIT_TRIES; attempt++) {
1531
+ const { body } = await getIssue(number, repo);
1532
+ const next = change(body);
1533
+ if (next === body)
1534
+ return wrote;
1535
+ await setBody(number, next, repo);
1536
+ wrote = true;
1537
+ if (sameText((await getIssue(number, repo)).body, next))
1538
+ return true;
1539
+ }
1540
+ throw new Error(`#${number}: its description keeps changing under the edit, so it was not written reliably`);
1541
+ }
1542
+ var login;
1543
+ async function sessionAuthor() {
1544
+ if (login !== undefined)
1545
+ return login;
1546
+ try {
1547
+ login = (await api("user")).login;
1548
+ } catch {
1549
+ return;
1550
+ }
1551
+ return login;
1189
1552
  }
1190
1553
  async function comment(number, body, repo) {
1191
- await tea(["comment", String(number), body], repo);
1554
+ await repoWrite("POST", `issues/${number}/comments`, { body }, repo);
1192
1555
  }
1193
1556
  async function close(number, repo) {
1194
- await tea(["issues", "close", String(number)], repo);
1557
+ await tea(["issues", "close"], [String(number)], repo);
1195
1558
  }
1196
1559
  async function reopen(number, repo) {
1197
- await tea(["issues", "reopen", String(number)], repo);
1560
+ await tea(["issues", "reopen"], [String(number)], repo);
1198
1561
  }
1199
1562
  async function repoLabels(repo) {
1200
1563
  const names = [];
@@ -1206,12 +1569,12 @@ async function repoLabels(repo) {
1206
1569
  }
1207
1570
  }
1208
1571
  async function editLabels(number, add, remove, repo) {
1209
- const args = ["issues", "edit", String(number)];
1572
+ const args = [String(number)];
1210
1573
  if (add.length > 0)
1211
1574
  args.push("--add-labels", add.join(","));
1212
1575
  if (remove.length > 0)
1213
1576
  args.push("--remove-labels", remove.join(","));
1214
- await tea(args, repo);
1577
+ await tea(["issues", "edit"], args, repo);
1215
1578
  }
1216
1579
 
1217
1580
  // src/milestones.ts
@@ -1265,15 +1628,15 @@ async function closeMilestone(id, repo) {
1265
1628
  // src/sessions.ts
1266
1629
  import { homedir } from "os";
1267
1630
  import { join } from "path";
1268
- import process9 from "process";
1631
+ import process11 from "process";
1269
1632
  var {Glob } = globalThis.Bun;
1270
1633
  var SESSION_FILES = new Glob("*.json");
1271
1634
  var ACCOUNT_DIRS = new Glob("*/sessions");
1272
1635
  async function sessionDirs() {
1273
1636
  const home = homedir();
1274
1637
  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"));
1638
+ if (process11.env.CLAUDE_CONFIG_DIR !== undefined)
1639
+ dirs.add(join(process11.env.CLAUDE_CONFIG_DIR, "sessions"));
1277
1640
  try {
1278
1641
  for await (const dir of ACCOUNT_DIRS.scan({ cwd: join(home, ".claude-accounts"), onlyFiles: false, absolute: true }))
1279
1642
  dirs.add(dir);
@@ -1319,7 +1682,7 @@ async function liveSessions() {
1319
1682
  }
1320
1683
  async function currentSession(sessions) {
1321
1684
  const byPid = new Map(sessions.map((session) => [session.pid, session]));
1322
- let pid = process9.ppid;
1685
+ let pid = process11.ppid;
1323
1686
  for (let depth = 0;depth < 32 && pid > 1; depth++) {
1324
1687
  const session = byPid.get(pid);
1325
1688
  if (session !== undefined)
@@ -1381,9 +1744,10 @@ ${lines.join(`
1381
1744
  }
1382
1745
  async function boardRows(issues, sessions, repo) {
1383
1746
  const comments = await commentsOfAll(issues, repo);
1747
+ const author = await sessionAuthor();
1384
1748
  return issues.map((issue) => {
1385
1749
  const own = comments.get(issue.number) ?? [];
1386
- return { issue, standing: standing(latestClaim(own), sessions), comments: own };
1750
+ return { issue, standing: standing(latestClaim(own, author), sessions), comments: own };
1387
1751
  });
1388
1752
  }
1389
1753
  function boardJson(rows) {
@@ -1499,12 +1863,13 @@ async function take(args) {
1499
1863
  const numbers = issueNumbers(positionals, takeUsage);
1500
1864
  const sessions = await liveSessions();
1501
1865
  const nameFor = await claimant(values, sessions);
1866
+ const author = await sessionAuthor();
1502
1867
  return eachIssue("take", numbers, async (number) => {
1503
1868
  const name = nameFor(number);
1504
1869
  const [found, comments] = await Promise.all([getIssue(number, values.repo), commentsOf(number, values.repo)]);
1505
1870
  if (found.state === "closed")
1506
1871
  fail(`#${number} is closed`, 1, runHint(`ft reopen ${number}`, "reopen it"));
1507
- const claim = latestClaim(comments);
1872
+ const claim = latestClaim(comments, author);
1508
1873
  const current = standing(claim, sessions);
1509
1874
  if (claim?.holder === name) {
1510
1875
  console.log(`#${number} is already taken by ${name}`);
@@ -1515,6 +1880,9 @@ async function take(args) {
1515
1880
  if (current.state === "away" && values["take-stale"] !== true)
1516
1881
  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
1882
  await comment(number, takenComment(name), values.repo);
1883
+ const standingNow = latestClaim(await commentsOf(number, values.repo), author);
1884
+ if (standingNow?.holder !== name)
1885
+ fail(`#${number} was taken by ${standingNow?.holder ?? "another session"} at the same moment`, 1);
1518
1886
  console.log(`took #${number} as ${name}: ${found.title}`);
1519
1887
  });
1520
1888
  }
@@ -1533,9 +1901,10 @@ async function drop(args) {
1533
1901
  const { values, positionals } = parsed;
1534
1902
  const numbers = issueNumbers(positionals, dropUsage);
1535
1903
  const nameFor = await claimant(values, await liveSessions());
1904
+ const author = await sessionAuthor();
1536
1905
  return eachIssue("drop", numbers, async (number) => {
1537
1906
  const name = nameFor(number);
1538
- const claim = latestClaim(await commentsOf(number, values.repo));
1907
+ const claim = latestClaim(await commentsOf(number, values.repo), author);
1539
1908
  if (claim?.holder !== name)
1540
1909
  fail(`#${number} is ${claim === undefined ? "not taken" : `taken by ${claim.holder}, not ${name}`}`, 1);
1541
1910
  await comment(number, releasedComment(name), values.repo);
@@ -1544,114 +1913,20 @@ async function drop(args) {
1544
1913
  }
1545
1914
 
1546
1915
  // src/commands/ci.ts
1547
- import process11 from "process";
1916
+ import process12 from "process";
1548
1917
 
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 : ""}`;
1918
+ // src/git.ts
1919
+ async function tagsAt(commit) {
1920
+ return (await git(["tag", "--points-at", commit])).split(`
1921
+ `).filter(Boolean);
1637
1922
  }
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 };
1923
+ async function commitsNaming(issue, ref) {
1924
+ const out = await git(["log", ref, "--reverse", "--extended-regexp", `--grep=#${issue}([^0-9]|$)`, "--format=%H %s"]);
1925
+ return out.split(`
1926
+ `).filter(Boolean).map((line) => {
1927
+ const space = line.indexOf(" ");
1928
+ return { sha: line.slice(0, space), subject: line.slice(space + 1) };
1648
1929
  });
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
1930
  }
1656
1931
 
1657
1932
  // src/log.ts
@@ -1728,7 +2003,13 @@ async function recentRuns(limit, repo, workflow) {
1728
2003
  return (await runsListing(limit, repo, workflow)).runs;
1729
2004
  }
1730
2005
  async function runsOn(sha, repo) {
1731
- return (await repoApi(`actions/runs?head_sha=${sha}`, repo)).workflow_runs ?? [];
2006
+ const runs = [];
2007
+ for (let page = 1;; page++) {
2008
+ const list = (await repoApi(`actions/runs?head_sha=${sha}&page=${page}&limit=${RUNS_PAGE}`, repo)).workflow_runs ?? [];
2009
+ runs.push(...list);
2010
+ if (list.length < RUNS_PAGE)
2011
+ return runs;
2012
+ }
1732
2013
  }
1733
2014
  async function runById(id, repo) {
1734
2015
  return repoApi(`actions/runs/${id}`, repo);
@@ -1814,9 +2095,9 @@ var options = {
1814
2095
  job: { type: "string", short: "j", description: "a job whose skipping means the run left its work to a newer push" },
1815
2096
  timeout: { type: "string", short: "t", description: "how long before giving up (30m), a bare number in minutes" }
1816
2097
  };
1817
- var POLL = 15000;
1818
2098
  var NO_RUN = 3 * 60000;
1819
2099
  var STALLED = 3 * 60000;
2100
+ var MAX_LOOKUPS = 20;
1820
2101
  function judge(runs, jobSkipped, tagged) {
1821
2102
  if (runs.length === 0)
1822
2103
  return { kind: "none" };
@@ -1828,7 +2109,13 @@ function judge(runs, jobSkipped, tagged) {
1828
2109
  return { kind: "runs", runs };
1829
2110
  return tagged ? { kind: "runs", runs: runs.map((run) => ({ ...run, status: "skipped" })) } : { kind: "passed-over" };
1830
2111
  }
1831
- async function ci(args) {
2112
+ function commitsToAsk(commits, passedOver, max = MAX_LOOKUPS) {
2113
+ return commits.filter((commit) => !passedOver.has(commit)).slice(0, max);
2114
+ }
2115
+ function settled(latest) {
2116
+ return latest !== undefined && latest.every((run) => FINISHED.has(run.status)) && judge(latest, false, false).kind === "runs" ? latest : undefined;
2117
+ }
2118
+ async function ci2(args) {
1832
2119
  const parsed = parse(args, usage, options);
1833
2120
  const { values, positionals } = parsed;
1834
2121
  const branch = values.branch ?? await defaultBranch();
@@ -1852,71 +2139,44 @@ async function ci(args) {
1852
2139
  return coverage;
1853
2140
  }
1854
2141
  async function covering() {
1855
- await git(["fetch", "-q", "origin", branch]);
2142
+ await fetchBranch(branch);
1856
2143
  if (!await gitOk(["merge-base", "--is-ancestor", sha, `origin/${branch}`]))
1857
2144
  fail(`${short} is not on origin/${branch}`, 2, runHint(`git push origin ${branch}`));
1858
2145
  const later = (await git(["rev-list", "--reverse", "--first-parent", `${sha}..origin/${branch}`])).split(`
1859
2146
  `).filter(Boolean);
1860
- for (const commit of [sha, ...later]) {
2147
+ for (const commit of commitsToAsk([sha, ...later], passedOver)) {
1861
2148
  const coverage = await coverageOf(commit);
1862
2149
  if (coverage.kind === "runs")
1863
2150
  return coverage.runs;
1864
2151
  }
1865
2152
  return;
1866
2153
  }
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
- }
2154
+ const { dim } = paint(process12.stderr);
2155
+ const runs = await waitForCi({
2156
+ tool: "ft ci",
2157
+ short,
2158
+ giveUpMs: giveUp,
2159
+ noneMs: NO_RUN,
2160
+ stalledMs: STALLED,
2161
+ noneMessage: `no run covers ${short} yet: its own was cancelled by a later push or never started, so the next push's will`,
2162
+ covering,
2163
+ finished: (found) => found.every((run) => FINISHED.has(run.status)),
2164
+ key: (found) => found.map((run) => run.id).join(","),
2165
+ announce: (found) => {
2166
+ for (const run of found)
2167
+ 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}`);
2168
+ },
2169
+ links: (found) => found.map((run) => run.html_url).join(" "),
2170
+ settle: async (found) => settled(await refreshed(found)),
2171
+ stall: async (found) => {
2172
+ const stall = await stallReason(found);
2173
+ return stall === undefined ? undefined : { message: `runs queued on ${short} are not moving: ${stall.reason}`, certain: stall.certain };
2174
+ },
2175
+ lines: (spinner, found, started) => [
2176
+ `${spinner} CI on ${short} ${dim(formatDuration(Date.now() - started))}`,
2177
+ ...(found ?? []).map((run) => ` ${badge(run.status, process12.stderr)} ${run.workflow_id} ${dim(`run ${run.id}`)}`)
2178
+ ]
2179
+ });
1920
2180
  const status = overall(runs);
1921
2181
  if (status !== "failure") {
1922
2182
  console.log(`CI ${status === "success" ? "passed" : "skipped its tests, a release tag's workflow testing them"} on ${short}`);
@@ -1933,7 +2193,7 @@ run ${run.id} failed${run.commit_sha === sha ? "" : `, testing every commit from
1933
2193
  }
1934
2194
 
1935
2195
  // src/commands/digest.ts
1936
- import process12 from "process";
2196
+ import process13 from "process";
1937
2197
  var RUNS_SCANNED = 50;
1938
2198
  var TITLE_WIDTH2 = 70;
1939
2199
  var SHOWN = 8;
@@ -1983,7 +2243,7 @@ async function quietly(read) {
1983
2243
  }
1984
2244
  }
1985
2245
  async function staleTodos() {
1986
- const bin = Bun.which("todo-scan", { PATH: process12.env.PATH ?? "" });
2246
+ const bin = Bun.which("todo-scan", { PATH: process13.env.PATH ?? "" });
1987
2247
  if (bin === null)
1988
2248
  return [];
1989
2249
  let found;
@@ -2044,7 +2304,8 @@ async function digest(args) {
2044
2304
  const [issues, sessions, branch, runs] = await Promise.all([openIssues(undefined, values.repo), liveSessions(), defaultBranch(), recentRuns(RUNS_SCANNED, values.repo)]);
2045
2305
  const comments = await commentsOfAll(issues, values.repo);
2046
2306
  const me = await currentSession(sessions);
2047
- const rows = issues.map((issue) => ({ issue, claim: latestClaim(comments.get(issue.number) ?? []) }));
2307
+ const author = await sessionAuthor();
2308
+ const rows = issues.map((issue) => ({ issue, claim: latestClaim(comments.get(issue.number) ?? [], author) }));
2048
2309
  const [unpushed, dirty, stale] = await Promise.all([quietly(unpushedCommits), quietly(dirtyTree), staleTodos()]);
2049
2310
  const summary = { ...claims(rows, sessions, me), ci: unsettled(runs, branch), unpushed, dirty, stale };
2050
2311
  if (values.json === true)
@@ -2161,7 +2422,7 @@ var issueOptions = {
2161
2422
  ...repoOption
2162
2423
  };
2163
2424
  function renderIssue(found, comments, view, now = Date.now()) {
2164
- const claim = latestClaim(comments);
2425
+ const claim = latestClaim(comments, view.author);
2165
2426
  const list = boxes(found.body);
2166
2427
  const left = list.filter((box) => !box.done);
2167
2428
  const facts = [found.state];
@@ -2189,14 +2450,14 @@ ${each.body.replaceAll(`\r
2189
2450
 
2190
2451
  `);
2191
2452
  }
2192
- function issueJson(found, comments) {
2453
+ function issueJson(found, comments, author) {
2193
2454
  return {
2194
2455
  number: found.number,
2195
2456
  title: found.title,
2196
2457
  state: found.state,
2197
2458
  url: found.html_url,
2198
2459
  labels: found.labels.map((label) => label.name),
2199
- claim: latestClaim(comments) ?? null,
2460
+ claim: latestClaim(comments, author) ?? null,
2200
2461
  boxes: boxes(found.body).map(({ number, done, text }) => ({ number, done, text })),
2201
2462
  body: found.body,
2202
2463
  comments: comments.map((each) => ({ author: each.user.login, at: each.created_at, body: each.body }))
@@ -2216,12 +2477,13 @@ async function issue(args) {
2216
2477
  return 0;
2217
2478
  }
2218
2479
  const read = await Promise.all(numbers.map(async (number) => Promise.all([getIssue(number, values.repo), commentsOf(number, values.repo)])));
2480
+ const author = await sessionAuthor();
2219
2481
  if (values.json === true) {
2220
- const json = read.map(([found, comments]) => issueJson(found, comments));
2482
+ const json = read.map(([found, comments]) => issueJson(found, comments, author));
2221
2483
  printJson(json.length === 1 ? json[0] : json);
2222
2484
  return 0;
2223
2485
  }
2224
- const view = { todo: values.todo === true, comments: values.comments === true };
2486
+ const view = { todo: values.todo === true, comments: values.comments === true, author };
2225
2487
  console.log(read.map(([found, comments]) => renderIssue(found, comments, view)).join(view.todo ? `
2226
2488
 
2227
2489
  ` : `
@@ -2240,11 +2502,14 @@ async function noteText(values) {
2240
2502
  const text = values.file === undefined ? values.message : await readInput(values.file);
2241
2503
  return text?.trim() === "" ? undefined : text?.trim();
2242
2504
  }
2505
+ var NOT_FOUND = /not exist|not found|couldn't be found/i;
2243
2506
  async function isClosed(number, repo) {
2244
2507
  try {
2245
2508
  return (await getIssue(number, repo)).state === "closed";
2246
- } catch {
2247
- return false;
2509
+ } catch (error) {
2510
+ if (error instanceof Error && NOT_FOUND.test(error.message))
2511
+ return false;
2512
+ throw error;
2248
2513
  }
2249
2514
  }
2250
2515
  async function closedReferences(body, repo) {
@@ -2317,17 +2582,21 @@ ${editUsage}`);
2317
2582
  const milestone = await milestoneTitle(values.milestone, values.repo);
2318
2583
  if (milestone !== undefined)
2319
2584
  change.milestone = milestone;
2320
- if (text !== undefined && values.append !== true)
2585
+ const grows = text !== undefined && values.append === true || added.length > 0;
2586
+ if (text !== undefined && !grows)
2321
2587
  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);
2588
+ if (Object.keys(change).length > 0)
2589
+ await editIssue(number, change, values.repo);
2590
+ if (grows) {
2591
+ await editBody(number, (body) => {
2592
+ const appended = text !== undefined && values.append === true && !body.trimEnd().endsWith(text) ? `${body.trimEnd()}
2593
+
2594
+ ${text}` : body;
2595
+ const present = new Set(boxes(appended).map((box) => box.text));
2596
+ const missing = added.filter((box) => !present.has(box));
2597
+ return missing.length > 0 ? appendBoxes(appended, missing) : appended;
2598
+ }, values.repo);
2599
+ }
2331
2600
  console.log(`edited #${number}`);
2332
2601
  return 0;
2333
2602
  }
@@ -2343,6 +2612,13 @@ var tickOptions = {
2343
2612
  ...noteOptions,
2344
2613
  ...repoOption
2345
2614
  };
2615
+ function tickedOrFail(number, run) {
2616
+ try {
2617
+ return run();
2618
+ } catch (error) {
2619
+ fail(`#${number}: ${error instanceof Error ? error.message : String(error)}`);
2620
+ }
2621
+ }
2346
2622
  async function tick(args) {
2347
2623
  const parsed = parse(args, tickUsage, tickOptions);
2348
2624
  const { values, positionals } = parsed;
@@ -2360,17 +2636,13 @@ ${tickUsage}`);
2360
2636
  const found = await getIssue(number, values.repo);
2361
2637
  const done = values.undo !== true;
2362
2638
  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
- }
2639
+ const apply = (body) => closed === undefined ? setBoxes(body, picks, done) : tickReferences(body, (issue) => closed.has(issue));
2640
+ let result = tickedOrFail(number, () => apply(found.body));
2369
2641
  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);
2642
+ await editBody(number, (body) => {
2643
+ result = tickedOrFail(number, () => apply(body));
2644
+ return result.body;
2645
+ }, values.repo);
2374
2646
  }
2375
2647
  for (const box of result.changed)
2376
2648
  console.log(`${done ? "ticked" : "unticked"} ${box.number}: ${box.text}`);
@@ -2461,7 +2733,7 @@ async function fixed(args) {
2461
2733
  fail(`#${number} is already closed`, 1, runHint(`ft reopen ${number}`, "reopen it"));
2462
2734
  const left = boxes(found.body).filter((box) => !box.done);
2463
2735
  if (left.length > 0 && values.tick === true) {
2464
- await setBody(number, setBoxes(found.body, left.map((box) => String(box.number)), true).body, values.repo);
2736
+ await editBody(number, (body) => setBoxes(body, boxes(body).filter((box) => !box.done).map((box) => String(box.number)), true).body, values.repo);
2465
2737
  console.log(`ticked ${left.length} box${left.length === 1 ? "" : "es"} of #${number}`);
2466
2738
  } else if (left.length > 0 && values.force !== true) {
2467
2739
  fail(`#${number} has ${left.length} boxes left:
@@ -2469,7 +2741,9 @@ ${left.map((box) => `${String(box.number).padStart(3)} [ ] ${box.text}`).join(`
2469
2741
  `)}`, 1, `${runHint(`ft fixed ${number} --tick`, "tick them and close")}, or ${runHint(`ft fixed ${number} --force`, "close with them left")}`);
2470
2742
  }
2471
2743
  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);
2744
+ const line = `Fixed in ${shas}: ${values.message ?? commits[0]?.subject ?? ""}`;
2745
+ if ((await commentsOf(number, values.repo)).at(-1)?.body.trim() !== line.trim())
2746
+ await comment(number, line, values.repo);
2473
2747
  await close(number, values.repo);
2474
2748
  console.log(`closed #${number}: fixed in ${shas}`);
2475
2749
  await tickListings(number, values.repo);
@@ -2484,22 +2758,22 @@ With no failed job it prints every job's last lines; --full prints whole logs.
2484
2758
 
2485
2759
  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
2760
  var options3 = {
2487
- job: { type: "string", short: "j", description: "only this job, by name" },
2761
+ job: { type: "string", short: "j", description: "only this job, by name or id" },
2488
2762
  full: { type: "boolean", short: "f", description: "the whole log, timestamps stripped" },
2489
2763
  ...repoOption
2490
2764
  };
2491
- function pickJobs(jobs, name) {
2492
- if (name !== undefined)
2493
- return jobs.filter((job) => job.name === name);
2765
+ function pickJobs(jobs, wanted) {
2766
+ if (wanted !== undefined)
2767
+ return jobs.filter((job) => job.name === wanted || String(job.id) === wanted);
2494
2768
  const failed = jobs.filter((job) => job.status === "failure");
2495
2769
  return failed.length > 0 ? failed : jobs;
2496
2770
  }
2497
- async function logs(args) {
2771
+ async function logs2(args) {
2498
2772
  const parsed = parse(args, usage3, options3);
2499
2773
  const { values, positionals } = parsed;
2500
2774
  if (positionals[0] === undefined)
2501
2775
  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);
2776
+ const run = await runById(wholeNumber(positionals[0], "<run id>", 0), values.repo);
2503
2777
  const jobs = await jobsOf(run.id, values.repo);
2504
2778
  const picked = pickJobs(jobs, values.job);
2505
2779
  if (picked.length === 0)
@@ -2776,8 +3050,8 @@ function report(plan, added, skipped) {
2776
3050
 
2777
3051
  // src/commands/release.ts
2778
3052
  import { basename as basename2, join as join2, relative, resolve } from "path";
2779
- import process13 from "process";
2780
- var {$: $3 } = globalThis.Bun;
3053
+ import process14 from "process";
3054
+ var {$ } = globalThis.Bun;
2781
3055
 
2782
3056
  // src/commands/watch.ts
2783
3057
  var usage4 = `ft watch [--after <id>]
@@ -2795,18 +3069,15 @@ var STALLED2 = 3 * 60000;
2795
3069
  async function watchRuns(pick, timeout, repo, idle) {
2796
3070
  const started = Date.now();
2797
3071
  const reported = new Map;
3072
+ const followed = new Set;
3073
+ const failed = new Set;
2798
3074
  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
- }
3075
+ async function poll() {
3076
+ const newest = await recentRuns(LIMIT, repo);
3077
+ const gone = await Promise.all([...followed].filter((id) => !newest.some((run) => run.id === id)).map(async (id) => runById(id, repo)));
3078
+ const runs = [...pick(newest), ...gone];
3079
+ for (const run of runs)
3080
+ followed.add(run.id);
2810
3081
  for (const run of runs) {
2811
3082
  if (reported.get(run.id) !== run.status)
2812
3083
  console.log(`${run.workflow_id} run ${run.id} on ${run.prettyref}: ${badge(run.status, process.stdout)}`);
@@ -2817,26 +3088,44 @@ async function watchRuns(pick, timeout, repo, idle) {
2817
3088
  console.log("no run started, so nothing was waited for");
2818
3089
  return 0;
2819
3090
  }
2820
- continue;
3091
+ return;
2821
3092
  }
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(`
3093
+ for (const run of runs) {
3094
+ if (run.status === "failure" && !failed.has(run.id)) {
3095
+ console.error(`
2827
3096
  ${await failureReport(run, repo)}
2828
3097
 
2829
3098
  ${run.html_url}`);
3099
+ failed.add(run.id);
2830
3100
  }
2831
- return 1;
2832
3101
  }
2833
- if (runs.every((run) => FINISHED.has(run.status)))
2834
- return 0;
3102
+ if (runs.every((run) => FINISHED.has(run.status))) {
3103
+ const status = overall(runs);
3104
+ return status === "failure" || status === "cancelled" ? 1 : 0;
3105
+ }
2835
3106
  const stall = await stallReason(runs, repo);
2836
3107
  if (stall === undefined)
2837
3108
  moving = Date.now();
2838
3109
  else if (stall.certain || Date.now() - moving > STALLED2)
2839
3110
  fail(`queued runs are not moving: ${stall.reason}`);
3111
+ return;
3112
+ }
3113
+ for (;; ) {
3114
+ await Bun.sleep(timing.poll);
3115
+ if (Date.now() - started > timeout)
3116
+ fail(`the runs did not finish in ${formatDuration(timeout)}`);
3117
+ try {
3118
+ const code = await poll();
3119
+ if (code !== undefined)
3120
+ return code;
3121
+ } catch (error) {
3122
+ if (error instanceof Exit)
3123
+ throw error;
3124
+ const message = error instanceof Error ? error.message : String(error);
3125
+ if (isPermanentFailure(error))
3126
+ fail(message);
3127
+ console.warn(`ft watch: ${message}, polling again`);
3128
+ }
2840
3129
  }
2841
3130
  }
2842
3131
  async function watch(args) {
@@ -2862,7 +3151,7 @@ ft release --tagged
2862
3151
  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
3152
  --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
3153
  --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.
3154
+ 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
3155
  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
3156
  --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
3157
  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 +3168,8 @@ var PARTS = /^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/;
2879
3168
  var LAST_NUMBER = /\d+$/;
2880
3169
  var REGISTRY_TRIES = 12;
2881
3170
  var REGISTRY_POLL = 5000;
3171
+ var limits = { pushMs: 5 * 60000 };
3172
+ var REGISTRY_INFO_MS = 30000;
2882
3173
  var NO_RUN2 = 3 * 60000;
2883
3174
  function taggedVersion(tag, prefix) {
2884
3175
  const version = tag.slice(prefix.length);
@@ -2921,7 +3212,7 @@ async function commitPackageBump(dir, prefix) {
2921
3212
  const { version } = await Bun.file(join2(dir, "package.json")).json();
2922
3213
  const root = await git(["rev-parse", "--show-toplevel"]);
2923
3214
  const tag = `${prefix}${version}`;
2924
- const lock = await $3`bun install --lockfile-only`.cwd(root).quiet().nothrow();
3215
+ const lock = await $`bun install --lockfile-only`.cwd(root).quiet().nothrow();
2925
3216
  if (lock.exitCode !== 0)
2926
3217
  fail(`bun install --lockfile-only after the bump: ${lock.stderr.toString().trim()}`);
2927
3218
  await git(["add", join2(dir, "package.json"), join2(root, "bun.lock")]);
@@ -2930,8 +3221,14 @@ async function commitPackageBump(dir, prefix) {
2930
3221
  }
2931
3222
  async function waitForRegistry(name, version) {
2932
3223
  for (let attempt = 0;attempt < REGISTRY_TRIES; attempt++) {
2933
- const served = (await $3`bun info ${name} version --no-cache`.quiet().nothrow().text()).trim().split(`
3224
+ let served;
3225
+ try {
3226
+ served = (await runTool("bun info", ["bun", "info", name, "version", "--no-cache"], { timeoutMs: REGISTRY_INFO_MS })).stdout.trim().split(`
2934
3227
  `).at(-1);
3228
+ } catch (error) {
3229
+ if (isPermanentFailure(error))
3230
+ throw error;
3231
+ }
2935
3232
  if (served === version)
2936
3233
  return true;
2937
3234
  await Bun.sleep(REGISTRY_POLL);
@@ -2940,10 +3237,18 @@ async function waitForRegistry(name, version) {
2940
3237
  }
2941
3238
  async function testsPass(sha, branch, timeout) {
2942
3239
  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)
3240
+ const held = holdSignals();
3241
+ let push;
3242
+ try {
3243
+ push = await runTool("git push", ["git", "push", "origin", `${sha}:refs/heads/${branch}`], { timeoutMs: limits.pushMs, onStart: (child) => held.forward(child) });
3244
+ } finally {
3245
+ held.release();
3246
+ }
3247
+ if (held.signal !== undefined)
3248
+ fail(`interrupted by ${held.signal} while pushing ${sha.slice(0, 7)}, so nothing was bumped`, exitCodeOf(null, held.signal));
3249
+ if (push.code !== 0)
2945
3250
  fail(`origin refused the push of ${sha.slice(0, 7)}, so nothing was bumped:
2946
- ${push.stderr.toString().trim()}`, 1);
3251
+ ${push.stderr.trim()}`, 1);
2947
3252
  console.log(`pushed ${branch} at ${sha.slice(0, 7)}, waiting for its runs before the bump`);
2948
3253
  }
2949
3254
  return await watchRuns((runs) => newestPerWorkflow(runs.filter((run) => run.commit_sha === sha)), timeout, undefined, NO_RUN2) === 0;
@@ -2951,8 +3256,8 @@ ${push.stderr.toString().trim()}`, 1);
2951
3256
  async function printPlan({ level, dir, prefix, branch, base, timeout, registry }) {
2952
3257
  const file = Bun.file(join2(dir ?? ".", "package.json"));
2953
3258
  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) || "."}`;
3259
+ const name = pkg?.name ?? basename2(dir ?? process14.cwd());
3260
+ const where = dir === undefined ? "" : ` in ${relative(process14.cwd(), dir) || "."}`;
2956
3261
  const sha = base.slice(0, 7);
2957
3262
  let version;
2958
3263
  let bump;
@@ -2983,6 +3288,17 @@ async function printPlan({ level, dir, prefix, branch, base, timeout, registry }
2983
3288
  console.log("dry run: nothing was pushed, committed or tagged");
2984
3289
  return 0;
2985
3290
  }
3291
+ async function rollBack(preBump, tagsBefore) {
3292
+ try {
3293
+ for (const name of await tagsAt("HEAD")) {
3294
+ if (!tagsBefore.has(name))
3295
+ await git(["tag", "-d", name]);
3296
+ }
3297
+ await git(["reset", "--keep", preBump]);
3298
+ } catch (error) {
3299
+ warn(`could not undo the bump, back to ${preBump.slice(0, 7)} by hand`, error instanceof Error ? error.message : String(error));
3300
+ }
3301
+ }
2986
3302
  async function release(args) {
2987
3303
  const parsed = parse(args, usage5, options6);
2988
3304
  const { values, positionals } = parsed;
@@ -3003,34 +3319,61 @@ ${usage5}`);
3003
3319
  fail(`a release is cut from ${branch}, not ${current || "a detached HEAD"}`, 2, runHint(`git switch ${branch}`));
3004
3320
  if (await git(["status", "--porcelain"]) !== "")
3005
3321
  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)
3322
+ await fetchBranch(branch);
3323
+ if ((await $`git merge-base --is-ancestor ${`origin/${branch}`} HEAD`.quiet().nothrow()).exitCode !== 0)
3008
3324
  fail(`origin/${branch} has commits HEAD lacks`, 2, runHint(`git pull --rebase origin ${branch}`));
3009
3325
  const base = await git(["rev-parse", tagged ? "HEAD~1" : "HEAD"]);
3010
3326
  if (values["dry-run"] === true)
3011
3327
  return printPlan({ level, dir, prefix, branch, base, timeout, registry: values["no-registry"] !== true });
3012
3328
  if (!await testsPass(base, branch, timeout))
3013
3329
  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`);
3330
+ const preBump = await git(["rev-parse", "HEAD"]);
3331
+ const tagsBefore = new Set(await tagsAt("HEAD"));
3024
3332
  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);
3333
+ const held = holdSignals();
3334
+ const signalOf = () => held.signal;
3335
+ let tag = "";
3336
+ let head = "";
3337
+ let pushed = false;
3338
+ let isPushUnknown = false;
3339
+ try {
3340
+ if (level !== undefined) {
3341
+ const bump = dir === undefined ? await $`bun pm version ${level}`.quiet().nothrow() : await $`bun pm version ${level} --no-git-tag-version`.cwd(dir).quiet().nothrow();
3342
+ if (bump.exitCode !== 0)
3343
+ fail(`bun pm version ${level}: ${bump.stderr.toString().trim() || bump.stdout.toString().trim()}`);
3344
+ if (dir !== undefined)
3345
+ await commitPackageBump(dir, prefix);
3346
+ }
3347
+ const found = (await tagsAt("HEAD")).find((name) => taggedVersion(name, prefix) !== undefined);
3348
+ if (found === undefined)
3349
+ fail(tagged ? `HEAD carries no ${prefix}* tag` : `the bump made no ${prefix}* tag`);
3350
+ tag = found;
3351
+ head = await git(["rev-parse", "HEAD"]);
3352
+ if (held.signal !== undefined)
3353
+ fail(`interrupted by ${held.signal} before the push`, exitCodeOf(null, held.signal));
3354
+ let push;
3355
+ try {
3356
+ push = await runTool("git push", ["git", "push", "--atomic", "origin", `HEAD:refs/heads/${branch}`, tag], { timeoutMs: limits.pushMs, onStart: (child) => held.forward(child) });
3357
+ } catch (error) {
3358
+ isPushUnknown = true;
3359
+ 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`);
3360
+ }
3361
+ if (push.code !== 0) {
3362
+ const signal = signalOf();
3363
+ 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`}:
3364
+ ${push.stderr.trim()}`, signal === undefined ? 1 : exitCodeOf(null, signal));
3365
+ }
3366
+ pushed = true;
3367
+ } finally {
3368
+ held.release();
3369
+ if (!pushed && !isPushUnknown && level !== undefined)
3370
+ await rollBack(preBump, tagsBefore);
3031
3371
  }
3372
+ const late = signalOf();
3373
+ if (late !== undefined)
3374
+ fail(`interrupted by ${late} after ${tag} was pushed: its runs are not waited for`, exitCodeOf(null, late));
3032
3375
  console.log(`pushed ${branch} and ${tag}, waiting for their runs`);
3033
- const code = await watchRuns((runs) => runs.filter((run) => run.id > before), timeout);
3376
+ const code = await watchRuns((runs) => runs.filter((run) => run.id > before && run.commit_sha === head), timeout, undefined, NO_RUN2);
3034
3377
  if (code !== 0)
3035
3378
  return code;
3036
3379
  const file = Bun.file(join2(dir ?? ".", "package.json"));
@@ -3069,7 +3412,7 @@ ${done.map((issue) => ` #${issue.number} ${issue.title}`).join(`
3069
3412
  // src/commands/remote.ts
3070
3413
  import { lookup } from "dns/promises";
3071
3414
  import { homedir as homedir3 } from "os";
3072
- var {$: $4 } = globalThis.Bun;
3415
+ var {$: $2 } = globalThis.Bun;
3073
3416
 
3074
3417
  // src/config.ts
3075
3418
  import { homedir as homedir2 } from "os";
@@ -3078,30 +3421,53 @@ function settingsPath(home = homedir2()) {
3078
3421
  return join3(home, ".config", "ft", "config.json");
3079
3422
  }
3080
3423
  async function loadSettings(home = homedir2()) {
3081
- const file = Bun.file(settingsPath(home));
3424
+ const path = settingsPath(home);
3425
+ const file = Bun.file(path);
3082
3426
  if (!await file.exists())
3083
3427
  return {};
3084
3428
  let raw;
3085
3429
  try {
3086
3430
  raw = await file.json();
3087
- } catch {
3431
+ } catch (error) {
3432
+ warn(`${path} is not valid JSON, so its settings are ignored`, error instanceof Error ? error.message : String(error));
3433
+ return {};
3434
+ }
3435
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
3436
+ warn(`${path} is not a JSON object, so its settings are ignored`);
3088
3437
  return {};
3089
3438
  }
3090
- const host = typeof raw === "object" && raw !== null && "remoteHost" in raw ? raw.remoteHost : undefined;
3439
+ const host = "remoteHost" in raw ? raw.remoteHost : undefined;
3440
+ if (host !== undefined && (typeof host !== "string" || host === ""))
3441
+ warn(`remoteHost in ${path} must be a non-empty string, so it is ignored`);
3091
3442
  return typeof host === "string" && host !== "" ? { remoteHost: host } : {};
3092
3443
  }
3093
3444
 
3094
3445
  // src/remote.ts
3095
3446
  var SCP_LIKE = /^(?:([^@/]+)@)?([^:/]+):(?!\/)(.+?)(?:\.git)?\/?$/;
3096
- var SSH_URL = /^ssh:\/\/(?:([^@/]+)@)?([^:/]+)(?::\d+)?\/(.+?)(?:\.git)?\/?$/;
3447
+ var SSH_URL = /^ssh:\/\/(?:([^@/]+)@)?([^:/]+)(?::(\d+))?\/(.+?)(?:\.git)?\/?$/;
3448
+ var USERINFO = /^([a-z][a-z\d+.-]*:\/\/)[^@/]*@/i;
3449
+ var DEFAULT_PORT = "22";
3097
3450
  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] ?? "" };
3451
+ const ssh = SSH_URL.exec(url);
3452
+ const scp = ssh === null && !url.includes("://") ? SCP_LIKE.exec(url) : null;
3453
+ const match = ssh ?? scp;
3454
+ if (match === null || match[2]?.startsWith("-") === true)
3455
+ return;
3456
+ const login = match[1] ?? "git";
3457
+ const user = login.includes(":") ? login.slice(0, login.indexOf(":")) : login;
3458
+ if (ssh === null)
3459
+ return { user, host: match[2] ?? "", path: match[3] ?? "" };
3460
+ return { user, host: match[2] ?? "", path: match[4] ?? "", ...ssh[3] === undefined ? {} : { port: ssh[3] } };
3461
+ }
3462
+ function redactUrl(url) {
3463
+ return url.replace(USERINFO, "$1");
3100
3464
  }
3101
3465
  function canonicalRemote(url, host, reaches) {
3102
3466
  const remote = parseSshRemote(url);
3103
3467
  if (remote === undefined || remote.host === host || !reaches(remote.host))
3104
3468
  return;
3469
+ if (remote.port !== undefined && remote.port !== DEFAULT_PORT)
3470
+ return `ssh://${remote.user}@${host}:${remote.port}/${remote.path}.git`;
3105
3471
  return `${remote.user}@${host}:${remote.path}.git`;
3106
3472
  }
3107
3473
  async function reachesHost(hostname, host, hostAddresses, addressesOf) {
@@ -3124,7 +3490,7 @@ var options7 = {
3124
3490
  };
3125
3491
  var HOSTNAME = /^hostname (.+)$/m;
3126
3492
  async function sshHostname(alias) {
3127
- return HOSTNAME.exec(await $4`ssh -G ${alias}`.quiet().nothrow().text())?.[1];
3493
+ return HOSTNAME.exec(await $2`ssh -G -- ${alias}`.quiet().nothrow().text())?.[1];
3128
3494
  }
3129
3495
  async function addresses(name) {
3130
3496
  try {
@@ -3133,6 +3499,14 @@ async function addresses(name) {
3133
3499
  return [];
3134
3500
  }
3135
3501
  }
3502
+ async function restoreOrigin(fetchUrl, pushUrls, changes) {
3503
+ if (changes.some(([from]) => from === fetchUrl))
3504
+ await git(["remote", "set-url", "origin", fetchUrl]);
3505
+ for (const [from, to] of changes) {
3506
+ if (pushUrls.includes(from))
3507
+ await git(["remote", "set-url", "--push", "origin", from, to]);
3508
+ }
3509
+ }
3136
3510
  function originHost(url) {
3137
3511
  return parseSshRemote(url)?.host ?? (URL.canParse(url) ? new URL(url).hostname : undefined);
3138
3512
  }
@@ -3144,7 +3518,7 @@ async function remote(args, home = homedir3()) {
3144
3518
  return 0;
3145
3519
  }
3146
3520
  const fetchUrl = await git(["remote", "get-url", "origin"]);
3147
- const pushUrls = (await $4`git config --get-all remote.origin.pushurl`.quiet().nothrow().text()).split(`
3521
+ const pushUrls = (await $2`git config --get-all remote.origin.pushurl`.quiet().nothrow().text()).split(`
3148
3522
  `).filter(Boolean);
3149
3523
  const hostAddresses = new Set(await addresses(host));
3150
3524
  const hostnames = new Map;
@@ -3162,15 +3536,15 @@ async function remote(args, home = homedir3()) {
3162
3536
  if (changes.length === 0) {
3163
3537
  const named = originHost(fetchUrl);
3164
3538
  if (named === host) {
3165
- console.log(`origin already names ${host}: ${fetchUrl}`);
3539
+ console.log(`origin already names ${host}: ${redactUrl(fetchUrl)}`);
3166
3540
  return 0;
3167
3541
  }
3168
3542
  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`);
3543
+ 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
3544
  return 1;
3171
3545
  }
3172
3546
  for (const [from, to] of changes)
3173
- console.log(`${from} -> ${to}`);
3547
+ console.log(`${redactUrl(from)} -> ${redactUrl(to)}`);
3174
3548
  if (values.fix !== true) {
3175
3549
  console.log("run `ft remote --fix` to switch them");
3176
3550
  return 1;
@@ -3182,7 +3556,12 @@ async function remote(args, home = homedir3()) {
3182
3556
  if (pushUrls.includes(from))
3183
3557
  await git(["remote", "set-url", "--push", "origin", to, from]);
3184
3558
  }
3185
- await git(["ls-remote", "origin", "HEAD"]);
3559
+ try {
3560
+ await git(["ls-remote", "origin", "HEAD"]);
3561
+ } catch (error) {
3562
+ await restoreOrigin(fetchUrl, pushUrls, changes);
3563
+ return fail(`the switched origin does not answer, so the old URLs were put back: ${redactUrl(error instanceof Error ? error.message : String(error))}`, 1);
3564
+ }
3186
3565
  console.log("origin switched, and git ls-remote reaches it");
3187
3566
  return 0;
3188
3567
  }
@@ -3234,7 +3613,7 @@ async function runs(args) {
3234
3613
  }
3235
3614
 
3236
3615
  // src/cli.ts
3237
- process14.exitCode = await runCli({
3616
+ process15.exitCode = await runCli({
3238
3617
  name: "ft",
3239
3618
  version,
3240
3619
  about: "Forgejo tools over tea, run from a repository whose origin is on Forgejo",
@@ -3242,9 +3621,9 @@ process14.exitCode = await runCli({
3242
3621
  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
3622
  commands: {
3244
3623
  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" },
3624
+ ci: { run: ci2, summary: "wait for the runs covering a commit, printing what failed" },
3246
3625
  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" },
3626
+ logs: { run: logs2, summary: "what the failed jobs of a run say went wrong" },
3248
3627
  release: { run: release, summary: "bump, push branch and tag atomically, wait for runs and registry" },
3249
3628
  board: { run: board, summary: "open issues: available, taken by a live session, or claimed by one gone" },
3250
3629
  digest: { run: digest, summary: "repo state for a cold start: held and abandoned claims, red CI, unpushed commits, dirty files, stale TODOs" },