react-doctor 0.0.8 → 0.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/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));
1092
+ }
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(""));
1001
1125
  }
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(" ")}`);
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
  };
@@ -1120,13 +1272,7 @@ const resolveProjectFlag = (projectFlag, workspacePackages) => {
1120
1272
  return resolvedDirectories;
1121
1273
  };
1122
1274
  const printDiscoveredProjects = (packages) => {
1123
- logger.log(`${highlighter.success("✔")} Found ${highlighter.info(`${packages.length}`)} React projects:`);
1124
- logger.break();
1125
- for (const workspacePackage of packages) logger.log(` ${highlighter.dim("─")} ${workspacePackage.directory}`);
1126
- logger.break();
1127
- logger.dim(`Run with a specific path to scan a project:`);
1128
- logger.dim(` npx -y react-doctor@latest <path>`);
1129
- logger.break();
1275
+ logger.log(`${highlighter.success("✔")} Select projects to scan ${highlighter.dim("›")} ${packages.map((workspacePackage) => workspacePackage.name).join(", ")}`);
1130
1276
  };
1131
1277
  const promptProjectSelection = async (workspacePackages, rootDirectory) => {
1132
1278
  const { selectedDirectories } = await prompts({
@@ -1165,9 +1311,10 @@ const writeConfig = (config) => {
1165
1311
  const installSkill = () => {
1166
1312
  try {
1167
1313
  execSync(`npx -y skills add ${SKILL_REPO}`, { stdio: "inherit" });
1168
- return true;
1169
1314
  } catch {
1170
- return false;
1315
+ logger.break();
1316
+ logger.dim("Skill install failed. You can install manually:");
1317
+ logger.dim(` npx skills add ${SKILL_REPO}`);
1171
1318
  }
1172
1319
  };
1173
1320
  const maybePromptSkillInstall = async (shouldSkipPrompts) => {
@@ -1187,14 +1334,7 @@ const maybePromptSkillInstall = async (shouldSkipPrompts) => {
1187
1334
  });
1188
1335
  if (shouldInstall) {
1189
1336
  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
- }
1337
+ installSkill();
1198
1338
  }
1199
1339
  writeConfig({
1200
1340
  ...config,
@@ -1202,15 +1342,85 @@ const maybePromptSkillInstall = async (shouldSkipPrompts) => {
1202
1342
  });
1203
1343
  };
1204
1344
 
1345
+ //#endregion
1346
+ //#region src/utils/global-install.ts
1347
+ const isGloballyInstalled = () => {
1348
+ try {
1349
+ return !execSync("which react-doctor", {
1350
+ stdio: "pipe",
1351
+ encoding: "utf-8"
1352
+ }).trim().includes("/_npx/");
1353
+ } catch {
1354
+ return false;
1355
+ }
1356
+ };
1357
+ const maybeInstallGlobally = () => {
1358
+ try {
1359
+ if (isGloballyInstalled()) return;
1360
+ const child = spawn("npm", [
1361
+ "install",
1362
+ "-g",
1363
+ "react-doctor@latest"
1364
+ ], {
1365
+ detached: true,
1366
+ stdio: "ignore"
1367
+ });
1368
+ child.on("error", () => {});
1369
+ child.unref();
1370
+ } catch {}
1371
+ };
1372
+
1373
+ //#endregion
1374
+ //#region src/utils/copy-to-clipboard.ts
1375
+ const getClipboardCommands = () => {
1376
+ if (process.platform === "darwin") return [{
1377
+ command: "pbcopy",
1378
+ args: []
1379
+ }];
1380
+ if (process.platform === "win32") return [{
1381
+ command: "clip",
1382
+ args: []
1383
+ }];
1384
+ return [
1385
+ {
1386
+ command: "wl-copy",
1387
+ args: []
1388
+ },
1389
+ {
1390
+ command: "xclip",
1391
+ args: ["-selection", "clipboard"]
1392
+ },
1393
+ {
1394
+ command: "xsel",
1395
+ args: ["--clipboard", "--input"]
1396
+ }
1397
+ ];
1398
+ };
1399
+ const copyToClipboard = (text) => {
1400
+ const clipboardCommands = getClipboardCommands();
1401
+ for (const clipboardCommand of clipboardCommands) if (spawnSync(clipboardCommand.command, clipboardCommand.args, {
1402
+ input: text,
1403
+ stdio: [
1404
+ "pipe",
1405
+ "ignore",
1406
+ "ignore"
1407
+ ],
1408
+ encoding: "utf8"
1409
+ }).status === 0) return true;
1410
+ return false;
1411
+ };
1412
+
1205
1413
  //#endregion
1206
1414
  //#region src/cli.ts
1207
- const VERSION = "0.0.8";
1415
+ const VERSION = "0.0.10";
1208
1416
  process.on("SIGINT", () => process.exit(0));
1209
1417
  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) => {
1418
+ 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) => {
1419
+ const isScoreOnly = flags.score && !flags.prompt;
1420
+ const shouldCopyPromptOutput = flags.prompt;
1421
+ if (shouldCopyPromptOutput) startLoggerCapture();
1211
1422
  try {
1212
1423
  const resolvedDirectory = path.resolve(directory);
1213
- const isScoreOnly = flags.score;
1214
1424
  if (!isScoreOnly) {
1215
1425
  logger.log(`react-doctor v${VERSION}`);
1216
1426
  logger.break();
@@ -1218,7 +1428,7 @@ const program = new Command().name("react-doctor").description("Diagnose React c
1218
1428
  const scanOptions = {
1219
1429
  lint: flags.lint,
1220
1430
  deadCode: flags.deadCode,
1221
- verbose: Boolean(flags.verbose),
1431
+ verbose: flags.prompt || Boolean(flags.verbose),
1222
1432
  scoreOnly: isScoreOnly
1223
1433
  };
1224
1434
  const isAutomatedEnvironment = [
@@ -1242,19 +1452,23 @@ const program = new Command().name("react-doctor").description("Diagnose React c
1242
1452
  if (!isScoreOnly) logger.break();
1243
1453
  }
1244
1454
  if (flags.fix) openAmiToFix(resolvedDirectory);
1245
- if (!isScoreOnly) {
1455
+ if (!isScoreOnly && !flags.prompt) {
1246
1456
  await maybePromptSkillInstall(shouldSkipPrompts);
1247
1457
  if (!shouldSkipPrompts && !flags.fix) await maybePromptAmiFix(resolvedDirectory);
1248
1458
  }
1249
1459
  } catch (error) {
1250
- handleError(error);
1460
+ handleError(error, { shouldExit: !shouldCopyPromptOutput });
1461
+ } finally {
1462
+ if (shouldCopyPromptOutput) copyPromptToClipboard(stopLoggerCapture(), !isScoreOnly);
1251
1463
  }
1252
1464
  }).addHelpText("after", `
