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/forge-utils.mjs +164 -0
- package/bin/forgedock.mjs +1914 -394
- package/bin/hooks/session-start.mjs +317 -0
- package/bin/init-detect.mjs +325 -0
- package/bin/init-enrich-api.mjs +211 -0
- package/bin/registry.mjs +358 -0
- package/bin/tui.mjs +470 -5
- package/commands/forgedock-init.md +305 -384
- package/commands/work-on.md +18 -0
- package/docs/CONFIG.md +185 -0
- package/docs/superpowers/specs/2026-06-09-intelligent-onboarding-design.md +172 -0
- package/package.json +5 -1
- package/scripts/verify-route-registration.sh +23 -17
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
|
+
}
|