forgedock 1.0.8 → 1.0.9

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