@@ -765,14 +765,108 @@ function getAheadBehind(repoDir, branch) {
765765 return { behind, ahead } ;
766766}
767767
768+ function resolvePackageInstallSpec ( packageName , tag , repoDir , dryRun ) {
769+ if ( dryRun ) {
770+ // Cannot query npm in dry-run; use tag as best-effort
771+ return `${ packageName } @${ tag } ` ;
772+ }
773+
774+ let tagVersion = null ;
775+ try {
776+ const r = runQuiet ( `npm view ${ packageName } @${ tag } version` , repoDir ) ;
777+ if ( r && r . trim ( ) ) tagVersion = r . trim ( ) ;
778+ } catch ( err ) {
779+ // @tag doesn't exist
780+ }
781+
782+ let latestVersion = null ;
783+ try {
784+ const r = runQuiet ( `npm view ${ packageName } @latest version` , repoDir ) ;
785+ if ( r && r . trim ( ) ) latestVersion = r . trim ( ) ;
786+ } catch ( err ) {
787+ // no @latest
788+ }
789+
790+ if ( ! tagVersion ) {
791+ console . log ( ` ${ packageName } : no @${ tag } found → using @latest` ) ;
792+ return `${ packageName } @latest` ;
793+ }
794+
795+ if ( ! latestVersion ) {
796+ console . log ( ` ${ packageName } : @${ tag } =${ tagVersion } , no @latest → using @${ tag } ` ) ;
797+ return `${ packageName } @${ tagVersion } ` ;
798+ }
799+
800+ // Extract base X.Y.Z from the test version (strip prerelease suffix)
801+ const tagBaseMatch = tagVersion . match ( / ^ ( \d + \. \d + \. \d + ) / ) ;
802+ const tagBase = tagBaseMatch ? tagBaseMatch [ 1 ] : null ;
803+ const cmp = tagBase ? compareBaseSemver ( tagBase , latestVersion ) : null ;
804+
805+ if ( cmp !== null && cmp > 0 ) {
806+ // @test base is strictly newer than @latest → prefer @test
807+ console . log ( ` ${ packageName } : @${ tag } =${ tagVersion } > @latest=${ latestVersion } → using @${ tag } ` ) ;
808+ return `${ packageName } @${ tagVersion } ` ;
809+ }
810+
811+ // @test base is equal to or behind @latest → use @latest
812+ console . log ( ` ${ packageName } : @${ tag } =${ tagVersion } <= @latest=${ latestVersion } → using @latest` ) ;
813+ return `${ packageName } @latest` ;
814+ }
815+
816+ function runAfterInstallStep ( rawCmd , tag , repoDir , dryRun ) {
817+ const parts = rawCmd . trim ( ) . split ( / \s + / ) ;
818+ if ( parts [ 0 ] !== 'npm' || parts [ 1 ] !== 'install' ) {
819+ run ( rawCmd , repoDir , dryRun ) ;
820+ return ;
821+ }
822+
823+ const flags = [ ] ;
824+ const untaggedPkgs = [ ] ;
825+ const taggedItems = [ ] ;
826+
827+ for ( let i = 2 ; i < parts . length ; i ++ ) {
828+ const part = parts [ i ] ;
829+ if ( ! part ) continue ;
830+ if ( part . startsWith ( '-' ) ) { flags . push ( part ) ; continue ; }
831+
832+ const isScoped = part . startsWith ( '@' ) ;
833+ const hasExplicitTag = isScoped
834+ ? part . lastIndexOf ( '@' ) > part . indexOf ( '/' )
835+ : part . includes ( '@' ) ;
836+
837+ if ( hasExplicitTag ) {
838+ taggedItems . push ( part ) ;
839+ } else {
840+ untaggedPkgs . push ( part ) ;
841+ }
842+ }
843+
844+ const flagStr = flags . length > 0 ? `${ flags . join ( ' ' ) } ` : '' ;
845+
846+ // Resolve each untagged package individually via npm registry
847+ const resolvedSpecs = [ ] ;
848+ if ( untaggedPkgs . length > 0 ) {
849+ console . log ( `Resolving afterInstall versions (tag=@${ tag } ):` ) ;
850+ for ( const pkg of untaggedPkgs ) {
851+ resolvedSpecs . push ( resolvePackageInstallSpec ( pkg , tag , repoDir , dryRun ) ) ;
852+ }
853+ }
854+
855+ // Build one combined install command with precise version specs
856+ const allSpecs = [ ...resolvedSpecs , ...taggedItems ] ;
857+ if ( allSpecs . length > 0 ) {
858+ run ( `npm install ${ flagStr } ${ allSpecs . join ( ' ' ) } ` . trim ( ) , repoDir , dryRun ) ;
859+ }
860+ }
861+
768862function runSteps ( steps , repoDir , dryRun , tag = null ) {
769863 if ( ! steps || ! Array . isArray ( steps ) ) return ;
770- for ( let cmd of steps ) {
771- // Inject npm tag into npm install commands if tag is provided
772- if ( tag && cmd . startsWith ( 'npm install' ) ) {
773- cmd = parseNpmInstallCmd ( cmd , tag ) ;
864+ for ( const rawCmd of steps ) {
865+ if ( tag && rawCmd . trim ( ) . startsWith ( 'npm install' ) ) {
866+ runAfterInstallStep ( rawCmd , tag , repoDir , dryRun ) ;
867+ } else {
868+ run ( rawCmd , repoDir , dryRun ) ;
774869 }
775- run ( cmd , repoDir , dryRun ) ;
776870 }
777871}
778872
@@ -862,14 +956,13 @@ function buildLockedVersion(installedVersion, prefix) {
862956 return `${ normalizedPrefix } ${ installedVersion } ` ;
863957}
864958
865- function collectAfterInstallPackageNames ( repo , npmTag ) {
959+ function collectAfterInstallPackageNames ( repo ) {
866960 const commands = Array . isArray ( repo . afterInstall ) ? repo . afterInstall : [ ] ;
867961 const packageNames = new Set ( ) ;
868962
869963 for ( const rawCmd of commands ) {
870- const installCmd = parseNpmInstallCmd ( rawCmd , npmTag ) ;
871- const firstSegment = installCmd . split ( '||' ) [ 0 ] . trim ( ) ;
872- const parsedPackages = extractNpmInstallPackages ( firstSegment ) ;
964+ // Parse package names from the raw command — no tag injection needed for name extraction
965+ const parsedPackages = extractNpmInstallPackages ( rawCmd ) ;
873966 for ( const pkgName of parsedPackages ) {
874967 packageNames . add ( pkgName ) ;
875968 }
@@ -892,8 +985,7 @@ function getDeclaredPackageSpecs(packageJson, packageName) {
892985}
893986
894987function logBuildDependencyVersions ( repoDir , repo , modeConfig ) {
895- const npmTag = modeConfig . npmTag || 'latest' ;
896- const packageNames = collectAfterInstallPackageNames ( repo , npmTag ) ;
988+ const packageNames = collectAfterInstallPackageNames ( repo ) ;
897989 if ( packageNames . length === 0 ) return ;
898990
899991 const pkg = getPackageJson ( repoDir ) ;
@@ -910,8 +1002,7 @@ function logBuildDependencyVersions(repoDir, repo, modeConfig) {
9101002}
9111003
9121004function lockStableDependencyVersions ( repoDir , repo , config , modeConfig , dryRun ) {
913- const npmTag = modeConfig . npmTag || 'latest' ;
914- const packageNames = collectAfterInstallPackageNames ( repo , npmTag ) ;
1005+ const packageNames = collectAfterInstallPackageNames ( repo ) ;
9151006
9161007 if ( packageNames . length === 0 ) {
9171008 return ;
@@ -980,40 +1071,28 @@ function lockStableDependencyVersions(repoDir, repo, config, modeConfig, dryRun)
9801071}
9811072
9821073function parseNpmInstallCmd ( cmd , tag ) {
1074+ // Simple tag injection — used for package-name extraction in lock/log helpers.
1075+ // Actual execution goes through runAfterInstallStep → resolvePackageInstallSpec.
9831076 const parts = cmd . split ( / \s + / ) ;
9841077 if ( parts [ 0 ] !== 'npm' || parts [ 1 ] !== 'install' ) {
985- return cmd ; // Not an npm install command
1078+ return cmd ;
9861079 }
9871080
9881081 const result = [ 'npm' , 'install' ] ;
989- const fallback = [ 'npm' , 'install' ] ;
990-
9911082 for ( let i = 2 ; i < parts . length ; i ++ ) {
9921083 const part = parts [ i ] ;
993-
994- // Keep flags, option values, and packages with existing tags as-is
995- if ( part . startsWith ( '-' ) || part . startsWith ( '@' ) || part . includes ( '@' ) ) {
996- result . push ( part ) ;
997- fallback . push ( part ) ;
998- } else if ( part === '' ) {
999- // Skip empty strings
1000- continue ;
1001- } else {
1002- // This is a package name, add tag
1003- result . push ( `${ part } @${ tag } ` ) ;
1004- fallback . push ( `${ part } @latest` ) ;
1005- }
1006- }
1007-
1008- const mainCmd = result . join ( ' ' ) ;
1009-
1010- // For test mode, add fallback to @latest if @test doesn't exist
1011- if ( tag === 'test' ) {
1012- const fallbackCmd = fallback . join ( ' ' ) ;
1013- return `${ mainCmd } || ${ fallbackCmd } ` ;
1084+ if ( ! part ) continue ;
1085+ if ( part . startsWith ( '-' ) ) { result . push ( part ) ; continue ; }
1086+
1087+ const isScoped = part . startsWith ( '@' ) ;
1088+ const hasExplicitTag = isScoped
1089+ ? part . lastIndexOf ( '@' ) > part . indexOf ( '/' )
1090+ : part . includes ( '@' ) ;
1091+
1092+ result . push ( hasExplicitTag ? part : `${ part } @${ tag } ` ) ;
10141093 }
1015-
1016- return mainCmd ;
1094+
1095+ return result . join ( ' ' ) ;
10171096}
10181097
10191098function packageVersionExists ( name , version , repoDir ) {
0 commit comments