77 MILLISECONDS_PER_SECOND ,
88 OFFLINE_FLAG_MESSAGE ,
99 OFFLINE_MESSAGE ,
10+ OXLINT_NODE_REQUIREMENT ,
11+ OXLINT_RECOMMENDED_NODE_MAJOR ,
1012 PERFECT_SCORE ,
1113 SCORE_BAR_WIDTH_CHARS ,
1214 SCORE_GOOD_THRESHOLD ,
@@ -31,6 +33,12 @@ import { highlighter } from "./utils/highlighter.js";
3133import { indentMultilineText } from "./utils/indent-multiline-text.js" ;
3234import { loadConfig } from "./utils/load-config.js" ;
3335import { logger } from "./utils/logger.js" ;
36+ import { prompts } from "./utils/prompts.js" ;
37+ import {
38+ installNodeViaNvm ,
39+ isNvmInstalled ,
40+ resolveNodeForOxlint ,
41+ } from "./utils/resolve-compatible-node.js" ;
3442import { runKnip } from "./utils/run-knip.js" ;
3543import { runOxlint } from "./utils/run-oxlint.js" ;
3644import { spinner } from "./utils/spinner.js" ;
@@ -331,6 +339,64 @@ const printSummary = (
331339 logger . dim ( ` Share your results: ${ highlighter . info ( shareUrl ) } ` ) ;
332340} ;
333341
342+ const resolveOxlintNode = async (
343+ isLintEnabled : boolean ,
344+ isScoreOnly : boolean ,
345+ ) : Promise < string | null > => {
346+ if ( ! isLintEnabled ) return null ;
347+
348+ const nodeResolution = resolveNodeForOxlint ( ) ;
349+
350+ if ( nodeResolution ) {
351+ if ( ! nodeResolution . isCurrentNode && ! isScoreOnly ) {
352+ logger . warn (
353+ `Node ${ process . version } is unsupported by oxlint. Using Node ${ nodeResolution . version } from nvm.` ,
354+ ) ;
355+ logger . break ( ) ;
356+ }
357+ return nodeResolution . binaryPath ;
358+ }
359+
360+ if ( isScoreOnly ) return null ;
361+
362+ logger . warn (
363+ `Node ${ process . version } is not compatible with oxlint (requires ${ OXLINT_NODE_REQUIREMENT } ). Lint checks will be skipped.` ,
364+ ) ;
365+
366+ if ( isNvmInstalled ( ) && process . stdin . isTTY ) {
367+ const { shouldInstallNode } = await prompts ( {
368+ type : "confirm" ,
369+ name : "shouldInstallNode" ,
370+ message : `Install Node ${ OXLINT_RECOMMENDED_NODE_MAJOR } via nvm to enable lint checks?` ,
371+ initial : true ,
372+ } ) ;
373+
374+ if ( shouldInstallNode ) {
375+ logger . break ( ) ;
376+ const freshResolution = installNodeViaNvm ( ) ? resolveNodeForOxlint ( ) : null ;
377+ if ( freshResolution ) {
378+ logger . break ( ) ;
379+ logger . success ( `Node ${ freshResolution . version } installed. Using it for lint checks.` ) ;
380+ logger . break ( ) ;
381+ return freshResolution . binaryPath ;
382+ }
383+ logger . break ( ) ;
384+ logger . warn ( "Failed to install Node via nvm. Skipping lint checks." ) ;
385+ logger . break ( ) ;
386+ return null ;
387+ }
388+ } else if ( isNvmInstalled ( ) ) {
389+ logger . dim ( ` Run: nvm install ${ OXLINT_RECOMMENDED_NODE_MAJOR } ` ) ;
390+ } else {
391+ logger . dim (
392+ ` Install nvm (https://github.com/nvm-sh/nvm) and run: nvm install ${ OXLINT_RECOMMENDED_NODE_MAJOR } ` ,
393+ ) ;
394+ }
395+
396+ logger . break ( ) ;
397+ return null ;
398+ } ;
399+
334400interface ResolvedScanOptions {
335401 lint : boolean ;
336402 deadCode : boolean ;
@@ -411,7 +477,10 @@ export const scan = async (
411477 let didLintFail = false ;
412478 let didDeadCodeFail = false ;
413479
414- const lintPromise = options . lint
480+ const resolvedNodeBinaryPath = await resolveOxlintNode ( options . lint , options . scoreOnly ) ;
481+ if ( options . lint && ! resolvedNodeBinaryPath ) didLintFail = true ;
482+
483+ const lintPromise = resolvedNodeBinaryPath
415484 ? ( async ( ) => {
416485 const lintSpinner = options . scoreOnly ? null : spinner ( "Running lint checks..." ) . start ( ) ;
417486 try {
@@ -421,19 +490,25 @@ export const scan = async (
421490 projectInfo . framework ,
422491 projectInfo . hasReactCompiler ,
423492 jsxIncludePaths ,
493+ resolvedNodeBinaryPath ,
424494 ) ;
425495 lintSpinner ?. succeed ( "Running lint checks." ) ;
426496 return lintDiagnostics ;
427497 } catch ( error ) {
428498 didLintFail = true ;
429- lintSpinner ?. fail ( "Lint checks failed (non-fatal, skipping)." ) ;
430- if ( error instanceof Error ) {
431- logger . error ( error . message ) ;
432- if ( error . stack ) {
433- logger . dim ( error . stack ) ;
434- }
499+ const errorMessage = error instanceof Error ? error . message : String ( error ) ;
500+ const isNativeBindingError = errorMessage . includes ( "native binding" ) ;
501+
502+ if ( isNativeBindingError ) {
503+ lintSpinner ?. fail (
504+ `Lint checks failed — oxlint native binding not found (Node ${ process . version } ).` ,
505+ ) ;
506+ logger . dim (
507+ ` Upgrade to Node ${ OXLINT_NODE_REQUIREMENT } or run: npx -p oxlint@latest react-doctor@latest` ,
508+ ) ;
435509 } else {
436- logger . error ( String ( error ) ) ;
510+ lintSpinner ?. fail ( "Lint checks failed (non-fatal, skipping)." ) ;
511+ logger . error ( errorMessage ) ;
437512 }
438513 return [ ] ;
439514 }
0 commit comments