forgedock 1.0.8 → 1.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/tui.mjs CHANGED
@@ -106,9 +106,6 @@ export function box(content, { title = "", padding = 1, width } = {}) {
106
106
  const lines = Array.isArray(content) ? content : String(content).split("\n");
107
107
  const pad = " ".repeat(padding);
108
108
 
109
- // Strip ANSI codes to compute visual width
110
- const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
111
-
112
109
  const contentWidth = lines.reduce(
113
110
  (max, l) => Math.max(max, stripAnsi(l).length),
114
111
  0,
@@ -659,8 +656,6 @@ export function table(
659
656
  ) {
660
657
  if (!rows || rows.length === 0) return "";
661
658
 
662
- const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
663
-
664
659
  // Compute column widths
665
660
  const colCount = Math.max(...rows.map((r) => r.length));
666
661
  const widths = Array.from({ length: colCount }, (_, c) =>
@@ -700,3 +695,473 @@ export function table(
700
695
 
701
696
  return lines.join("\n") + "\n";
702
697
  }
698
+
699
+ // ---------------------------------------------------------------------------
700
+ // ANSI string utilities (exported for testing)
701
+ // ---------------------------------------------------------------------------
702
+
703
+ /** Strip all ANSI CSI sequences from a string, returning only visible text. */
704
+ export function stripAnsi(s) {
705
+ return s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
706
+ }
707
+
708
+ /**
709
+ * Truncate an ANSI-decorated string to at most `maxWidth` visible characters,
710
+ * never bisecting an escape sequence.
711
+ *
712
+ * Tokens from the cut region are suppressed — only ANSI tokens encountered
713
+ * while the visible budget is still live are forwarded to the output.
714
+ * A trailing reset (\x1b[0m) is appended when truncation occurs and the
715
+ * result already contains ANSI sequences, preventing color bleed into
716
+ * adjacent columns.
717
+ */
718
+ export function truncateVisible(str, maxWidth) {
719
+ const ansiRe = /\x1b\[[0-9;]*[A-Za-z]/g;
720
+ let visible = 0;
721
+ let result = "";
722
+ let lastIndex = 0;
723
+ let m;
724
+ ansiRe.lastIndex = 0;
725
+ while ((m = ansiRe.exec(str)) !== null) {
726
+ // Consume plain-text chars before this ANSI token
727
+ const plain = str.slice(lastIndex, m.index);
728
+ const remaining = maxWidth - visible;
729
+ if (remaining > 0) {
730
+ result += plain.slice(0, remaining);
731
+ visible += Math.min(plain.length, remaining);
732
+ }
733
+ // Only include the ANSI token while the visible budget is not yet exhausted.
734
+ // Once the cut point is reached, tokens from the removed region are suppressed;
735
+ // the post-loop reset guard below handles color-state cleanup.
736
+ if (visible < maxWidth) {
737
+ result += m[0];
738
+ }
739
+ lastIndex = ansiRe.lastIndex;
740
+ }
741
+ // Remaining plain text after last ANSI token
742
+ const remaining = maxWidth - visible;
743
+ if (remaining > 0) {
744
+ result += str.slice(lastIndex, lastIndex + remaining);
745
+ }
746
+ // If truncation occurred and the result contains any ANSI sequences,
747
+ // append a full reset to prevent color from bleeding into adjacent columns.
748
+ // Truncation is detected by checking whether the original string has more
749
+ // visible characters than maxWidth (i.e. stripAnsi(str).length > maxWidth).
750
+ if (stripAnsi(str).length > maxWidth && result.includes("\x1b[")) {
751
+ result += "\x1b[0m";
752
+ }
753
+ return result;
754
+ }
755
+
756
+ // ---------------------------------------------------------------------------
757
+ // Annotated Review Screen
758
+ // ---------------------------------------------------------------------------
759
+
760
+ /**
761
+ * Confidence badge — returns a short colored indicator string.
762
+ * Uses function wrappers (not raw ANSI constants) so NO_COLOR is respected.
763
+ *
764
+ * @param {'high'|'medium'|'low'} confidence
765
+ * @returns {string}
766
+ */
767
+ function confidenceBadge(confidence) {
768
+ switch (confidence) {
769
+ case "high":
770
+ return green("[high]");
771
+ case "medium":
772
+ return yellow("[med] ");
773
+ case "low":
774
+ return red("[low] ");
775
+ default:
776
+ return dim("[???] ");
777
+ }
778
+ }
779
+
780
+ /**
781
+ * All ConfigDraft fields in display order with their human-readable labels
782
+ * and the forge.yaml key used for TODO comments.
783
+ */
784
+ const REVIEW_FIELDS = [
785
+ { key: "owner", label: "GitHub Owner", section: "project", draftPath: ["project", "owner"] },
786
+ { key: "repo", label: "Repository Name", section: "project", draftPath: ["project", "repo"] },
787
+ { key: "name", label: "Project Name", section: "project", draftPath: ["project", "name"] },
788
+ { key: "description", label: "Description", section: "project", draftPath: null },
789
+ { key: "root", label: "Repository Root", section: "paths", draftPath: ["paths", "root"] },
790
+ { key: "worktreeBase", label: "Worktree Base", section: "paths", draftPath: ["paths", "worktreeBase"] },
791
+ { key: "defaultBranch", label: "Default Branch", section: "branches", draftPath: ["branches", "default"] },
792
+ { key: "stagingBranch", label: "Staging Branch", section: "branches", draftPath: ["branches", "staging"] },
793
+ ];
794
+
795
+ /**
796
+ * Render the annotated review screen for a ConfigDraft.
797
+ *
798
+ * Shows every required forge.yaml field with its detected value, confidence
799
+ * badge, source, and a plain-language "why" explanation. The user presses
800
+ * Enter to accept all values, or types a field number to edit that field
801
+ * inline. Fields with low confidence are flagged — the caller can use the
802
+ * returned metadata to inject \`# TODO(forgedock:<field>)\` YAML comments.
803
+ *
804
+ * @param {import('./init-detect.mjs').ConfigDraft} draft
805
+ * The ConfigDraft returned by detectConfig().
806
+ * @param {object} [opts]
807
+ * @param {boolean} [opts.hasExistingConfig=false]
808
+ * When true, shows a "diff-style" header noting an existing forge.yaml will
809
+ * be overwritten.
810
+ * @param {string} [opts.existingContent=""]
811
+ * Serialized content of the existing forge.yaml for diff context display.
812
+ * @param {boolean} [opts.showSources=false]
813
+ * When true (e.g. \`--verbose\` mode), renders the Notes/why block for ALL
814
+ * fields — including high-confidence ones — so the user can see every
815
+ * detection source and reasoning string.
816
+ *
817
+ * @returns {Promise<{
818
+ * owner: string,
819
+ * repo: string,
820
+ * name: string,
821
+ * description: string,
822
+ * root: string,
823
+ * worktreeBase: string,
824
+ * defaultBranch: string,
825
+ * stagingBranch: string,
826
+ * lowConfidenceKeys: string[],
827
+ * }>}
828
+ * Resolves with the accepted (or edited) values, plus the list of field keys
829
+ * that had low confidence at the time of the screen render (so the caller can
830
+ * inject TODO comments).
831
+ */
832
+ export async function annotatedReviewScreen(
833
+ draft,
834
+ { hasExistingConfig = false, existingContent = "", showSources = false } = {},
835
+ ) {
836
+ // Helper — pull a field from the draft by path, or return a low-confidence placeholder.
837
+ function getField(draftPath) {
838
+ if (!draftPath) return { value: "", confidence: "low", source: "none", why: "Not auto-detected" };
839
+ let node = draft;
840
+ for (const key of draftPath) {
841
+ if (node && typeof node === "object" && key in node) {
842
+ node = node[key];
843
+ } else {
844
+ return { value: "", confidence: "low", source: "none", why: "Not auto-detected" };
845
+ }
846
+ }
847
+ if (node && typeof node === "object" && "value" in node) return node;
848
+ return { value: "", confidence: "low", source: "none", why: "Not auto-detected" };
849
+ }
850
+
851
+ // Build the mutable values map (starts from draft detections).
852
+ // Description is not part of ConfigDraft — always starts empty with low confidence.
853
+ const values = {};
854
+ const confidences = {};
855
+ const sources = {};
856
+ const whys = {};
857
+
858
+ for (const fd of REVIEW_FIELDS) {
859
+ const field = getField(fd.draftPath);
860
+ values[fd.key] = field.value;
861
+ confidences[fd.key] = field.confidence;
862
+ sources[fd.key] = field.source;
863
+ whys[fd.key] = field.why;
864
+ }
865
+
866
+ // Non-TTY: return detect values directly without interaction.
867
+ if (!process.stdin.isTTY) {
868
+ return {
869
+ ...values,
870
+ lowConfidenceKeys: REVIEW_FIELDS.filter(
871
+ (fd) => confidences[fd.key] === "low",
872
+ ).map((fd) => fd.key),
873
+ };
874
+ }
875
+
876
+ // Pad an ANSI-decorated string to `width` visible characters.
877
+ function padVisible(str, width) {
878
+ const visual = stripAnsi(str).length;
879
+ return str + " ".repeat(Math.max(0, width - visual));
880
+ }
881
+
882
+ // ── Render the annotated table ────────────────────────────────────────────
883
+ function renderScreen() {
884
+ process.stdout.write("\n");
885
+
886
+ if (hasExistingConfig) {
887
+ process.stdout.write(
888
+ box(
889
+ [
890
+ "",
891
+ ` ${yellow("forge.yaml already exists.")} The values below will ${bold("overwrite")} it.`,
892
+ ` ${dim("A backup will be created before writing.")}`,
893
+ "",
894
+ ],
895
+ { title: "Overwrite Mode" },
896
+ ),
897
+ );
898
+ }
899
+
900
+ // Header
901
+ process.stdout.write(
902
+ `${bold(" forge.yaml configuration")} ${dim("(detected from this repository)")}\n\n`,
903
+ );
904
+
905
+ // Table header
906
+ const NUM_W = 3;
907
+ const KEY_W = 16;
908
+ const VAL_W = 36;
909
+ const BADGE_W = 7; // "[high]" + space = 7 visible chars
910
+ const SOURCE_W = 32;
911
+
912
+ const hdr = [
913
+ dim("#".padEnd(NUM_W)),
914
+ dim("Field".padEnd(KEY_W)),
915
+ dim("Value".padEnd(VAL_W)),
916
+ dim("Conf".padEnd(BADGE_W)),
917
+ dim("Source"),
918
+ ].join(" ");
919
+ process.stdout.write(" " + hdr + "\n");
920
+ process.stdout.write(
921
+ " " +
922
+ dim("─".repeat(NUM_W + KEY_W + VAL_W + BADGE_W + SOURCE_W + 8)) +
923
+ "\n",
924
+ );
925
+
926
+ for (let i = 0; i < REVIEW_FIELDS.length; i++) {
927
+ const fd = REVIEW_FIELDS[i];
928
+ const num = dim(String(i + 1).padEnd(NUM_W));
929
+ const key = fd.label.padEnd(KEY_W);
930
+ const rawVal = values[fd.key] || dim("(empty)");
931
+ // Pad/truncate on visible width only — ANSI escape bytes must not be counted.
932
+ const displayVal = stripAnsi(rawVal).length > VAL_W
933
+ ? truncateVisible(rawVal, VAL_W - 1) + dim("…")
934
+ : padVisible(rawVal, VAL_W);
935
+ const badge = confidenceBadge(confidences[fd.key]);
936
+ // Truncate long source strings
937
+ const rawSrc = sources[fd.key] || "";
938
+ const displaySrc = rawSrc.length > SOURCE_W
939
+ ? rawSrc.slice(0, SOURCE_W - 1) + "…"
940
+ : rawSrc;
941
+
942
+ process.stdout.write(
943
+ ` ${num} ${key} ${displayVal} ${badge} ${dim(displaySrc)}\n`,
944
+ );
945
+ }
946
+
947
+ process.stdout.write("\n");
948
+
949
+ // Legend
950
+ process.stdout.write(
951
+ ` ${dim("Confidence:")} ${green("[high]")} detected ${yellow("[med] ")} inferred ${red("[low] ")} guessed\n`,
952
+ );
953
+ process.stdout.write("\n");
954
+
955
+ // Why summary for non-high fields (or ALL fields when showSources is enabled)
956
+ const interestingFields = REVIEW_FIELDS.filter(
957
+ (fd) => (confidences[fd.key] !== "high" || showSources) && whys[fd.key],
958
+ );
959
+ if (interestingFields.length > 0) {
960
+ process.stdout.write(` ${bold("Notes:")}\n`);
961
+ for (const fd of interestingFields) {
962
+ const badge = confidenceBadge(confidences[fd.key]);
963
+ process.stdout.write(
964
+ ` ${badge} ${bold(fd.label)}: ${dim(whys[fd.key])}\n`,
965
+ );
966
+ }
967
+ process.stdout.write("\n");
968
+ }
969
+
970
+ // TODO flag notice for low-confidence fields — derived live so it
971
+ // reflects any confidence promotions from interactive edits.
972
+ const liveLowKeys = REVIEW_FIELDS.filter(
973
+ (fd) => confidences[fd.key] === "low",
974
+ );
975
+ if (liveLowKeys.length > 0) {
976
+ const todoNames = liveLowKeys.map((fd) => fd.label).join(", ");
977
+ process.stdout.write(
978
+ ` ${red("⚠")} ${bold("Low-confidence fields")} will be written with a ${cyan("# TODO(forgedock:<field>)")} comment:\n`,
979
+ );
980
+ process.stdout.write(` ${dim(todoNames)}\n\n`);
981
+ }
982
+
983
+ process.stdout.write(
984
+ ` ${dim("Press")} ${bold("Enter")} ${dim("to accept all values, or enter a field number to edit it.")}\n\n`,
985
+ );
986
+ }
987
+
988
+ // ── Inline editor for a single field ─────────────────────────────────────
989
+ async function editField(index) {
990
+ const fd = REVIEW_FIELDS[index];
991
+ const current = values[fd.key];
992
+
993
+ // We need to temporarily use readline (cooked mode input).
994
+ // Exit raw mode first if it was active.
995
+ let wasRaw = false;
996
+ if (process.stdin.isRaw) {
997
+ try {
998
+ process.stdin.setRawMode(false);
999
+ wasRaw = true;
1000
+ } catch {
1001
+ /* ignore */
1002
+ }
1003
+ }
1004
+ process.stdin.resume();
1005
+ process.stdin.setEncoding("utf-8");
1006
+
1007
+ const rl = readline.createInterface({
1008
+ input: process.stdin,
1009
+ output: process.stdout,
1010
+ });
1011
+
1012
+ const hint = current ? ` ${dim(`(current: ${current})`)}` : "";
1013
+ const newValue = await new Promise((resolve) => {
1014
+ rl.question(
1015
+ ` ${cyan("?")} ${bold(fd.label)}${hint}: `,
1016
+ (answer) => {
1017
+ rl.close();
1018
+ resolve(answer.trim() === "" ? current : answer.trim());
1019
+ },
1020
+ );
1021
+ });
1022
+
1023
+ // Restore raw mode if it was active.
1024
+ if (wasRaw) {
1025
+ try {
1026
+ process.stdin.setRawMode(true);
1027
+ } catch {
1028
+ /* ignore */
1029
+ }
1030
+ }
1031
+
1032
+ // Mark as user-edited (medium confidence).
1033
+ values[fd.key] = newValue;
1034
+ if (confidences[fd.key] !== "high") {
1035
+ confidences[fd.key] = "medium";
1036
+ sources[fd.key] = "user input";
1037
+ whys[fd.key] = "Edited interactively during review";
1038
+ }
1039
+ }
1040
+
1041
+ // ── Interaction loop ──────────────────────────────────────────────────────
1042
+ renderScreen();
1043
+
1044
+ // Set up raw mode for single-keypress interaction.
1045
+ const wasRaw = process.stdin.isRaw;
1046
+ try {
1047
+ process.stdin.setRawMode(true);
1048
+ } catch {
1049
+ // Terminal doesn't support raw mode — fall back to accept-all.
1050
+ return {
1051
+ ...values,
1052
+ lowConfidenceKeys: REVIEW_FIELDS.filter(
1053
+ (fd) => confidences[fd.key] === "low",
1054
+ ).map((fd) => fd.key),
1055
+ };
1056
+ }
1057
+ process.stdin.resume();
1058
+ process.stdin.setEncoding("utf-8");
1059
+
1060
+ let accepted = false;
1061
+
1062
+ // Buffer for multi-character field number input (1–8 fields).
1063
+ let inputBuffer = "";
1064
+
1065
+ const sigintHandler = () => {
1066
+ try {
1067
+ process.stdin.setRawMode(wasRaw || false);
1068
+ } catch {
1069
+ /* ignore */
1070
+ }
1071
+ process.stdin.pause();
1072
+ process.stdin.removeAllListeners("data");
1073
+ if (process.stdout.isTTY) process.stdout.write("\x1b[?25h");
1074
+ process.exit(130);
1075
+ };
1076
+ process.once("SIGINT", sigintHandler);
1077
+
1078
+ await new Promise((resolve) => {
1079
+ function onData(key) {
1080
+ if (key === "") {
1081
+ // Ctrl+C
1082
+ sigintHandler();
1083
+ return;
1084
+ }
1085
+
1086
+ if (key === "\r" || key === "\n") {
1087
+ // Enter with no pending number → accept all
1088
+ if (inputBuffer === "") {
1089
+ accepted = true;
1090
+ cleanup();
1091
+ resolve();
1092
+ return;
1093
+ }
1094
+ // Enter after typing a number → edit that field
1095
+ const num = parseInt(inputBuffer, 10);
1096
+ inputBuffer = "";
1097
+ if (num >= 1 && num <= REVIEW_FIELDS.length) {
1098
+ cleanup();
1099
+ editField(num - 1).then(() => {
1100
+ // Re-render and re-enter interaction loop
1101
+ renderScreen();
1102
+ try {
1103
+ process.stdin.setRawMode(true);
1104
+ } catch {
1105
+ accepted = true;
1106
+ resolve();
1107
+ return;
1108
+ }
1109
+ process.stdin.resume();
1110
+ process.stdin.setEncoding("utf-8");
1111
+ process.stdin.on("data", onData);
1112
+ });
1113
+ } else {
1114
+ // Invalid number — ignore
1115
+ process.stdout.write(
1116
+ ` ${red("Invalid field number")} — enter 1–${REVIEW_FIELDS.length} or press Enter to accept.\n`,
1117
+ );
1118
+ }
1119
+ return;
1120
+ }
1121
+
1122
+ // Digit key — accumulate field number
1123
+ if (/^[0-9]$/.test(key)) {
1124
+ inputBuffer += key;
1125
+ process.stdout.write(key); // echo digit
1126
+ return;
1127
+ }
1128
+
1129
+ // Backspace
1130
+ if (key === "" || key === "") {
1131
+ if (inputBuffer.length > 0) {
1132
+ inputBuffer = inputBuffer.slice(0, -1);
1133
+ process.stdout.write(" "); // erase char
1134
+ }
1135
+ return;
1136
+ }
1137
+
1138
+ // Any other key — ignore
1139
+ }
1140
+
1141
+ function cleanup() {
1142
+ try {
1143
+ process.stdin.setRawMode(wasRaw || false);
1144
+ } catch {
1145
+ /* ignore */
1146
+ }
1147
+ process.stdin.pause();
1148
+ process.stdin.removeListener("data", onData);
1149
+ process.removeListener("SIGINT", sigintHandler);
1150
+ }
1151
+
1152
+ process.stdin.on("data", onData);
1153
+ });
1154
+
1155
+ if (accepted) {
1156
+ process.stdout.write(
1157
+ `\n ${green("✔")} ${bold("All values accepted.")}\n\n`,
1158
+ );
1159
+ }
1160
+
1161
+ return {
1162
+ ...values,
1163
+ lowConfidenceKeys: REVIEW_FIELDS.filter(
1164
+ (fd) => confidences[fd.key] === "low",
1165
+ ).map((fd) => fd.key),
1166
+ };
1167
+ }