react-doctor 0.0.8 → 0.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/dist/cli.js CHANGED
@@ -14,6 +14,23 @@ import { fileURLToPath } from "node:url";
14
14
  import ora from "ora";
15
15
  import basePrompts from "prompts";
16
16
 
17
+ //#region src/constants.ts
18
+ const SOURCE_FILE_PATTERN = /\.(tsx?|jsx?)$/;
19
+ const JSX_FILE_PATTERN = /\.(tsx|jsx)$/;
20
+ const MILLISECONDS_PER_SECOND = 1e3;
21
+ const ERROR_PREVIEW_LENGTH_CHARS = 200;
22
+ const PERFECT_SCORE = 100;
23
+ const SCORE_GOOD_THRESHOLD = 75;
24
+ const SCORE_OK_THRESHOLD = 50;
25
+ const SCORE_BAR_WIDTH_CHARS = 50;
26
+ const SEPARATOR_LENGTH_CHARS = 40;
27
+ const SUMMARY_BOX_HORIZONTAL_PADDING_CHARS = 1;
28
+ const SUMMARY_BOX_OUTER_INDENT_CHARS = 2;
29
+ const SCORE_API_URL = "https://www.react.doctor/api/score";
30
+ const SHARE_BASE_URL = "https://www.react.doctor/share";
31
+ const OFFLINE_MESSAGE = "You are offline, could not calculate score. Reconnect to calculate.";
32
+
33
+ //#endregion
17
34
  //#region src/utils/highlighter.ts
