react-doctor 0.0.27 → 0.0.29
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/README.md +23 -0
- package/dist/cli.js +333 -121
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +70 -17
- package/dist/index.js.map +1 -1
- package/dist/react-doctor-plugin.js +8 -0
- package/dist/react-doctor-plugin.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { execSync, spawn, spawnSync } from "node:child_process";
|
|
4
|
-
import fs, { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import fs, { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
5
5
|
import os, { homedir, tmpdir } from "node:os";
|
|
6
6
|
import path, { join } from "node:path";
|
|
7
7
|
import { Command } from "commander";
|
|
8
8
|
import { randomUUID } from "node:crypto";
|
|
9
9
|
import { performance } from "node:perf_hooks";
|
|
10
10
|
import pc from "picocolors";
|
|
11
|
+
import basePrompts from "prompts";
|
|
11
12
|
import { main } from "knip";
|
|
12
13
|
import { createOptions } from "knip/session";
|
|
13
14
|
import { fileURLToPath } from "node:url";
|
|
14
15
|
import ora from "ora";
|
|
15
|
-
import basePrompts from "prompts";
|
|
16
16
|
|
|
17
17
|
//#region src/constants.ts
|
|
18
18
|
const SOURCE_FILE_PATTERN = /\.(tsx?|jsx?)$/;
|
|
@@ -40,6 +40,8 @@ const WARNING_RULE_PENALTY = .75;
|
|
|
40
40
|
const ERROR_ESTIMATED_FIX_RATE = .85;
|
|
41
41
|
const WARNING_ESTIMATED_FIX_RATE = .8;
|
|
42
42
|
const MAX_KNIP_RETRIES = 5;
|
|
43
|
+
const OXLINT_NODE_REQUIREMENT = "^20.19.0 || >=22.12.0";
|
|
44
|
+
const OXLINT_RECOMMENDED_NODE_MAJOR = 24;
|
|
43
45
|
const AMI_WEBSITE_URL = "https://ami.dev";
|
|
44
46
|
const AMI_INSTALL_URL = `${AMI_WEBSITE_URL}/install.sh`;
|
|
45
47
|
const AMI_RELEASES_URL = "https://github.com/millionco/ami-releases/releases";
|
|
@@ -281,6 +283,7 @@ const combineDiagnostics = (lintDiagnostics, deadCodeDiagnostics, directory, isD
|
|
|
281
283
|
//#region src/utils/find-monorepo-root.ts
|
|
282
284
|
const isMonorepoRoot = (directory) => {
|
|
283
285
|
if (fs.existsSync(path.join(directory, "pnpm-workspace.yaml"))) return true;
|
|
286
|
+
if (fs.existsSync(path.join(directory, "nx.json"))) return true;
|
|
284
287
|
const packageJsonPath = path.join(directory, "package.json");
|
|
285
288
|
if (!fs.existsSync(packageJsonPath)) return false;
|
|
286
289
|
const packageJson = readPackageJson(packageJsonPath);
|
|
@@ -333,7 +336,9 @@ const FRAMEWORK_PACKAGES = {
|
|
|
333
336
|
vite: "vite",
|
|
334
337
|
"react-scripts": "cra",
|
|
335
338
|
"@remix-run/react": "remix",
|
|
336
|
-
gatsby: "gatsby"
|
|
339
|
+
gatsby: "gatsby",
|
|
340
|
+
expo: "expo",
|
|
341
|
+
"react-native": "react-native"
|
|
337
342
|
};
|
|
338
343
|
const FRAMEWORK_DISPLAY_NAMES = {
|
|
339
344
|
nextjs: "Next.js",
|
|
@@ -341,10 +346,34 @@ const FRAMEWORK_DISPLAY_NAMES = {
|
|
|
341
346
|
cra: "Create React App",
|
|
342
347
|
remix: "Remix",
|
|
343
348
|
gatsby: "Gatsby",
|
|
349
|
+
expo: "Expo",
|
|
350
|
+
"react-native": "React Native",
|
|
344
351
|
unknown: "React"
|
|
345
352
|
};
|
|
346
353
|
const formatFrameworkName = (framework) => FRAMEWORK_DISPLAY_NAMES[framework];
|
|
347
|
-
const
|
|
354
|
+
const IGNORED_DIRECTORIES = new Set([
|
|
355
|
+
"node_modules",
|
|
356
|
+
"dist",
|
|
357
|
+
"build",
|
|
358
|
+
"coverage"
|
|
359
|
+
]);
|
|
360
|
+
const countSourceFilesViaFilesystem = (rootDirectory) => {
|
|
361
|
+
let count = 0;
|
|
362
|
+
const stack = [rootDirectory];
|
|
363
|
+
while (stack.length > 0) {
|
|
364
|
+
const currentDirectory = stack.pop();
|
|
365
|
+
const entries = fs.readdirSync(currentDirectory, { withFileTypes: true });
|
|
366
|
+
for (const entry of entries) {
|
|
367
|
+
if (entry.isDirectory()) {
|
|
368
|
+
if (!entry.name.startsWith(".") && !IGNORED_DIRECTORIES.has(entry.name)) stack.push(path.join(currentDirectory, entry.name));
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
if (entry.isFile() && SOURCE_FILE_PATTERN.test(entry.name)) count++;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return count;
|
|
375
|
+
};
|
|
376
|
+
const countSourceFilesViaGit = (rootDirectory) => {
|
|
348
377
|
const result = spawnSync("git", [
|
|
349
378
|
"ls-files",
|
|
350
379
|
"--cached",
|
|
@@ -355,9 +384,10 @@ const countSourceFiles = (rootDirectory) => {
|
|
|
355
384
|
encoding: "utf-8",
|
|
356
385
|
maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
|
|
357
386
|
});
|
|
358
|
-
if (result.error || result.status !== 0) return
|
|
387
|
+
if (result.error || result.status !== 0) return null;
|
|
359
388
|
return result.stdout.split("\n").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath)).length;
|
|
360
389
|
};
|
|
390
|
+
const countSourceFiles = (rootDirectory) => countSourceFilesViaGit(rootDirectory) ?? countSourceFilesViaFilesystem(rootDirectory);
|
|
361
391
|
const collectAllDependencies = (packageJson) => ({
|
|
362
392
|
...packageJson.peerDependencies,
|
|
363
393
|
...packageJson.dependencies,
|
|
@@ -442,14 +472,30 @@ const findReactInWorkspaces = (rootDirectory, packageJson) => {
|
|
|
442
472
|
}
|
|
443
473
|
return result;
|
|
444
474
|
};
|
|
475
|
+
const REACT_DEPENDENCY_NAMES = new Set([
|
|
476
|
+
"react",
|
|
477
|
+
"react-native",
|
|
478
|
+
"next"
|
|
479
|
+
]);
|
|
445
480
|
const hasReactDependency = (packageJson) => {
|
|
446
481
|
const allDependencies = collectAllDependencies(packageJson);
|
|
447
|
-
return Object.keys(allDependencies).some((packageName) =>
|
|
482
|
+
return Object.keys(allDependencies).some((packageName) => REACT_DEPENDENCY_NAMES.has(packageName));
|
|
448
483
|
};
|
|
449
484
|
const discoverReactSubprojects = (rootDirectory) => {
|
|
450
485
|
if (!fs.existsSync(rootDirectory) || !fs.statSync(rootDirectory).isDirectory()) return [];
|
|
451
|
-
const entries = fs.readdirSync(rootDirectory, { withFileTypes: true });
|
|
452
486
|
const packages = [];
|
|
487
|
+
const rootPackageJsonPath = path.join(rootDirectory, "package.json");
|
|
488
|
+
if (fs.existsSync(rootPackageJsonPath)) {
|
|
489
|
+
const rootPackageJson = readPackageJson(rootPackageJsonPath);
|
|
490
|
+
if (hasReactDependency(rootPackageJson)) {
|
|
491
|
+
const name = rootPackageJson.name ?? path.basename(rootDirectory);
|
|
492
|
+
packages.push({
|
|
493
|
+
name,
|
|
494
|
+
directory: rootDirectory
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
const entries = fs.readdirSync(rootDirectory, { withFileTypes: true });
|
|
453
499
|
for (const entry of entries) {
|
|
454
500
|
if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
455
501
|
const subdirectory = path.join(rootDirectory, entry.name);
|
|
@@ -621,14 +667,10 @@ const loadConfig = (rootDirectory) => {
|
|
|
621
667
|
if (fs.existsSync(configFilePath)) try {
|
|
622
668
|
const fileContent = fs.readFileSync(configFilePath, "utf-8");
|
|
623
669
|
const parsed = JSON.parse(fileContent);
|
|
624
|
-
if (
|
|
625
|
-
|
|
626
|
-
return null;
|
|
627
|
-
}
|
|
628
|
-
return parsed;
|
|
670
|
+
if (isPlainObject(parsed)) return parsed;
|
|
671
|
+
console.warn(`Warning: ${CONFIG_FILENAME} must be a JSON object, ignoring.`);
|
|
629
672
|
} catch (error) {
|
|
630
673
|
console.warn(`Warning: Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
|
|
631
|
-
return null;
|
|
632
674
|
}
|
|
633
675
|
const packageJsonPath = path.join(rootDirectory, "package.json");
|
|
634
676
|
if (fs.existsSync(packageJsonPath)) try {
|
|
@@ -641,6 +683,167 @@ const loadConfig = (rootDirectory) => {
|
|
|
641
683
|
return null;
|
|
642
684
|
};
|
|
643
685
|
|
|
686
|
+
//#endregion
|
|
687
|
+
//#region src/utils/should-auto-select-current-choice.ts
|
|
688
|
+
const shouldAutoSelectCurrentChoice = (choiceStates, cursor) => {
|
|
689
|
+
if (choiceStates.some((choiceState) => choiceState.selected)) return false;
|
|
690
|
+
const currentChoice = choiceStates[cursor];
|
|
691
|
+
return Boolean(currentChoice) && !currentChoice.disabled;
|
|
692
|
+
};
|
|
693
|
+
|
|
694
|
+
//#endregion
|
|
695
|
+
//#region src/utils/should-select-all-choices.ts
|
|
696
|
+
const shouldSelectAllChoices = (choiceStates) => {
|
|
697
|
+
return choiceStates.filter((choiceState) => !choiceState.disabled).some((choiceState) => choiceState.selected !== true);
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
//#endregion
|
|
701
|
+
//#region src/utils/prompts.ts
|
|
702
|
+
const require = createRequire(import.meta.url);
|
|
703
|
+
const PROMPTS_MULTISELECT_MODULE_PATH = "prompts/lib/elements/multiselect";
|
|
704
|
+
const PROMPTS_SELECT_MODULE_PATH = "prompts/lib/elements/select";
|
|
705
|
+
let didPatchMultiselectToggleAll = false;
|
|
706
|
+
let didPatchMultiselectSubmit = false;
|
|
707
|
+
let didPatchSelectBanner = false;
|
|
708
|
+
const selectBannerMap = /* @__PURE__ */ new Map();
|
|
709
|
+
const setSelectBanner = (banner, targetIndex) => {
|
|
710
|
+
selectBannerMap.set(targetIndex, banner);
|
|
711
|
+
};
|
|
712
|
+
const clearSelectBanner = () => {
|
|
713
|
+
selectBannerMap.clear();
|
|
714
|
+
};
|
|
715
|
+
const onCancel = () => {
|
|
716
|
+
logger.break();
|
|
717
|
+
logger.log("Cancelled.");
|
|
718
|
+
logger.dim("Run `npx react-doctor@latest --fix` to fix issues.");
|
|
719
|
+
logger.break();
|
|
720
|
+
process.exit(0);
|
|
721
|
+
};
|
|
722
|
+
const patchMultiselectToggleAll = () => {
|
|
723
|
+
if (didPatchMultiselectToggleAll) return;
|
|
724
|
+
didPatchMultiselectToggleAll = true;
|
|
725
|
+
const multiselectPromptConstructor = require(PROMPTS_MULTISELECT_MODULE_PATH);
|
|
726
|
+
multiselectPromptConstructor.prototype.toggleAll = function() {
|
|
727
|
+
const isCurrentChoiceDisabled = Boolean(this.value[this.cursor]?.disabled);
|
|
728
|
+
if (this.maxChoices !== void 0 || isCurrentChoiceDisabled) {
|
|
729
|
+
this.bell();
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
const shouldSelectAllEnabledChoices = shouldSelectAllChoices(this.value);
|
|
733
|
+
for (const choiceState of this.value) {
|
|
734
|
+
if (choiceState.disabled) continue;
|
|
735
|
+
choiceState.selected = shouldSelectAllEnabledChoices;
|
|
736
|
+
}
|
|
737
|
+
this.render();
|
|
738
|
+
};
|
|
739
|
+
};
|
|
740
|
+
const patchMultiselectSubmit = () => {
|
|
741
|
+
if (didPatchMultiselectSubmit) return;
|
|
742
|
+
didPatchMultiselectSubmit = true;
|
|
743
|
+
const multiselectPromptConstructor = require(PROMPTS_MULTISELECT_MODULE_PATH);
|
|
744
|
+
const originalSubmit = multiselectPromptConstructor.prototype.submit;
|
|
745
|
+
multiselectPromptConstructor.prototype.submit = function() {
|
|
746
|
+
if (shouldAutoSelectCurrentChoice(this.value, this.cursor)) this.value[this.cursor].selected = true;
|
|
747
|
+
originalSubmit.call(this);
|
|
748
|
+
};
|
|
749
|
+
};
|
|
750
|
+
const patchSelectBanner = () => {
|
|
751
|
+
if (didPatchSelectBanner) return;
|
|
752
|
+
didPatchSelectBanner = true;
|
|
753
|
+
const selectConstructor = require(PROMPTS_SELECT_MODULE_PATH);
|
|
754
|
+
const promptsClear = require("prompts/lib/util/clear");
|
|
755
|
+
const originalRender = selectConstructor.prototype.render;
|
|
756
|
+
selectConstructor.prototype.render = function() {
|
|
757
|
+
originalRender.call(this);
|
|
758
|
+
const banner = selectBannerMap.get(this.cursor);
|
|
759
|
+
if (!banner || this.closed || this.done) return;
|
|
760
|
+
this.out.write(promptsClear(this.outputText, this.out.columns));
|
|
761
|
+
this.outputText = `${banner}\n\n${this.outputText}`;
|
|
762
|
+
this.out.write(this.outputText);
|
|
763
|
+
};
|
|
764
|
+
};
|
|
765
|
+
const prompts = (questions) => {
|
|
766
|
+
patchMultiselectToggleAll();
|
|
767
|
+
patchMultiselectSubmit();
|
|
768
|
+
patchSelectBanner();
|
|
769
|
+
return basePrompts(questions, { onCancel });
|
|
770
|
+
};
|
|
771
|
+
|
|
772
|
+
//#endregion
|
|
773
|
+
//#region src/utils/resolve-compatible-node.ts
|
|
774
|
+
const parseNodeVersion = (versionString) => {
|
|
775
|
+
const [major = 0, minor = 0, patch = 0] = versionString.replace(/^v/, "").trim().split(".").map(Number);
|
|
776
|
+
return {
|
|
777
|
+
major,
|
|
778
|
+
minor,
|
|
779
|
+
patch
|
|
780
|
+
};
|
|
781
|
+
};
|
|
782
|
+
const isNodeVersionCompatibleWithOxlint = ({ major, minor }) => {
|
|
783
|
+
if (major === 20 && minor >= 19) return true;
|
|
784
|
+
if (major === 22 && minor >= 12) return true;
|
|
785
|
+
if (major > 22) return true;
|
|
786
|
+
return false;
|
|
787
|
+
};
|
|
788
|
+
const isCurrentNodeCompatibleWithOxlint = () => isNodeVersionCompatibleWithOxlint(parseNodeVersion(process.version));
|
|
789
|
+
const getNvmDirectory = () => {
|
|
790
|
+
const envNvmDirectory = process.env.NVM_DIR;
|
|
791
|
+
if (envNvmDirectory && existsSync(envNvmDirectory)) return envNvmDirectory;
|
|
792
|
+
const defaultNvmDirectory = path.join(os.homedir(), ".nvm");
|
|
793
|
+
if (existsSync(defaultNvmDirectory)) return defaultNvmDirectory;
|
|
794
|
+
return null;
|
|
795
|
+
};
|
|
796
|
+
const isNvmInstalled = () => getNvmDirectory() !== null;
|
|
797
|
+
const findCompatibleNvmBinary = () => {
|
|
798
|
+
const nvmDirectory = getNvmDirectory();
|
|
799
|
+
if (!nvmDirectory) return null;
|
|
800
|
+
const versionsDirectory = path.join(nvmDirectory, "versions", "node");
|
|
801
|
+
if (!existsSync(versionsDirectory)) return null;
|
|
802
|
+
const compatibleVersions = readdirSync(versionsDirectory).filter((directoryName) => directoryName.startsWith("v")).map((directoryName) => ({
|
|
803
|
+
directoryName,
|
|
804
|
+
...parseNodeVersion(directoryName)
|
|
805
|
+
})).filter((version) => isNodeVersionCompatibleWithOxlint(version)).sort((versionA, versionB) => versionB.major - versionA.major || versionB.minor - versionA.minor || versionB.patch - versionA.patch);
|
|
806
|
+
if (compatibleVersions.length === 0) return null;
|
|
807
|
+
const bestVersion = compatibleVersions[0];
|
|
808
|
+
const binaryPath = path.join(versionsDirectory, bestVersion.directoryName, "bin", "node");
|
|
809
|
+
return existsSync(binaryPath) ? binaryPath : null;
|
|
810
|
+
};
|
|
811
|
+
const getNodeVersionFromBinary = (binaryPath) => {
|
|
812
|
+
try {
|
|
813
|
+
return execSync(`"${binaryPath}" --version`, { encoding: "utf-8" }).trim();
|
|
814
|
+
} catch {
|
|
815
|
+
return null;
|
|
816
|
+
}
|
|
817
|
+
};
|
|
818
|
+
const installNodeViaNvm = () => {
|
|
819
|
+
const nvmDirectory = getNvmDirectory();
|
|
820
|
+
if (!nvmDirectory) return false;
|
|
821
|
+
const nvmScript = path.join(nvmDirectory, "nvm.sh");
|
|
822
|
+
if (!existsSync(nvmScript)) return false;
|
|
823
|
+
try {
|
|
824
|
+
execSync(`bash -c ". '${nvmScript}' && nvm install ${OXLINT_RECOMMENDED_NODE_MAJOR}"`, { stdio: "inherit" });
|
|
825
|
+
return findCompatibleNvmBinary() !== null;
|
|
826
|
+
} catch {
|
|
827
|
+
return false;
|
|
828
|
+
}
|
|
829
|
+
};
|
|
830
|
+
const resolveNodeForOxlint = () => {
|
|
831
|
+
if (isCurrentNodeCompatibleWithOxlint()) return {
|
|
832
|
+
binaryPath: process.execPath,
|
|
833
|
+
isCurrentNode: true,
|
|
834
|
+
version: process.version
|
|
835
|
+
};
|
|
836
|
+
const nvmBinaryPath = findCompatibleNvmBinary();
|
|
837
|
+
if (!nvmBinaryPath) return null;
|
|
838
|
+
const version = getNodeVersionFromBinary(nvmBinaryPath);
|
|
839
|
+
if (!version) return null;
|
|
840
|
+
return {
|
|
841
|
+
binaryPath: nvmBinaryPath,
|
|
842
|
+
isCurrentNode: false,
|
|
843
|
+
version
|
|
844
|
+
};
|
|
845
|
+
};
|
|
846
|
+
|
|
644
847
|
//#endregion
|
|
645
848
|
//#region src/utils/run-knip.ts
|
|
646
849
|
const KNIP_CATEGORY_MAP = {
|
|
@@ -699,11 +902,15 @@ const CONFIG_LOADING_ERROR_PATTERN = /Error loading .*\/([a-z-]+)\.config\./;
|
|
|
699
902
|
const extractFailedPluginName = (error) => {
|
|
700
903
|
return String(error).match(CONFIG_LOADING_ERROR_PATTERN)?.[1] ?? null;
|
|
701
904
|
};
|
|
905
|
+
const TSCONFIG_FILENAMES = ["tsconfig.base.json", "tsconfig.json"];
|
|
906
|
+
const resolveTsConfigFile = (directory) => TSCONFIG_FILENAMES.find((filename) => fs.existsSync(path.join(directory, filename)));
|
|
702
907
|
const runKnipWithOptions = async (knipCwd, workspaceName) => {
|
|
908
|
+
const tsConfigFile = resolveTsConfigFile(knipCwd);
|
|
703
909
|
const options = await silenced(() => createOptions({
|
|
704
910
|
cwd: knipCwd,
|
|
705
911
|
isShowProgress: false,
|
|
706
|
-
...workspaceName ? { workspace: workspaceName } : {}
|
|
912
|
+
...workspaceName ? { workspace: workspaceName } : {},
|
|
913
|
+
...tsConfigFile ? { tsConfigFile } : {}
|
|
707
914
|
}));
|
|
708
915
|
const parsedConfig = options.parsedConfig;
|
|
709
916
|
for (let attempt = 0; attempt <= MAX_KNIP_RETRIES; attempt++) try {
|
|
@@ -775,6 +982,16 @@ const NEXTJS_RULES = {
|
|
|
775
982
|
"react-doctor/nextjs-no-head-import": "error",
|
|
776
983
|
"react-doctor/nextjs-no-side-effect-in-get-handler": "error"
|
|
777
984
|
};
|
|
985
|
+
const REACT_NATIVE_RULES = {
|
|
986
|
+
"react-doctor/rn-no-raw-text": "error",
|
|
987
|
+
"react-doctor/rn-no-deprecated-modules": "error",
|
|
988
|
+
"react-doctor/rn-no-legacy-expo-packages": "warn",
|
|
989
|
+
"react-doctor/rn-no-dimensions-get": "warn",
|
|
990
|
+
"react-doctor/rn-no-inline-flatlist-renderitem": "warn",
|
|
991
|
+
"react-doctor/rn-no-legacy-shadow-styles": "warn",
|
|
992
|
+
"react-doctor/rn-prefer-reanimated": "warn",
|
|
993
|
+
"react-doctor/rn-no-single-element-style-array": "warn"
|
|
994
|
+
};
|
|
778
995
|
const REACT_COMPILER_RULES = {
|
|
779
996
|
"react-hooks-js/set-state-in-render": "error",
|
|
780
997
|
"react-hooks-js/immutability": "error",
|
|
@@ -878,7 +1095,8 @@ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler }) => ({
|
|
|
878
1095
|
"react-doctor/server-after-nonblocking": "warn",
|
|
879
1096
|
"react-doctor/client-passive-event-listeners": "warn",
|
|
880
1097
|
"react-doctor/async-parallel": "warn",
|
|
881
|
-
...framework === "nextjs" ? NEXTJS_RULES : {}
|
|
1098
|
+
...framework === "nextjs" ? NEXTJS_RULES : {},
|
|
1099
|
+
...framework === "expo" || framework === "react-native" ? REACT_NATIVE_RULES : {}
|
|
882
1100
|
}
|
|
883
1101
|
});
|
|
884
1102
|
|
|
@@ -986,7 +1204,15 @@ const RULE_CATEGORY_MAP = {
|
|
|
986
1204
|
"react-doctor/server-auth-actions": "Server",
|
|
987
1205
|
"react-doctor/server-after-nonblocking": "Server",
|
|
988
1206
|
"react-doctor/client-passive-event-listeners": "Performance",
|
|
989
|
-
"react-doctor/async-parallel": "Performance"
|
|
1207
|
+
"react-doctor/async-parallel": "Performance",
|
|
1208
|
+
"react-doctor/rn-no-raw-text": "React Native",
|
|
1209
|
+
"react-doctor/rn-no-deprecated-modules": "React Native",
|
|
1210
|
+
"react-doctor/rn-no-legacy-expo-packages": "React Native",
|
|
1211
|
+
"react-doctor/rn-no-dimensions-get": "React Native",
|
|
1212
|
+
"react-doctor/rn-no-inline-flatlist-renderitem": "React Native",
|
|
1213
|
+
"react-doctor/rn-no-legacy-shadow-styles": "React Native",
|
|
1214
|
+
"react-doctor/rn-prefer-reanimated": "React Native",
|
|
1215
|
+
"react-doctor/rn-no-single-element-style-array": "React Native"
|
|
990
1216
|
};
|
|
991
1217
|
const RULE_HELP_MAP = {
|
|
992
1218
|
"no-derived-state-effect": "For derived state, compute inline: `const x = fn(dep)`. For state resets on prop change, use a key prop: `<Component key={prop} />`",
|
|
@@ -1042,7 +1268,15 @@ const RULE_HELP_MAP = {
|
|
|
1042
1268
|
"server-auth-actions": "Add `const session = await auth()` at the top and throw/redirect if unauthorized before any data access",
|
|
1043
1269
|
"server-after-nonblocking": "`import { after } from 'next/server'` then wrap: `after(() => analytics.track(...))` — response isn't blocked",
|
|
1044
1270
|
"client-passive-event-listeners": "Add `{ passive: true }` as the third argument: `addEventListener('scroll', handler, { passive: true })`",
|
|
1045
|
-
"async-parallel": "Use `const [a, b] = await Promise.all([fetchA(), fetchB()])` to run independent operations concurrently"
|
|
1271
|
+
"async-parallel": "Use `const [a, b] = await Promise.all([fetchA(), fetchB()])` to run independent operations concurrently",
|
|
1272
|
+
"rn-no-raw-text": "Wrap text in a `<Text>` component: `<Text>{value}</Text>` — raw strings outside `<Text>` crash on React Native",
|
|
1273
|
+
"rn-no-deprecated-modules": "Import from the community package instead — deprecated modules were removed from the react-native core",
|
|
1274
|
+
"rn-no-legacy-expo-packages": "Migrate to the recommended replacement package — legacy Expo packages are no longer maintained",
|
|
1275
|
+
"rn-no-dimensions-get": "Use `const { width, height } = useWindowDimensions()` — it updates reactively on rotation and resize",
|
|
1276
|
+
"rn-no-inline-flatlist-renderitem": "Extract renderItem to a named function or wrap in useCallback to avoid re-creating on every render",
|
|
1277
|
+
"rn-no-legacy-shadow-styles": "Use `boxShadow` for cross-platform shadows on the new architecture instead of platform-specific shadow properties",
|
|
1278
|
+
"rn-prefer-reanimated": "Use `import Animated from 'react-native-reanimated'` — animations run on the UI thread instead of the JS thread",
|
|
1279
|
+
"rn-no-single-element-style-array": "Use `style={value}` instead of `style={[value]}` — single-element arrays add unnecessary allocation"
|
|
1046
1280
|
};
|
|
1047
1281
|
const FILEPATH_WITH_LOCATION_PATTERN = /\S+\.\w+:\d+:\d+[\s\S]*$/;
|
|
1048
1282
|
const REACT_COMPILER_MESSAGE = "React Compiler can't optimize this code";
|
|
@@ -1102,8 +1336,8 @@ const batchIncludePaths = (baseArgs, includePaths) => {
|
|
|
1102
1336
|
if (currentBatch.length > 0) batches.push(currentBatch);
|
|
1103
1337
|
return batches;
|
|
1104
1338
|
};
|
|
1105
|
-
const spawnOxlint = (args, rootDirectory) => new Promise((resolve, reject) => {
|
|
1106
|
-
const child = spawn(
|
|
1339
|
+
const spawnOxlint = (args, rootDirectory, nodeBinaryPath) => new Promise((resolve, reject) => {
|
|
1340
|
+
const child = spawn(nodeBinaryPath, args, { cwd: rootDirectory });
|
|
1107
1341
|
const stdoutBuffers = [];
|
|
1108
1342
|
const stderrBuffers = [];
|
|
1109
1343
|
child.stdout.on("data", (buffer) => stdoutBuffers.push(buffer));
|
|
@@ -1146,7 +1380,7 @@ const parseOxlintOutput = (stdout) => {
|
|
|
1146
1380
|
};
|
|
1147
1381
|
});
|
|
1148
1382
|
};
|
|
1149
|
-
const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompiler, includePaths) => {
|
|
1383
|
+
const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompiler, includePaths, nodeBinaryPath = process.execPath) => {
|
|
1150
1384
|
if (includePaths !== void 0 && includePaths.length === 0) return [];
|
|
1151
1385
|
const configPath = path.join(os.tmpdir(), `react-doctor-oxlintrc-${process.pid}.json`);
|
|
1152
1386
|
const config = createOxlintConfig({
|
|
@@ -1168,7 +1402,7 @@ const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompil
|
|
|
1168
1402
|
const fileBatches = includePaths !== void 0 ? batchIncludePaths(baseArgs, includePaths) : [["."]];
|
|
1169
1403
|
const allDiagnostics = [];
|
|
1170
1404
|
for (const batch of fileBatches) {
|
|
1171
|
-
const stdout = await spawnOxlint([...baseArgs, ...batch], rootDirectory);
|
|
1405
|
+
const stdout = await spawnOxlint([...baseArgs, ...batch], rootDirectory, nodeBinaryPath);
|
|
1172
1406
|
allDiagnostics.push(...parseOxlintOutput(stdout));
|
|
1173
1407
|
}
|
|
1174
1408
|
return allDiagnostics;
|
|
@@ -1393,6 +1627,44 @@ const printSummary = (diagnostics, elapsedMilliseconds, scoreResult, projectName
|
|
|
1393
1627
|
logger.break();
|
|
1394
1628
|
logger.dim(` Share your results: ${highlighter.info(shareUrl)}`);
|
|
1395
1629
|
};
|
|
1630
|
+
const resolveOxlintNode = async (isLintEnabled, isScoreOnly) => {
|
|
1631
|
+
if (!isLintEnabled) return null;
|
|
1632
|
+
const nodeResolution = resolveNodeForOxlint();
|
|
1633
|
+
if (nodeResolution) {
|
|
1634
|
+
if (!nodeResolution.isCurrentNode && !isScoreOnly) {
|
|
1635
|
+
logger.warn(`Node ${process.version} is unsupported by oxlint. Using Node ${nodeResolution.version} from nvm.`);
|
|
1636
|
+
logger.break();
|
|
1637
|
+
}
|
|
1638
|
+
return nodeResolution.binaryPath;
|
|
1639
|
+
}
|
|
1640
|
+
if (isScoreOnly) return null;
|
|
1641
|
+
logger.warn(`Node ${process.version} is not compatible with oxlint (requires ${OXLINT_NODE_REQUIREMENT}). Lint checks will be skipped.`);
|
|
1642
|
+
if (isNvmInstalled() && process.stdin.isTTY) {
|
|
1643
|
+
const { shouldInstallNode } = await prompts({
|
|
1644
|
+
type: "confirm",
|
|
1645
|
+
name: "shouldInstallNode",
|
|
1646
|
+
message: `Install Node ${OXLINT_RECOMMENDED_NODE_MAJOR} via nvm to enable lint checks?`,
|
|
1647
|
+
initial: true
|
|
1648
|
+
});
|
|
1649
|
+
if (shouldInstallNode) {
|
|
1650
|
+
logger.break();
|
|
1651
|
+
const freshResolution = installNodeViaNvm() ? resolveNodeForOxlint() : null;
|
|
1652
|
+
if (freshResolution) {
|
|
1653
|
+
logger.break();
|
|
1654
|
+
logger.success(`Node ${freshResolution.version} installed. Using it for lint checks.`);
|
|
1655
|
+
logger.break();
|
|
1656
|
+
return freshResolution.binaryPath;
|
|
1657
|
+
}
|
|
1658
|
+
logger.break();
|
|
1659
|
+
logger.warn("Failed to install Node via nvm. Skipping lint checks.");
|
|
1660
|
+
logger.break();
|
|
1661
|
+
return null;
|
|
1662
|
+
}
|
|
1663
|
+
} else if (isNvmInstalled()) logger.dim(` Run: nvm install ${OXLINT_RECOMMENDED_NODE_MAJOR}`);
|
|
1664
|
+
else logger.dim(` Install nvm (https://github.com/nvm-sh/nvm) and run: nvm install ${OXLINT_RECOMMENDED_NODE_MAJOR}`);
|
|
1665
|
+
logger.break();
|
|
1666
|
+
return null;
|
|
1667
|
+
};
|
|
1396
1668
|
const mergeScanOptions = (inputOptions, userConfig) => ({
|
|
1397
1669
|
lint: inputOptions.lint ?? userConfig?.lint ?? true,
|
|
1398
1670
|
deadCode: inputOptions.deadCode ?? userConfig?.deadCode ?? true,
|
|
@@ -1428,19 +1700,26 @@ const scan = async (directory, inputOptions = {}) => {
|
|
|
1428
1700
|
const jsxIncludePaths = computeJsxIncludePaths(includePaths);
|
|
1429
1701
|
let didLintFail = false;
|
|
1430
1702
|
let didDeadCodeFail = false;
|
|
1431
|
-
const
|
|
1703
|
+
const resolvedNodeBinaryPath = await resolveOxlintNode(options.lint, options.scoreOnly);
|
|
1704
|
+
if (options.lint && !resolvedNodeBinaryPath) didLintFail = true;
|
|
1705
|
+
const lintPromise = resolvedNodeBinaryPath ? (async () => {
|
|
1432
1706
|
const lintSpinner = options.scoreOnly ? null : spinner("Running lint checks...").start();
|
|
1433
1707
|
try {
|
|
1434
|
-
const lintDiagnostics = await runOxlint(directory, projectInfo.hasTypeScript, projectInfo.framework, projectInfo.hasReactCompiler, jsxIncludePaths);
|
|
1708
|
+
const lintDiagnostics = await runOxlint(directory, projectInfo.hasTypeScript, projectInfo.framework, projectInfo.hasReactCompiler, jsxIncludePaths, resolvedNodeBinaryPath);
|
|
1435
1709
|
lintSpinner?.succeed("Running lint checks.");
|
|
1436
1710
|
return lintDiagnostics;
|
|
1437
1711
|
} catch (error) {
|
|
1438
1712
|
didLintFail = true;
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1713
|
+
if (!options.scoreOnly) {
|
|
1714
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1715
|
+
if (errorMessage.includes("native binding")) {
|
|
1716
|
+
lintSpinner?.fail(`Lint checks failed — oxlint native binding not found (Node ${process.version}).`);
|
|
1717
|
+
logger.dim(` Upgrade to Node ${OXLINT_NODE_REQUIREMENT} or run: npx -p oxlint@latest react-doctor@latest`);
|
|
1718
|
+
} else {
|
|
1719
|
+
lintSpinner?.fail("Lint checks failed (non-fatal, skipping).");
|
|
1720
|
+
logger.error(errorMessage);
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1444
1723
|
return [];
|
|
1445
1724
|
}
|
|
1446
1725
|
})() : Promise.resolve([]);
|
|
@@ -1452,8 +1731,10 @@ const scan = async (directory, inputOptions = {}) => {
|
|
|
1452
1731
|
return knipDiagnostics;
|
|
1453
1732
|
} catch (error) {
|
|
1454
1733
|
didDeadCodeFail = true;
|
|
1455
|
-
|
|
1456
|
-
|
|
1734
|
+
if (!options.scoreOnly) {
|
|
1735
|
+
deadCodeSpinner?.fail("Dead code detection failed (non-fatal, skipping).");
|
|
1736
|
+
logger.error(String(error));
|
|
1737
|
+
}
|
|
1457
1738
|
return [];
|
|
1458
1739
|
}
|
|
1459
1740
|
})() : Promise.resolve([]);
|
|
@@ -1603,92 +1884,6 @@ const handleError = (error, options = DEFAULT_HANDLE_ERROR_OPTIONS) => {
|
|
|
1603
1884
|
process.exitCode = 1;
|
|
1604
1885
|
};
|
|
1605
1886
|
|
|
1606
|
-
//#endregion
|
|
1607
|
-
//#region src/utils/should-auto-select-current-choice.ts
|
|
1608
|
-
const shouldAutoSelectCurrentChoice = (choiceStates, cursor) => {
|
|
1609
|
-
if (choiceStates.some((choiceState) => choiceState.selected)) return false;
|
|
1610
|
-
const currentChoice = choiceStates[cursor];
|
|
1611
|
-
return Boolean(currentChoice) && !currentChoice.disabled;
|
|
1612
|
-
};
|
|
1613
|
-
|
|
1614
|
-
//#endregion
|
|
1615
|
-
//#region src/utils/should-select-all-choices.ts
|
|
1616
|
-
const shouldSelectAllChoices = (choiceStates) => {
|
|
1617
|
-
return choiceStates.filter((choiceState) => !choiceState.disabled).some((choiceState) => choiceState.selected !== true);
|
|
1618
|
-
};
|
|
1619
|
-
|
|
1620
|
-
//#endregion
|
|
1621
|
-
//#region src/utils/prompts.ts
|
|
1622
|
-
const require = createRequire(import.meta.url);
|
|
1623
|
-
const PROMPTS_MULTISELECT_MODULE_PATH = "prompts/lib/elements/multiselect";
|
|
1624
|
-
const PROMPTS_SELECT_MODULE_PATH = "prompts/lib/elements/select";
|
|
1625
|
-
let didPatchMultiselectToggleAll = false;
|
|
1626
|
-
let didPatchMultiselectSubmit = false;
|
|
1627
|
-
let didPatchSelectBanner = false;
|
|
1628
|
-
const selectBannerMap = /* @__PURE__ */ new Map();
|
|
1629
|
-
const setSelectBanner = (banner, targetIndex) => {
|
|
1630
|
-
selectBannerMap.set(targetIndex, banner);
|
|
1631
|
-
};
|
|
1632
|
-
const clearSelectBanner = () => {
|
|
1633
|
-
selectBannerMap.clear();
|
|
1634
|
-
};
|
|
1635
|
-
const onCancel = () => {
|
|
1636
|
-
logger.break();
|
|
1637
|
-
logger.log("Cancelled.");
|
|
1638
|
-
logger.dim("Run `npx react-doctor@latest --fix` to fix issues.");
|
|
1639
|
-
logger.break();
|
|
1640
|
-
process.exit(0);
|
|
1641
|
-
};
|
|
1642
|
-
const patchMultiselectToggleAll = () => {
|
|
1643
|
-
if (didPatchMultiselectToggleAll) return;
|
|
1644
|
-
didPatchMultiselectToggleAll = true;
|
|
1645
|
-
const multiselectPromptConstructor = require(PROMPTS_MULTISELECT_MODULE_PATH);
|
|
1646
|
-
multiselectPromptConstructor.prototype.toggleAll = function() {
|
|
1647
|
-
const isCurrentChoiceDisabled = Boolean(this.value[this.cursor]?.disabled);
|
|
1648
|
-
if (this.maxChoices !== void 0 || isCurrentChoiceDisabled) {
|
|
1649
|
-
this.bell();
|
|
1650
|
-
return;
|
|
1651
|
-
}
|
|
1652
|
-
const shouldSelectAllEnabledChoices = shouldSelectAllChoices(this.value);
|
|
1653
|
-
for (const choiceState of this.value) {
|
|
1654
|
-
if (choiceState.disabled) continue;
|
|
1655
|
-
choiceState.selected = shouldSelectAllEnabledChoices;
|
|
1656
|
-
}
|
|
1657
|
-
this.render();
|
|
1658
|
-
};
|
|
1659
|
-
};
|
|
1660
|
-
const patchMultiselectSubmit = () => {
|
|
1661
|
-
if (didPatchMultiselectSubmit) return;
|
|
1662
|
-
didPatchMultiselectSubmit = true;
|
|
1663
|
-
const multiselectPromptConstructor = require(PROMPTS_MULTISELECT_MODULE_PATH);
|
|
1664
|
-
const originalSubmit = multiselectPromptConstructor.prototype.submit;
|
|
1665
|
-
multiselectPromptConstructor.prototype.submit = function() {
|
|
1666
|
-
if (shouldAutoSelectCurrentChoice(this.value, this.cursor)) this.value[this.cursor].selected = true;
|
|
1667
|
-
originalSubmit.call(this);
|
|
1668
|
-
};
|
|
1669
|
-
};
|
|
1670
|
-
const patchSelectBanner = () => {
|
|
1671
|
-
if (didPatchSelectBanner) return;
|
|
1672
|
-
didPatchSelectBanner = true;
|
|
1673
|
-
const selectConstructor = require(PROMPTS_SELECT_MODULE_PATH);
|
|
1674
|
-
const promptsClear = require("prompts/lib/util/clear");
|
|
1675
|
-
const originalRender = selectConstructor.prototype.render;
|
|
1676
|
-
selectConstructor.prototype.render = function() {
|
|
1677
|
-
originalRender.call(this);
|
|
1678
|
-
const banner = selectBannerMap.get(this.cursor);
|
|
1679
|
-
if (!banner || this.closed || this.done) return;
|
|
1680
|
-
this.out.write(promptsClear(this.outputText, this.out.columns));
|
|
1681
|
-
this.outputText = `${banner}\n\n${this.outputText}`;
|
|
1682
|
-
this.out.write(this.outputText);
|
|
1683
|
-
};
|
|
1684
|
-
};
|
|
1685
|
-
const prompts = (questions) => {
|
|
1686
|
-
patchMultiselectToggleAll();
|
|
1687
|
-
patchMultiselectSubmit();
|
|
1688
|
-
patchSelectBanner();
|
|
1689
|
-
return basePrompts(questions, { onCancel });
|
|
1690
|
-
};
|
|
1691
|
-
|
|
1692
1887
|
//#endregion
|
|
1693
1888
|
//#region src/utils/select-projects.ts
|
|
1694
1889
|
const selectProjects = async (rootDirectory, projectFlag, skipPrompts) => {
|
|
@@ -1909,7 +2104,18 @@ const maybePromptSkillInstall = async (shouldSkipPrompts) => {
|
|
|
1909
2104
|
|
|
1910
2105
|
//#endregion
|
|
1911
2106
|
//#region src/cli.ts
|
|
1912
|
-
const VERSION = "0.0.
|
|
2107
|
+
const VERSION = "0.0.29";
|
|
2108
|
+
const VALID_FAIL_ON_LEVELS = new Set([
|
|
2109
|
+
"error",
|
|
2110
|
+
"warning",
|
|
2111
|
+
"none"
|
|
2112
|
+
]);
|
|
2113
|
+
const isValidFailOnLevel = (level) => VALID_FAIL_ON_LEVELS.has(level);
|
|
2114
|
+
const shouldFailForDiagnostics = (diagnostics, failOnLevel) => {
|
|
2115
|
+
if (failOnLevel === "none") return false;
|
|
2116
|
+
if (failOnLevel === "warning") return diagnostics.length > 0;
|
|
2117
|
+
return diagnostics.some((diagnostic) => diagnostic.severity === "error");
|
|
2118
|
+
};
|
|
1913
2119
|
const exitWithFixHint = () => {
|
|
1914
2120
|
logger.break();
|
|
1915
2121
|
logger.log("Cancelled.");
|
|
@@ -1932,8 +2138,8 @@ const isAutomatedEnvironment = () => AUTOMATED_ENVIRONMENT_VARIABLES.some((envVa
|
|
|
1932
2138
|
const resolveCliScanOptions = (flags, userConfig, programInstance) => {
|
|
1933
2139
|
const isCliOverride = (optionName) => programInstance.getOptionValueSource(optionName) === "cli";
|
|
1934
2140
|
return {
|
|
1935
|
-
lint: isCliOverride("lint") ? flags.lint : userConfig?.lint ??
|
|
1936
|
-
deadCode: isCliOverride("deadCode") ? flags.deadCode : userConfig?.deadCode ??
|
|
2141
|
+
lint: isCliOverride("lint") ? flags.lint : userConfig?.lint ?? true,
|
|
2142
|
+
deadCode: isCliOverride("deadCode") ? flags.deadCode : userConfig?.deadCode ?? true,
|
|
1937
2143
|
verbose: isCliOverride("verbose") ? Boolean(flags.verbose) : userConfig?.verbose ?? false,
|
|
1938
2144
|
scoreOnly: flags.score,
|
|
1939
2145
|
offline: flags.offline
|
|
@@ -1961,7 +2167,7 @@ const resolveDiffMode = async (diffInfo, effectiveDiff, shouldSkipPrompts, isSco
|
|
|
1961
2167
|
});
|
|
1962
2168
|
return Boolean(shouldScanChangedOnly);
|
|
1963
2169
|
};
|
|
1964
|
-
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("--diff [base]", "scan only files changed vs base branch").option("--offline", "skip telemetry (anonymous, not stored, only used to calculate score)").option("--no-ami", "skip Ami-related prompts").option("--fix", "open Ami to auto-fix all issues").action(async (directory, flags) => {
|
|
2170
|
+
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("--lint", "enable linting").option("--no-lint", "skip linting").option("--dead-code", "enable dead code detection").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("--diff [base]", "scan only files changed vs base branch").option("--offline", "skip telemetry (anonymous, not stored, only used to calculate score)").option("--no-ami", "skip Ami-related prompts").option("--fail-on <level>", "exit with error code on diagnostics: error, warning, none", "none").option("--fix", "open Ami to auto-fix all issues").action(async (directory, flags) => {
|
|
1965
2171
|
const isScoreOnly = flags.score;
|
|
1966
2172
|
try {
|
|
1967
2173
|
const resolvedDirectory = path.resolve(directory);
|
|
@@ -2011,6 +2217,8 @@ const program = new Command().name("react-doctor").description("Diagnose React c
|
|
|
2011
2217
|
allDiagnostics.push(...scanResult.diagnostics);
|
|
2012
2218
|
if (!isScoreOnly) logger.break();
|
|
2013
2219
|
}
|
|
2220
|
+
const resolvedFailOn = program.getOptionValueSource("failOn") === "cli" ? flags.failOn : userConfig?.failOn ?? flags.failOn;
|
|
2221
|
+
if (shouldFailForDiagnostics(allDiagnostics, isValidFailOnLevel(resolvedFailOn) ? resolvedFailOn : "none")) process.exitCode = 1;
|
|
2014
2222
|
if (flags.fix) openAmiToFix(resolvedDirectory);
|
|
2015
2223
|
if (!isScoreOnly && !shouldSkipAmiPrompts && !flags.fix) {
|
|
2016
2224
|
await maybePromptSkillInstall(shouldSkipAmiPrompts);
|
|
@@ -2073,7 +2281,11 @@ const openAmiToFix = (directory) => {
|
|
|
2073
2281
|
if (!isInstalled) {
|
|
2074
2282
|
if (process.platform === "darwin") {
|
|
2075
2283
|
installAmi();
|
|
2076
|
-
logger.success("Ami installed successfully.");
|
|
2284
|
+
if (isAmiInstalled()) logger.success("Ami installed successfully.");
|
|
2285
|
+
else {
|
|
2286
|
+
logger.error("Installation could not be verified.");
|
|
2287
|
+
logger.dim(`Install manually at ${highlighter.info(AMI_WEBSITE_URL)}`);
|
|
2288
|
+
}
|
|
2077
2289
|
} else {
|
|
2078
2290
|
logger.error("Ami is not installed.");
|
|
2079
2291
|
logger.dim(`Download at ${highlighter.info(AMI_RELEASES_URL)}`);
|