@@ -2,6 +2,110 @@ use std::path::Path;
22
33use napi:: { anyhow, bindgen_prelude:: * } ;
44use napi_derive:: napi;
5+ use node_semver:: { Range , Version } ;
6+ use vite_js_runtime:: NodeProvider ;
7+
8+ /// Compute the semver requirement that selects the latest release of the same
9+ /// major as `current`.
10+ ///
11+ /// `supported_range` is the Vite+-supported Node.js range, sourced from the
12+ /// `engines.node` field in `package.json` (e.g.
13+ /// `^20.19.0 || ^22.18.0 || >=24.11.0`). It is the only source of truth: there
14+ /// are no hardcoded per-major floors here.
15+ ///
16+ /// Returns `None` when:
17+ /// - `current` is not an exact semver version (a leading `v` is tolerated), or
18+ /// - `current` already satisfies `supported_range` (no upgrade needed).
19+ ///
20+ /// Otherwise returns a constrained range like `>=24.0.0 <25.0.0` that, when
21+ /// resolved against the Node.js release index, yields the latest release of that
22+ /// major. The resolved version is verified against `supported_range` separately,
23+ /// which is what rejects unsupported majors (e.g. 21 or 23).
24+ fn supported_node_requirement ( current : & str , supported_range : & Range ) -> Option < String > {
25+ let normalized = current. strip_prefix ( 'v' ) . unwrap_or ( current) ;
26+
27+ // Exact versions only: a range/alias/partial version is left untouched.
28+ let version = Version :: parse ( normalized) . ok ( ) ?;
29+
30+ // Already supported — nothing to upgrade (and never hits the network).
31+ if supported_range. satisfies ( & version) {
32+ return None ;
33+ }
34+
35+ let major = version. major ;
36+ Some ( format ! ( ">={major}.0.0 <{}.0.0" , major + 1 ) )
37+ }
38+
39+ /// Resolve the latest supported Node.js release matching `current`'s major from
40+ /// an explicit version list, verifying the result against `supported_range`.
41+ /// Shared by the NAPI entry point and unit tests.
42+ #[ cfg( test) ]
43+ fn resolve_supported_node_version_from_list (
44+ current : & str ,
45+ supported_range : & str ,
46+ versions : & [ vite_js_runtime:: NodeVersionEntry ] ,
47+ ) -> Option < String > {
48+ let supported = Range :: parse ( supported_range) . ok ( ) ?;
49+ let requirement = supported_node_requirement ( current, & supported) ?;
50+ let resolved = vite_js_runtime:: resolve_version_from_list ( & requirement, versions) . ok ( ) ?. to_string ( ) ;
51+ // Verify the resolved version actually satisfies the supported range. An
52+ // unsupported major (e.g. 21 or 23) resolves to a concrete release but must
53+ // not be returned.
54+ Version :: parse ( resolved. as_str ( ) ) . ok ( ) . filter ( |v| supported. satisfies ( v) ) . map ( |_| resolved)
55+ }
56+
57+ /// Resolve a Node.js version that is below Vite+'s supported range to the
58+ /// concrete latest release of the same major.
59+ ///
60+ /// Engine-strict installers skip the native optional dependency under an
61+ /// unsupported Node.js version (causing "Cannot find native binding"), so
62+ /// `vp migrate` uses this to bump a too-old pin up to a supported release of the
63+ /// same major line.
64+ ///
65+ /// # Arguments
66+ ///
67+ /// * `current` - The pinned Node.js version (e.g. `24.3.0`, optionally `v`-prefixed)
68+ /// * `supported_range` - The Vite+-supported Node.js range, sourced from the
69+ /// `engines.node` field in `package.json` (e.g.
70+ /// `^20.19.0 || ^22.18.0 || >=24.11.0`). This is the only source of truth for
71+ /// what is supported.
72+ ///
73+ /// # Returns
74+ ///
75+ /// * `Some(latest)` - The concrete latest supported release of `current`'s major
76+ /// (e.g. `24.18.0`) when `current` is an exact, below-range, supported-major version
77+ /// * `None` - When `current` is already supported, is not an exact version, or
78+ /// belongs to an unsupported major (e.g. 21, 23)
79+ ///
80+ /// # Example
81+ ///
82+ /// ```javascript
83+ /// const upgraded = await resolveSupportedNodeVersion('24.3.0', '^20.19.0 || ^22.18.0 || >=24.11.0');
84+ /// // upgraded === '24.18.0' (latest 24.x at the time of resolution)
85+ /// ```
86+ #[ napi]
87+ pub async fn resolve_supported_node_version (
88+ current : String ,
89+ supported_range : String ,
90+ ) -> Result < Option < String > > {
91+ let Ok ( supported) = Range :: parse ( & supported_range) else {
92+ return Ok ( None ) ;
93+ } ;
94+ let Some ( requirement) = supported_node_requirement ( & current, & supported) else {
95+ return Ok ( None ) ;
96+ } ;
97+
98+ let provider = NodeProvider :: new ( ) ;
99+ let latest = provider. resolve_version ( & requirement) . await . map_err ( anyhow:: Error :: from) ?;
100+ let latest = latest. to_string ( ) ;
101+
102+ // Verify the resolved version is actually supported. An unsupported major
103+ // (e.g. 21 or 23) resolves to a concrete release but must not be returned.
104+ match Version :: parse ( latest. as_str ( ) ) {
105+ Ok ( version) if supported. satisfies ( & version) => Ok ( Some ( latest) ) ,
106+ _ => Ok ( None ) ,
107+ }
108+ }
5109
6110/// Rewrite scripts json content using rules from rules_yaml
7111///
@@ -320,3 +424,145 @@ pub fn rewrite_imports_in_directory(
320424 . collect ( ) ,
321425 } )
322426}
427+
428+ #[ cfg( test) ]
429+ mod tests {
430+ use vite_js_runtime:: { LtsInfo , NodeVersionEntry } ;
431+
432+ use super :: * ;
433+
434+ /// The Vite+-supported Node.js range used as test input. Mirrors the
435+ /// `engines.node` field shipped in `packages/cli/package.json`.
436+ const SUPPORTED_RANGE : & str = "^20.19.0 || ^22.18.0 || >=24.11.0" ;
437+
438+ /// A mock Node.js release index spanning several majors, mirroring the
439+ /// shape used in `vite_js_runtime`'s own `resolve_version_from_list` tests.
440+ fn mock_versions ( ) -> Vec < NodeVersionEntry > {
441+ vec ! [
442+ NodeVersionEntry { version: "v24.18.0" . into( ) , lts: LtsInfo :: Codename ( "Krypton" . into( ) ) } ,
443+ NodeVersionEntry { version: "v24.11.0" . into( ) , lts: LtsInfo :: Codename ( "Krypton" . into( ) ) } ,
444+ NodeVersionEntry { version: "v24.3.0" . into( ) , lts: LtsInfo :: Boolean ( false ) } ,
445+ NodeVersionEntry { version: "v23.5.0" . into( ) , lts: LtsInfo :: Boolean ( false ) } ,
446+ NodeVersionEntry { version: "v22.18.0" . into( ) , lts: LtsInfo :: Codename ( "Jod" . into( ) ) } ,
447+ NodeVersionEntry { version: "v22.10.0" . into( ) , lts: LtsInfo :: Codename ( "Jod" . into( ) ) } ,
448+ NodeVersionEntry { version: "v21.5.0" . into( ) , lts: LtsInfo :: Boolean ( false ) } ,
449+ NodeVersionEntry { version: "v20.19.0" . into( ) , lts: LtsInfo :: Codename ( "Iron" . into( ) ) } ,
450+ NodeVersionEntry { version: "v20.11.0" . into( ) , lts: LtsInfo :: Codename ( "Iron" . into( ) ) } ,
451+ ]
452+ }
453+
454+ #[ test]
455+ fn upgrades_below_range_major_24 ( ) {
456+ // 24.3.0 is below the 24.11.0 floor → latest 24.x (24.18.0).
457+ let result =
458+ resolve_supported_node_version_from_list ( "24.3.0" , SUPPORTED_RANGE , & mock_versions ( ) ) ;
459+ assert_eq ! ( result. as_deref( ) , Some ( "24.18.0" ) ) ;
460+ }
461+
462+ #[ test]
463+ fn leaves_supported_major_24_unchanged ( ) {
464+ // 24.11.0 already satisfies `>=24.11.0`.
465+ let result =
466+ resolve_supported_node_version_from_list ( "24.11.0" , SUPPORTED_RANGE , & mock_versions ( ) ) ;
467+ assert_eq ! ( result, None ) ;
468+ }
469+
470+ #[ test]
471+ fn leaves_supported_major_22_unchanged ( ) {
472+ // 22.18.0 already satisfies `^22.18.0`.
473+ let result =
474+ resolve_supported_node_version_from_list ( "22.18.0" , SUPPORTED_RANGE , & mock_versions ( ) ) ;
475+ assert_eq ! ( result, None ) ;
476+ }
477+
478+ #[ test]
479+ fn upgrades_below_range_major_20 ( ) {
480+ // 20.10.0 is below the 20.19.0 floor → latest 20.x (20.19.0).
481+ let result =
482+ resolve_supported_node_version_from_list ( "20.10.0" , SUPPORTED_RANGE , & mock_versions ( ) ) ;
483+ assert_eq ! ( result. as_deref( ) , Some ( "20.19.0" ) ) ;
484+ }
485+
486+ #[ test]
487+ fn skips_unsupported_major_21 ( ) {
488+ // Major 21 is not part of the supported range; the resolved release
489+ // fails the verify-against-range step, so it is never upgraded.
490+ let result =
491+ resolve_supported_node_version_from_list ( "21.5.0" , SUPPORTED_RANGE , & mock_versions ( ) ) ;
492+ assert_eq ! ( result, None ) ;
493+ }
494+
495+ #[ test]
496+ fn skips_unsupported_major_23 ( ) {
497+ // Major 23 is not part of the supported range; the resolved release
498+ // fails the verify-against-range step, so it is never upgraded.
499+ let result =
500+ resolve_supported_node_version_from_list ( "23.5.0" , SUPPORTED_RANGE , & mock_versions ( ) ) ;
501+ assert_eq ! ( result, None ) ;
502+ }
503+
504+ #[ test]
505+ fn skips_non_semver_input ( ) {
506+ assert_eq ! (
507+ resolve_supported_node_version_from_list( "lts/*" , SUPPORTED_RANGE , & mock_versions( ) ) ,
508+ None
509+ ) ;
510+ assert_eq ! (
511+ resolve_supported_node_version_from_list( "^24.3.0" , SUPPORTED_RANGE , & mock_versions( ) ) ,
512+ None
513+ ) ;
514+ assert_eq ! (
515+ resolve_supported_node_version_from_list(
516+ "not-a-version" ,
517+ SUPPORTED_RANGE ,
518+ & mock_versions( )
519+ ) ,
520+ None
521+ ) ;
522+ assert_eq ! (
523+ resolve_supported_node_version_from_list( "" , SUPPORTED_RANGE , & mock_versions( ) ) ,
524+ None
525+ ) ;
526+ }
527+
528+ #[ test]
529+ fn tolerates_leading_v_prefix ( ) {
530+ // A `v`-prefixed exact version is normalized before resolving.
531+ let result =
532+ resolve_supported_node_version_from_list ( "v24.3.0" , SUPPORTED_RANGE , & mock_versions ( ) ) ;
533+ assert_eq ! ( result. as_deref( ) , Some ( "24.18.0" ) ) ;
534+ }
535+
536+ #[ test]
537+ fn requirement_targets_same_major_bracket ( ) {
538+ let range = Range :: parse ( SUPPORTED_RANGE ) . unwrap ( ) ;
539+ // The requirement brackets the whole major; verification against the
540+ // range happens after resolution, not here.
541+ assert_eq ! (
542+ supported_node_requirement( "24.3.0" , & range) . as_deref( ) ,
543+ Some ( ">=24.0.0 <25.0.0" )
544+ ) ;
545+ assert_eq ! (
546+ supported_node_requirement( "20.10.0" , & range) . as_deref( ) ,
547+ Some ( ">=20.0.0 <21.0.0" )
548+ ) ;
549+ assert_eq ! (
550+ supported_node_requirement( "22.5.0" , & range) . as_deref( ) ,
551+ Some ( ">=22.0.0 <23.0.0" )
552+ ) ;
553+ // Unsupported majors still produce a major bracket; only the later
554+ // verify-against-range step rejects them.
555+ assert_eq ! (
556+ supported_node_requirement( "21.5.0" , & range) . as_deref( ) ,
557+ Some ( ">=21.0.0 <22.0.0" )
558+ ) ;
559+ assert_eq ! (
560+ supported_node_requirement( "23.5.0" , & range) . as_deref( ) ,
561+ Some ( ">=23.0.0 <24.0.0" )
562+ ) ;
563+ // Majors above 24 already satisfy `>=24.11.0`, so they are reported as
564+ // supported (no upgrade) before a requirement is computed.
565+ assert_eq ! ( supported_node_requirement( "26.0.0" , & range) , None ) ;
566+ assert_eq ! ( supported_node_requirement( "25.0.0" , & range) , None ) ;
567+ }
568+ }
0 commit comments