18
35
  const highlighter = {
19
36
  error: pc.red,
@@ -23,59 +40,74 @@ const highlighter = {
23
40
  dim: pc.dim
24
41
  };
25
42
 
43
+ //#endregion
44
+ //#region src/utils/strip-ansi.ts
45
+ const ANSI_ESCAPE_SEQUENCE = String.raw`\u001B\[[0-9;]*m`;
46
+ const ANSI_ESCAPE_PATTERN = new RegExp(ANSI_ESCAPE_SEQUENCE, "g");
47
+ const stripAnsi = (text) => text.replace(ANSI_ESCAPE_PATTERN, "");
48
+
26
49
  //#endregion
27
50
  //#region src/utils/logger.ts
51
+ const loggerCaptureState = {
52
+ isEnabled: false,
53
+ lines: []
54
+ };
55
+ const captureLogLine = (text) => {
56
+ if (!loggerCaptureState.isEnabled) return;
57
+ loggerCaptureState.lines.push(stripAnsi(text));
58
+ };
59
+ const writeLogLine = (text) => {
60
+ console.log(text);
61
+ captureLogLine(text);
62
+ };
63
+ const startLoggerCapture = () => {
64
+ loggerCaptureState.isEnabled = true;
65
+ loggerCaptureState.lines = [];
66
+ };
67
+ const stopLoggerCapture = () => {
68
+ const capturedOutput = loggerCaptureState.lines.join("\n");
69
+ loggerCaptureState.isEnabled = false;
70
+ loggerCaptureState.lines = [];
71
+ return capturedOutput;
72
+ };
28
73
  const logger = {
29
74
  error(...args) {
30
- console.log(highlighter.error(args.join(" ")));
75
+ writeLogLine(highlighter.error(args.join(" ")));
31
76
  },
32
77
  warn(...args) {
33
- console.log(highlighter.warn(args.join(" ")));
78
+ writeLogLine(highlighter.warn(args.join(" ")));
34
79
  },
35
80
  info(...args) {
36
- console.log(highlighter.info(args.join(" ")));
81
+ writeLogLine(highlighter.info(args.join(" ")));
37
82
  },
38
83
  success(...args) {
39
- console.log(highlighter.success(args.join(" ")));
84
+ writeLogLine(highlighter.success(args.join(" ")));
40
85
  },
41
86
  dim(...args) {
42
- console.log(highlighter.dim(args.join(" ")));
87
+ writeLogLine(highlighter.dim(args.join(" ")));
43
88
  },
44
89
  log(...args) {
45
- console.log(args.join(" "));
90
+ writeLogLine(args.join(" "));
46
91
  },
47
92
  break() {
48
- console.log("");
93
+ writeLogLine("");
49
94
  }
50
95
  };
51
96
 
52
97
  //#endregion
53
98
  //#region src/utils/handle-error.ts
54
- const handleError = (error) => {
99
+ const DEFAULT_HANDLE_ERROR_OPTIONS = { shouldExit: true };
100
+ const handleError = (error, options = DEFAULT_HANDLE_ERROR_OPTIONS) => {
55
101
  logger.break();
56
102
  logger.error("Something went wrong. Please check the error below for more details.");
57
103
  logger.error("If the problem persists, please open an issue on GitHub.");
58
104
  logger.error("");
59
105
  if (error instanceof Error) logger.error(error.message);
60
106
  logger.break();
61
- process.exit(1);
107
+ if (options.shouldExit) process.exit(1);
108
+ process.exitCode = 1;
62
109
  };
63
110
 
64
- //#endregion
65
- //#region src/constants.ts
66
- const SOURCE_FILE_PATTERN = /\.(tsx?|jsx?)$/;
67
- const JSX_FILE_PATTERN = /\.(tsx|jsx)$/;
68
- const MILLISECONDS_PER_SECOND = 1e3;
69
- const SEPARATOR_LENGTH_CHARS = 62;
70
- const ERROR_PREVIEW_LENGTH_CHARS = 200;
71
- const PERFECT_SCORE = 100;
72
- const SCORE_GOOD_THRESHOLD = 75;
73
- const SCORE_OK_THRESHOLD = 50;
74
- const SCORE_BAR_WIDTH_CHARS = 50;
75
- const SCORE_API_URL = "https://www.react.doctor/api/score";
76
- const SHARE_BASE_URL = "https://www.react.doctor/share";
77
- const OFFLINE_MESSAGE = "You are offline, could not calculate score. Reconnect to calculate.";
78
-
79
111
  //#endregion
80
112
  //#region src/utils/calculate-score.ts
81
113
  const calculateScore = async (diagnostics) => {
@@ -326,11 +358,13 @@ const discoverProject = (directory) => {
326
358
  if (!reactVersion) reactVersion = ancestorInfo.reactVersion;
327
359
  if (framework === "unknown") framework = ancestorInfo.framework;
328
360
  }
361
+ const projectName = packageJson.name ?? path.basename(directory);
329
362
  const hasTypeScript = fs.existsSync(path.join(directory, "tsconfig.json"));
330
363
  const sourceFileCount = countSourceFiles(directory);
331
364
  const hasReactCompiler = detectReactCompiler(directory, packageJson);
332
365
  return {
333
366
  rootDirectory: directory,
367
+ projectName,
334
368
  reactVersion,
335
369
  framework,
336
370
  hasTypeScript,
@@ -449,12 +483,45 @@ const silenced = async (fn) => {
449
483
  console.warn = originalWarn;
450
484
  }
451
485
  };
452
- const runKnip = async (rootDirectory) => {
486
+ const findMonorepoRoot = (directory) => {
487
+ let currentDirectory = path.dirname(directory);
488
+ while (currentDirectory !== path.dirname(currentDirectory)) {
489
+ if (fs.existsSync(path.join(currentDirectory, "pnpm-workspace.yaml")) || (() => {
490
+ const packageJsonPath = path.join(currentDirectory, "package.json");
491
+ if (!fs.existsSync(packageJsonPath)) return false;
492
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
493
+ return Array.isArray(packageJson.workspaces) || packageJson.workspaces?.packages;
494
+ })()) return currentDirectory;
495
+ currentDirectory = path.dirname(currentDirectory);
496
+ }
497
+ return null;
498
+ };
499
+ const runKnipWithOptions = async (knipCwd, workspaceName) => {
453
500
  const options = await silenced(() => createOptions({
454
- cwd: rootDirectory,
455
- isShowProgress: false
501
+ cwd: knipCwd,
502
+ isShowProgress: false,
503
+ ...workspaceName ? { workspace: workspaceName } : {}
456
504
  }));
457
- const { issues } = await silenced(() => main(options));
505
+ return await silenced(() => main(options));
506
+ };
507
+ const hasNodeModules = (directory) => {
508
+ const nodeModulesPath = path.join(directory, "node_modules");
509
+ return fs.existsSync(nodeModulesPath) && fs.statSync(nodeModulesPath).isDirectory();
510
+ };
511
+ const runKnip = async (rootDirectory) => {
512
+ const monorepoRoot = findMonorepoRoot(rootDirectory);
513
+ if (!(hasNodeModules(rootDirectory) || monorepoRoot !== null && hasNodeModules(monorepoRoot))) return [];
514
+ let knipResult;
515
+ if (monorepoRoot) {
516
+ const packageJsonPath = path.join(rootDirectory, "package.json");
517
+ const workspaceName = (fs.existsSync(packageJsonPath) ? JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) : {}).name ?? path.basename(rootDirectory);
518
+ try {
519
+ knipResult = await runKnipWithOptions(monorepoRoot, workspaceName);
520
+ } catch {
521
+ knipResult = await runKnipWithOptions(rootDirectory);
522
+ }
523
+ } else knipResult = await runKnipWithOptions(rootDirectory);
524
+ const { issues } = knipResult;
458
525
  const diagnostics = [];
459
526
  for (const unusedFile of issues.files) diagnostics.push({
460
527
  filePath: path.relative(rootDirectory, unusedFile),
@@ -483,7 +550,7 @@ const NEXTJS_RULES = {
483
550
  "react-doctor/nextjs-no-img-element": "warn",
484
551
  "react-doctor/nextjs-async-client-component": "error",
485
552
  "react-doctor/nextjs-no-a-element": "warn",
486
- "react-doctor/nextjs-no-use-search-params-without-suspense": "error",
553
+ "react-doctor/nextjs-no-use-search-params-without-suspense": "warn",
487
554
  "react-doctor/nextjs-no-client-fetch-for-server-data": "warn",
488
555
  "react-doctor/nextjs-missing-metadata": "warn",
489
556
  "react-doctor/nextjs-no-client-side-redirect": "warn",
@@ -515,12 +582,6 @@ const REACT_COMPILER_RULES = {
515
582
  "react-hooks-js/incompatible-library": "error",
516
583
  "react-hooks-js/todo": "error"
517
584
  };
518
- const REACT_PERF_RULES = {
519
- "react-perf/jsx-no-new-object-as-prop": "warn",
520
- "react-perf/jsx-no-new-array-as-prop": "warn",
521
- "react-perf/jsx-no-new-function-as-prop": "warn",
522
- "react-perf/jsx-no-jsx-as-prop": "warn"
523
- };
524
585
  const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler }) => ({
525
586
  categories: {
526
587
  correctness: "off",
@@ -550,7 +611,6 @@ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler }) => ({
550
611
  "react/jsx-no-script-url": "error",
551
612
  "react/no-render-return-value": "warn",
552
613
  "react/no-string-refs": "warn",
553
- "react/no-unescaped-entities": "warn",
554
614
  "react/no-is-mounted": "warn",
555
615
  "react/require-render-return": "error",
556
616
  "react/no-unknown-property": "warn",
@@ -569,7 +629,7 @@ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler }) => ({
569
629
  "jsx-a11y/label-has-associated-control": "warn",
570
630
  "jsx-a11y/no-distracting-elements": "error",
571
631
  "jsx-a11y/iframe-has-title": "warn",
572
- ...hasReactCompiler ? REACT_COMPILER_RULES : REACT_PERF_RULES,
632
+ ...hasReactCompiler ? REACT_COMPILER_RULES : {},
573
633
  "react-doctor/no-derived-state-effect": "error",
574
634
  "react-doctor/no-fetch-in-effect": "error",
575
635
  "react-doctor/no-cascading-set-state": "warn",
@@ -579,7 +639,6 @@ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler }) => ({
579
639
  "react-doctor/rerender-lazy-state-init": "warn",
580
640
  "react-doctor/rerender-functional-setstate": "warn",
581
641
  "react-doctor/rerender-dependencies": "error",
582
- "react-doctor/no-generic-handler-names": "warn",
583
642
  "react-doctor/no-giant-component": "warn",
584
643
  "react-doctor/no-render-in-render": "warn",
585
644
  "react-doctor/no-nested-component-definition": "error",
@@ -587,7 +646,7 @@ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler }) => ({
587
646
  "react-doctor/no-layout-property-animation": "error",
588
647
  "react-doctor/rerender-memo-with-default-value": "warn",
589
648
  "react-doctor/rendering-animate-svg-wrapper": "warn",
590
- "react-doctor/rendering-usetransition-loading": "warn",
649
+ "react-doctor/no-inline-prop-on-memo-component": "warn",
591
650
  "react-doctor/rendering-hydration-no-flicker": "warn",
592
651
  "react-doctor/no-transition-all": "warn",
593
652
  "react-doctor/no-global-css-variable-animation": "error",
@@ -869,12 +928,17 @@ const spinner = (text) => ({ start() {
869
928
  };
870
929
  } });
871
930
 
931
+ //#endregion
932
+ //#region src/utils/indent-multiline-text.ts
933
+ const indentMultilineText = (text, linePrefix) => text.split("\n").map((lineText) => `${linePrefix}${lineText}`).join("\n");
934
+
872
935
  //#endregion
873
936
  //#region src/scan.ts
874
937
  const SEVERITY_ORDER = {
875
938
  error: 0,
876
939
  warning: 1
877
940
  };
941
+ const colorizeBySeverity = (text, severity) => severity === "error" ? highlighter.error(text) : highlighter.warn(text);
878
942
  const sortBySeverity = (diagnosticGroups) => diagnosticGroups.toSorted(([, diagnosticsA], [, diagnosticsB]) => {
879
943
  return SEVERITY_ORDER[diagnosticsA[0].severity] - SEVERITY_ORDER[diagnosticsB[0].severity];
880
944
  });
@@ -892,11 +956,11 @@ const printDiagnostics = (diagnostics, isVerbose) => {
892
956
  const sortedRuleGroups = sortBySeverity([...groupBy(diagnostics, (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()]);
893
957
  for (const [, ruleDiagnostics] of sortedRuleGroups) {
894
958
  const firstDiagnostic = ruleDiagnostics[0];
895
- const icon = firstDiagnostic.severity === "error" ? highlighter.error("✗") : highlighter.warn("⚠");
959
+ const icon = colorizeBySeverity(firstDiagnostic.severity === "error" ? "✗" : "⚠", firstDiagnostic.severity);
896
960
  const count = ruleDiagnostics.length;
897
- const countLabel = count > 1 ? ` (${count})` : "";
961
+ const countLabel = count > 1 ? colorizeBySeverity(` (${count})`, firstDiagnostic.severity) : "";
898
962
  logger.log(` ${icon} ${firstDiagnostic.message}${countLabel}`);
899
- if (firstDiagnostic.help) logger.dim(` ${firstDiagnostic.help}`);
963
+ if (firstDiagnostic.help) logger.dim(indentMultilineText(firstDiagnostic.help, " "));
900
964
  if (isVerbose) {
901
965
  const fileLines = buildFileLineMap(ruleDiagnostics);
902
966
  for (const [filePath, lines] of fileLines) {
@@ -943,12 +1007,39 @@ const colorizeByScore = (text, score) => {
943
1007
  if (score >= SCORE_OK_THRESHOLD) return highlighter.warn(text);
944
1008
  return highlighter.error(text);
945
1009
  };
946
- const buildScoreBar = (score) => {
1010
+ const createFramedLine = (plainText, renderedText = plainText) => ({
1011
+ plainText,
1012
+ renderedText
1013
+ });
1014
+ const buildScoreBarSegments = (score) => {
947
1015
  const filledCount = Math.round(score / PERFECT_SCORE * SCORE_BAR_WIDTH_CHARS);
948
1016
  const emptyCount = SCORE_BAR_WIDTH_CHARS - filledCount;
949
- const filled = "█".repeat(filledCount);
950
- const empty = "".repeat(emptyCount);
951
- return colorizeByScore(filled, score) + highlighter.dim(empty);
1017
+ return {
1018
+ filledSegment: "".repeat(filledCount),
1019
+ emptySegment: "░".repeat(emptyCount)
1020
+ };
1021
+ };
1022
+ const buildPlainScoreBar = (score) => {
1023
+ const { filledSegment, emptySegment } = buildScoreBarSegments(score);
1024
+ return `${filledSegment}${emptySegment}`;
1025
+ };
1026
+ const buildScoreBar = (score) => {
1027
+ const { filledSegment, emptySegment } = buildScoreBarSegments(score);
1028
+ return colorizeByScore(filledSegment, score) + highlighter.dim(emptySegment);
1029
+ };
1030
+ const printFramedBox = (framedLines) => {
1031
+ if (framedLines.length === 0) return;
1032
+ const borderColorizer = highlighter.dim;
1033
+ const outerIndent = " ".repeat(SUMMARY_BOX_OUTER_INDENT_CHARS);
1034
+ const horizontalPadding = " ".repeat(SUMMARY_BOX_HORIZONTAL_PADDING_CHARS);
1035
+ const maximumLineLength = Math.max(...framedLines.map((framedLine) => framedLine.plainText.length));
1036
+ const borderLine = "─".repeat(maximumLineLength + SUMMARY_BOX_HORIZONTAL_PADDING_CHARS * 2);
1037
+ logger.log(`${outerIndent}${borderColorizer(`┌${borderLine}┐`)}`);
1038
+ for (const framedLine of framedLines) {
1039
+ const trailingSpaces = " ".repeat(maximumLineLength - framedLine.plainText.length);
1040
+ logger.log(`${outerIndent}${borderColorizer("│")}${horizontalPadding}${framedLine.renderedText}${trailingSpaces}${horizontalPadding}${borderColorizer("│")}`);
1041
+ }
1042
+ logger.log(`${outerIndent}${borderColorizer(`└${borderLine}┘`)}`);
952
1043
  };
953
1044
  const printScoreGauge = (score, label) => {
954
1045
  const scoreDisplay = colorizeByScore(`${score}`, score);
@@ -975,36 +1066,65 @@ const printBranding = (score) => {
975
1066
  logger.log(` React Doctor ${highlighter.dim("(www.react.doctor)")}`);
976
1067
  logger.break();
977
1068
  };
978
- const buildShareUrl = (diagnostics, scoreResult) => {
1069
+ const buildShareUrl = (diagnostics, scoreResult, projectName) => {
979
1070
  const errorCount = diagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
980
1071
  const warningCount = diagnostics.filter((diagnostic) => diagnostic.severity === "warning").length;
981
1072
  const affectedFileCount = collectAffectedFiles(diagnostics).size;
982
1073
  const params = new URLSearchParams();
1074
+ params.set("p", projectName);
983
1075
  if (scoreResult) params.set("s", String(scoreResult.score));
984
1076
  if (errorCount > 0) params.set("e", String(errorCount));
985
1077
  if (warningCount > 0) params.set("w", String(warningCount));
986
1078
  if (affectedFileCount > 0) params.set("f", String(affectedFileCount));
987
1079
  return `${SHARE_BASE_URL}?${params.toString()}`;
988
1080
  };
989
- const printSummary = (diagnostics, elapsedMilliseconds, scoreResult) => {
1081
+ const printSummary = (diagnostics, elapsedMilliseconds, scoreResult, projectName) => {
990
1082
  const errorCount = diagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
991
1083
  const warningCount = diagnostics.filter((diagnostic) => diagnostic.severity === "warning").length;
992
1084
  const affectedFileCount = collectAffectedFiles(diagnostics).size;
993
1085
  const elapsed = formatElapsedTime(elapsedMilliseconds);
994
- logger.log("─".repeat(SEPARATOR_LENGTH_CHARS));
995
- logger.break();
996
- printBranding(scoreResult?.score);
997
- if (scoreResult) printScoreGauge(scoreResult.score, scoreResult.label);
998
- else {
999
- logger.dim(` ${OFFLINE_MESSAGE}`);
1000
- logger.break();
1086
+ const summaryLineParts = [];
1087
+ const summaryLinePartsPlain = [];
1088
+ if (errorCount > 0) {
1089
+ const errorText = `✗ ${errorCount} error${errorCount === 1 ? "" : "s"}`;
1090
+ summaryLinePartsPlain.push(errorText);
1091
+ summaryLineParts.push(highlighter.error(errorText));
1001
1092
  }
1002
- const parts = [];
1003
- if (errorCount > 0) parts.push(highlighter.error(`${errorCount} error${errorCount === 1 ? "" : "s"}`));
1004
- if (warningCount > 0) parts.push(highlighter.warn(`${warningCount} warning${warningCount === 1 ? "" : "s"}`));
1005
- parts.push(highlighter.dim(`across ${affectedFileCount} file${affectedFileCount === 1 ? "" : "s"}`));
1006
- parts.push(highlighter.dim(`in ${elapsed}`));
1007
- logger.log(` ${parts.join(" ")}`);
1093
+ if (warningCount > 0) {
1094
+ const warningText = `⚠ ${warningCount} warning${warningCount === 1 ? "" : "s"}`;
1095
+ summaryLinePartsPlain.push(warningText);
1096
+ summaryLineParts.push(highlighter.warn(warningText));
1097
+ }
1098
+ const fileCountText = `across ${affectedFileCount} file${affectedFileCount === 1 ? "" : "s"}`;
1099
+ const elapsedTimeText = `in ${elapsed}`;
1100
+ summaryLinePartsPlain.push(fileCountText);
1101
+ summaryLinePartsPlain.push(elapsedTimeText);
1102
+ summaryLineParts.push(highlighter.dim(fileCountText));
1103
+ summaryLineParts.push(highlighter.dim(elapsedTimeText));
1104
+ const summaryFramedLines = [];
1105
+ if (scoreResult) {
1106
+ const [eyes, mouth] = getDoctorFace(scoreResult.score);
1107
+ const scoreColorizer = (text) => colorizeByScore(text, scoreResult.score);
1108
+ summaryFramedLines.push(createFramedLine("┌─────┐", scoreColorizer("┌─────┐")));
1109
+ summaryFramedLines.push(createFramedLine(`│ ${eyes} │`, scoreColorizer(`│ ${eyes} │`)));
1110
+ summaryFramedLines.push(createFramedLine(`│ ${mouth} │`, scoreColorizer(`│ ${mouth} │`)));
1111
+ summaryFramedLines.push(createFramedLine("└─────┘", scoreColorizer("└─────┘")));
1112
+ summaryFramedLines.push(createFramedLine("React Doctor (www.react.doctor)", `React Doctor ${highlighter.dim("(www.react.doctor)")}`));
1113
+ summaryFramedLines.push(createFramedLine(""));
1114
+ const scoreLinePlainText = `${scoreResult.score} / ${PERFECT_SCORE} ${scoreResult.label}`;
1115
+ const scoreLineRenderedText = `${colorizeByScore(String(scoreResult.score), scoreResult.score)} / ${PERFECT_SCORE} ${colorizeByScore(scoreResult.label, scoreResult.score)}`;
1116
+ summaryFramedLines.push(createFramedLine(scoreLinePlainText, scoreLineRenderedText));
1117
+ summaryFramedLines.push(createFramedLine(""));
1118
+ summaryFramedLines.push(createFramedLine(buildPlainScoreBar(scoreResult.score), buildScoreBar(scoreResult.score)));
1119
+ summaryFramedLines.push(createFramedLine(""));
1120
+ } else {
1121
+ summaryFramedLines.push(createFramedLine("React Doctor (www.react.doctor)", `React Doctor ${highlighter.dim("(www.react.doctor)")}`));
1122
+ summaryFramedLines.push(createFramedLine(""));
1123
+ summaryFramedLines.push(createFramedLine(OFFLINE_MESSAGE, highlighter.dim(OFFLINE_MESSAGE)));
1124
+ summaryFramedLines.push(createFramedLine(""));
1125
+ }
1126
+ summaryFramedLines.push(createFramedLine(summaryLinePartsPlain.join(" "), summaryLineParts.join(" ")));
1127
+ printFramedBox(summaryFramedLines);
1008
1128
  try {
1009
1129
  const diagnosticsDirectory = writeDiagnosticsDirectory(diagnostics);
1010
1130
  logger.break();
@@ -1012,7 +1132,7 @@ const printSummary = (diagnostics, elapsedMilliseconds, scoreResult) => {
1012
1132
  } catch {
1013
1133
  logger.break();
1014
1134
  }
1015
- const shareUrl = buildShareUrl(diagnostics, scoreResult);
1135
+ const shareUrl = buildShareUrl(diagnostics, scoreResult, projectName);
1016
1136
  logger.break();
1017
1137
  logger.dim(` Share your results: ${highlighter.info(shareUrl)}`);
1018
1138
  };
@@ -1078,18 +1198,46 @@ const scan = async (directory, options) => {
1078
1198
  return;
1079
1199
  }
1080
1200
  printDiagnostics(diagnostics, options.verbose);
1081
- printSummary(diagnostics, elapsedMilliseconds, scoreResult);
1201
+ printSummary(diagnostics, elapsedMilliseconds, scoreResult, projectInfo.projectName);
1202
+ };
1203
+
1204
+ //#endregion
1205
+ //#region src/utils/should-select-all-choices.ts
1206
+ const shouldSelectAllChoices = (choiceStates) => {
1207
+ return choiceStates.filter((choiceState) => !choiceState.disabled).some((choiceState) => choiceState.selected !== true);
1082
1208
  };
1083
1209
 
1084
1210
  //#endregion
1085
1211
  //#region src/utils/prompts.ts
1212
+ const require = createRequire(import.meta.url);
1213
+ const PROMPTS_MULTISELECT_MODULE_PATH = "prompts/lib/elements/multiselect";
1214
+ let didPatchMultiselectToggleAll = false;
1086
1215
  const onCancel = () => {
1087
1216
  logger.break();
1088
1217
  logger.log("Cancelled.");
1089
1218
  logger.break();
1090
1219
  process.exit(0);
1091
1220
  };
1221
+ const patchMultiselectToggleAll = () => {
1222
+ if (didPatchMultiselectToggleAll) return;
1223
+ didPatchMultiselectToggleAll = true;
1224
+ const multiselectPromptConstructor = require(PROMPTS_MULTISELECT_MODULE_PATH);
1225
+ multiselectPromptConstructor.prototype.toggleAll = function() {
1226
+ const isCurrentChoiceDisabled = Boolean(this.value[this.cursor]?.disabled);
1227
+ if (this.maxChoices !== void 0 || isCurrentChoiceDisabled) {
1228
+ this.bell();
1229
+ return;
1230
+ }
1231
+ const shouldSelectAllEnabledChoices = shouldSelectAllChoices(this.value);
1232
+ for (const choiceState of this.value) {
1233
+ if (choiceState.disabled) continue;
1234
+ choiceState.selected = shouldSelectAllEnabledChoices;
1235
+ }
1236
+ this.render();
1237
+ };
1238
+ };
1092
1239
  const prompts = (questions) => {
1240
+ patchMultiselectToggleAll();
1093
1241
  return basePrompts(questions, { onCancel });
1094
1242
  };
1095
1243
 
@@ -1099,10 +1247,14 @@ const selectProjects = async (rootDirectory, projectFlag, skipPrompts) => {
1099
1247
  let packages = listWorkspacePackages(rootDirectory);
1100
1248
  if (packages.length === 0) packages = discoverReactSubprojects(rootDirectory);
1101
1249
  if (packages.length === 0) return [rootDirectory];
1250
+ if (packages.length === 1) {
1251
+ logger.log(`${highlighter.success("✔")} Select projects to scan ${highlighter.dim("›")} ${packages[0].name}`);
1252
+ return [packages[0].directory];
1253
+ }
1102
1254
  if (projectFlag) return resolveProjectFlag(projectFlag, packages);
1103
1255
  if (skipPrompts) {
1104
1256
  printDiscoveredProjects(packages);
1105
- process.exit(0);
1257
+ return packages.map((workspacePackage) => workspacePackage.directory);
1106
1258
  }
1107
1259
  return promptProjectSelection(packages, rootDirectory);
1108
1260
  };
@@ -1165,9 +1317,10 @@ const writeConfig = (config) => {
1165
1317
  const installSkill = () => {
1166
1318
  try {
1167
1319
  execSync(`npx -y skills add ${SKILL_REPO}`, { stdio: "inherit" });
1168
- return true;
1169
1320
  } catch {
1170
- return false;
1321
+ logger.break();
1322
+ logger.dim("Skill install failed. You can install manually:");
1323
+ logger.dim(` npx skills add ${SKILL_REPO}`);
1171
1324
  }
1172
1325
  };
1173
1326
  const maybePromptSkillInstall = async (shouldSkipPrompts) => {
@@ -1187,14 +1340,7 @@ const maybePromptSkillInstall = async (shouldSkipPrompts) => {
1187
1340
  });
1188
1341
  if (shouldInstall) {
1189
1342
  logger.break();
1190
- if (installSkill()) {
1191
- logger.break();
1192
- logger.success("Skill installed!");
1193
- } else {
1194
- logger.break();
1195
- logger.dim("Skill install failed. You can install manually:");
1196
- logger.dim(` npx skills add ${SKILL_REPO}`);
1197
- }
1343
+ installSkill();
1198
1344
  }
1199
1345
  writeConfig({
1200
1346
  ...config,
@@ -1202,15 +1348,85 @@ const maybePromptSkillInstall = async (shouldSkipPrompts) => {
1202
1348
  });
1203
1349
  };
1204
1350
 
1351
+ //#endregion
1352
+ //#region src/utils/global-install.ts
1353
+ const isGloballyInstalled = () => {
1354
+ try {
1355
+ return !execSync("which react-doctor", {
1356
+ stdio: "pipe",
1357
+ encoding: "utf-8"
1358
+ }).trim().includes("/_npx/");
1359
+ } catch {
1360
+ return false;
1361
+ }
1362
+ };
1363
+ const maybeInstallGlobally = () => {
1364
+ try {
1365
+ if (isGloballyInstalled()) return;
1366
+ const child = spawn("npm", [
1367
+ "install",
1368
+ "-g",
1369
+ "react-doctor@latest"
1370
+ ], {
1371
+ detached: true,
1372
+ stdio: "ignore"
1373
+ });
1374
+ child.on("error", () => {});
1375
+ child.unref();
1376
+ } catch {}
1377
+ };
1378
+
1379
+ //#endregion
1380
+ //#region src/utils/copy-to-clipboard.ts
1381
+ const getClipboardCommands = () => {
1382
+ if (process.platform === "darwin") return [{
1383
+ command: "pbcopy",
1384
+ args: []
1385
+ }];
1386
+ if (process.platform === "win32") return [{
1387
+ command: "clip",
1388
+ args: []
1389
+ }];
1390
+ return [
1391
+ {
1392
+ command: "wl-copy",
1393
+ args: []
1394
+ },
1395
+ {
1396
+ command: "xclip",
1397
+ args: ["-selection", "clipboard"]
1398
+ },
1399
+ {
1400
+ command: "xsel",
1401
+ args: ["--clipboard", "--input"]
1402
+ }
1403
+ ];
1404
+ };
1405
+ const copyToClipboard = (text) => {
1406
+ const clipboardCommands = getClipboardCommands();
1407
+ for (const clipboardCommand of clipboardCommands) if (spawnSync(clipboardCommand.command, clipboardCommand.args, {
1408
+ input: text,
1409
+ stdio: [
1410
+ "pipe",
1411
+ "ignore",
1412
+ "ignore"
1413
+ ],
1414
+ encoding: "utf8"
1415
+ }).status === 0) return true;
1416
+ return false;
1417
+ };
1418
+
1205
1419
  //#endregion
1206
1420
  //#region src/cli.ts
1207
- const VERSION = "0.0.8";
1421
+ const VERSION = "0.0.9";
1208
1422
  process.on("SIGINT", () => process.exit(0));
1209
1423
  process.on("SIGTERM", () => process.exit(0));
1210
- const program = new Command().name("react-doctor").description("Diagnose React codebase health").version(VERSION, "-v, --version", "display the version number").argument("[directory]", "project directory to scan", ".").option("--no-lint", "skip linting").option("--no-dead-code", "skip dead code detection").option("--verbose", "show file details per rule").option("--score", "output only the score").option("-y, --yes", "skip prompts, scan all workspace projects").option("--project <name>", "select workspace project (comma-separated for multiple)").option("--fix", "open Ami to auto-fix all issues").action(async (directory, flags) => {
1424
+ const program = new Command().name("react-doctor").description("Diagnose React codebase health").version(VERSION, "-v, --version", "display the version number").argument("[directory]", "project directory to scan", ".").option("--no-lint", "skip linting").option("--no-dead-code", "skip dead code detection").option("--verbose", "show file details per rule").option("--score", "output only the score").option("-y, --yes", "skip prompts, scan all workspace projects").option("--project <name>", "select workspace project (comma-separated for multiple)").option("--fix", "open Ami to auto-fix all issues").option("--prompt", "copy latest scan output to clipboard").action(async (directory, flags) => {
1425
+ const isScoreOnly = flags.score && !flags.prompt;
1426
+ const shouldCopyPromptOutput = flags.prompt;
1427
+ if (shouldCopyPromptOutput) startLoggerCapture();
1211
1428
  try {
1212
1429
  const resolvedDirectory = path.resolve(directory);
1213
- const isScoreOnly = flags.score;
1214
1430
  if (!isScoreOnly) {
1215
1431
  logger.log(`react-doctor v${VERSION}`);
1216
1432
  logger.break();
@@ -1218,7 +1434,7 @@ const program = new Command().name("react-doctor").description("Diagnose React c
1218
1434
  const scanOptions = {
1219
1435
  lint: flags.lint,
1220
1436
  deadCode: flags.deadCode,
1221
- verbose: Boolean(flags.verbose),
1437
+ verbose: flags.prompt || Boolean(flags.verbose),
1222
1438
  scoreOnly: isScoreOnly
1223
1439
  };
1224
1440
  const isAutomatedEnvironment = [
@@ -1242,19 +1458,23 @@ const program = new Command().name("react-doctor").description("Diagnose React c
1242
1458
  if (!isScoreOnly) logger.break();
1243
1459
  }
1244
1460
  if (flags.fix) openAmiToFix(resolvedDirectory);
1245
- if (!isScoreOnly) {
1461
+ if (!isScoreOnly && !flags.prompt) {
1246
1462
  await maybePromptSkillInstall(shouldSkipPrompts);
1247
1463
  if (!shouldSkipPrompts && !flags.fix) await maybePromptAmiFix(resolvedDirectory);
1248
1464
  }
1249
1465
  } catch (error) {
1250
- handleError(error);
1466
+ handleError(error, { shouldExit: !shouldCopyPromptOutput });
1467
+ } finally {
1468
+ if (shouldCopyPromptOutput) copyPromptToClipboard(stopLoggerCapture(), !isScoreOnly);
1251
1469
  }
1252
1470
  }).addHelpText("after", `
1253
1471
  ${highlighter.dim("Learn more:")}
1254
1472
  ${highlighter.info("https://github.com/aidenybai/react-doctor")}
1255
1473
  `);
1256
1474
  const AMI_INSTALL_URL = "https://ami.dev/install.sh";
1257
- const AMI_FIX_PROMPT = "Run npx -y react-doctor@latest . --verbose, read every diagnostic, then fix all issues one by one. After fixing, re-run react-doctor to verify the score improved.";
1475
+ const FIX_PROMPT = "Fix all issues reported in the react-doctor diagnostics below, one by one. After applying fixes, run `react-dcotor` again to verify the results improved.";
1476
+ const REACT_DOCTOR_OUTPUT_LABEL = "react-doctor output";
1477
+ const SCAN_SUMMARY_SEPARATOR = "─".repeat(SEPARATOR_LENGTH_CHARS);
1258
1478
  const isAmiInstalled = () => {
1259
1479
  try {
1260
1480
  execSync("ls /Applications/Ami.app", { stdio: "ignore" });
@@ -1278,7 +1498,7 @@ const openAmiToFix = (directory) => {
1278
1498
  const resolvedDirectory = path.resolve(directory);
1279
1499
  if (!isAmiInstalled()) installAmi();
1280
1500
  logger.log("Opening Ami to fix react-doctor issues...");
1281
- const deeplink = `ami://open-project?cwd=${encodeURIComponent(resolvedDirectory)}&prompt=${encodeURIComponent(AMI_FIX_PROMPT)}&mode=agent`;
1501
+ const deeplink = `ami://open-project?cwd=${encodeURIComponent(resolvedDirectory)}&prompt=${encodeURIComponent(FIX_PROMPT)}&mode=agent`;
1282
1502
  try {
1283
1503
  execSync(`open "${deeplink}"`, { stdio: "ignore" });
1284
1504
  logger.success("Opened Ami with react-doctor fix prompt.");
@@ -1288,6 +1508,22 @@ const openAmiToFix = (directory) => {
1288
1508
  logger.info(deeplink);
1289
1509
  }
1290
1510
  };
1511
+ const buildPromptWithOutput = (reactDoctorOutput) => {
1512
+ const summaryStartIndex = reactDoctorOutput.indexOf(SCAN_SUMMARY_SEPARATOR);
1513
+ const normalizedReactDoctorOutput = (summaryStartIndex === -1 ? reactDoctorOutput : reactDoctorOutput.slice(0, summaryStartIndex).trimEnd()).trim();
1514
+ return `${FIX_PROMPT}\n\n${REACT_DOCTOR_OUTPUT_LABEL}:\n\`\`\`\n${normalizedReactDoctorOutput.length > 0 ? normalizedReactDoctorOutput : "No output captured."}\n\`\`\``;
1515
+ };
1516
+ const copyPromptToClipboard = (reactDoctorOutput, shouldLogResult) => {
1517
+ const promptWithOutput = buildPromptWithOutput(reactDoctorOutput);
1518
+ const didCopyPromptToClipboard = copyToClipboard(promptWithOutput);
1519
+ if (!shouldLogResult) return;
1520
+ if (didCopyPromptToClipboard) {
1521
+ logger.success("Copied latest scan output to clipboard");
1522
+ return;
1523
+ }
1524
+ logger.warn("Could not copy prompt to clipboard automatically. Use this prompt:");
1525
+ logger.info(promptWithOutput);
1526
+ };
1291
1527
  const maybePromptAmiFix = async (directory) => {
1292
1528
  logger.break();
1293
1529
  logger.log(`Fix these issues with ${highlighter.info("Ami")}?`);
@@ -1314,6 +1550,7 @@ const installAmiCommand = new Command("install-ami").description("Install Ami an
1314
1550
  program.addCommand(fixCommand);
1315
1551
  program.addCommand(installAmiCommand);
1316
1552
  const main$1 = async () => {
1553
+ maybeInstallGlobally();
1317
1554
  await program.parseAsync();
1318
1555
  };
1319
1556
  main$1();