1253
1465
  ${highlighter.dim("Learn more:")}
1254
1466
  ${highlighter.info("https://github.com/aidenybai/react-doctor")}
1255
1467
  `);
1256
1468
  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.";
1469
+ 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.";
1470
+ const REACT_DOCTOR_OUTPUT_LABEL = "react-doctor output";
1471
+ const SCAN_SUMMARY_SEPARATOR = "─".repeat(SEPARATOR_LENGTH_CHARS);
1258
1472
  const isAmiInstalled = () => {
1259
1473
  try {
1260
1474
  execSync("ls /Applications/Ami.app", { stdio: "ignore" });
@@ -1278,7 +1492,7 @@ const openAmiToFix = (directory) => {
1278
1492
  const resolvedDirectory = path.resolve(directory);
1279
1493
  if (!isAmiInstalled()) installAmi();
1280
1494
  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`;
1495
+ const deeplink = `ami://open-project?cwd=${encodeURIComponent(resolvedDirectory)}&prompt=${encodeURIComponent(FIX_PROMPT)}&mode=agent`;
1282
1496
  try {
1283
1497
  execSync(`open "${deeplink}"`, { stdio: "ignore" });
1284
1498
  logger.success("Opened Ami with react-doctor fix prompt.");
@@ -1288,6 +1502,22 @@ const openAmiToFix = (directory) => {
1288
1502
  logger.info(deeplink);
1289
1503
  }
1290
1504
  };
1505
+ const buildPromptWithOutput = (reactDoctorOutput) => {
1506
+ const summaryStartIndex = reactDoctorOutput.indexOf(SCAN_SUMMARY_SEPARATOR);
1507
+ const normalizedReactDoctorOutput = (summaryStartIndex === -1 ? reactDoctorOutput : reactDoctorOutput.slice(0, summaryStartIndex).trimEnd()).trim();
1508
+ return `${FIX_PROMPT}\n\n${REACT_DOCTOR_OUTPUT_LABEL}:\n\`\`\`\n${normalizedReactDoctorOutput.length > 0 ? normalizedReactDoctorOutput : "No output captured."}\n\`\`\``;
1509
+ };
1510
+ const copyPromptToClipboard = (reactDoctorOutput, shouldLogResult) => {
1511
+ const promptWithOutput = buildPromptWithOutput(reactDoctorOutput);
1512
+ const didCopyPromptToClipboard = copyToClipboard(promptWithOutput);
1513
+ if (!shouldLogResult) return;
1514
+ if (didCopyPromptToClipboard) {
1515
+ logger.success("Copied latest scan output to clipboard");
1516
+ return;
1517
+ }
1518
+ logger.warn("Could not copy prompt to clipboard automatically. Use this prompt:");
1519
+ logger.info(promptWithOutput);
1520
+ };
1291
1521
  const maybePromptAmiFix = async (directory) => {
1292
1522
  logger.break();
1293
1523
  logger.log(`Fix these issues with ${highlighter.info("Ami")}?`);
@@ -1314,6 +1544,7 @@ const installAmiCommand = new Command("install-ami").description("Install Ami an
1314
1544
  program.addCommand(fixCommand);
1315
1545
  program.addCommand(installAmiCommand);
1316
1546
  const main$1 = async () => {
1547
+ maybeInstallGlobally();
1317
1548
  await program.parseAsync();
1318
1549
  };
1319
1550
  main$1();