@@ -742,6 +742,7 @@ export async function dump(dir: string, options: DumpOptions = {}): Promise<void
742
742
|| message . includes ( '🔧' ) // Extracting
743
743
|| message . includes ( '🚀' ) // Service start messages
744
744
|| message . includes ( '⏳' ) // Waiting messages
745
+ || message . includes ( '📌' ) // Pin/version update notices
745
746
|| message . includes ( '🔍' ) // Verbose diagnostics
746
747
|| message . includes ( '⏱' ) // Timing summaries (verbose)
747
748
|| message . includes ( '✅' ) // Success messages
@@ -770,6 +771,7 @@ export async function dump(dir: string, options: DumpOptions = {}): Promise<void
770
771
|| message . includes ( '🔧' ) // Extracting
771
772
|| message . includes ( '🚀' ) // Service start messages
772
773
|| message . includes ( '⏳' ) // Waiting messages
774
+ || message . includes ( '📌' ) // Pin/version update notices
773
775
|| message . includes ( '🔍' ) // Verbose diagnostics
774
776
|| message . includes ( '⏱' ) // Timing summaries (verbose)
775
777
|| message . includes ( '✅' ) // Success messages
@@ -839,7 +841,15 @@ export async function dump(dir: string, options: DumpOptions = {}): Promise<void
839
841
if ( shellOutput ) {
840
842
// Generate hash for this project
841
843
const projectHash = generateProjectHash ( dir )
842
- const envDir = path . join ( process . env . HOME || '' , '.local' , 'share' , 'launchpad' , 'envs' , projectHash )
844
+ // Compute dependency fingerprint to ensure env path reflects dependency versions
845
+ let depSuffix = ''
846
+ try {
847
+ const depContent = fs . readFileSync ( dependencyFile )
848
+ const depHash = crypto . createHash ( 'md5' ) . update ( depContent ) . digest ( 'hex' ) . slice ( 0 , 8 )
849
+ depSuffix = `-d${ depHash } `
850
+ }
851
+ catch { }
852
+ const envDir = path . join ( process . env . HOME || '' , '.local' , 'share' , 'launchpad' , 'envs' , `${ projectHash } ${ depSuffix } ` )
843
853
const globalEnvDir = path . join ( process . env . HOME || '' , '.local' , 'share' , 'launchpad' , 'global' )
844
854
845
855
// Check if environments exist first (quick filesystem check)
@@ -862,20 +872,107 @@ export async function dump(dir: string, options: DumpOptions = {}): Promise<void
862
872
sniffResult = { pkgs : [ ] , env : { } }
863
873
}
864
874
875
+ // Merge raw constraints from deps.yaml to honor exact pins over any normalization
876
+ try {
877
+ const rawContent = fs . readFileSync ( dependencyFile , 'utf8' )
878
+ const rawLines = rawContent . split ( / \r ? \n / )
879
+ let inDeps = false
880
+ const rawMap = new Map < string , string > ( )
881
+ for ( const raw of rawLines ) {
882
+ const indent = raw . length - raw . trimStart ( ) . length
883
+ const line = raw . trim ( )
884
+ if ( ! inDeps ) {
885
+ if ( line . startsWith ( 'dependencies:' ) )
886
+ inDeps = true
887
+ continue
888
+ }
889
+ if ( indent === 0 && line . endsWith ( ':' ) )
890
+ break
891
+ if ( ! line || line . startsWith ( '#' ) )
892
+ continue
893
+ const m = line . match ( / ^ ( [ \w . \- / ] + ) : \s * ( \S .* ) $ / )
894
+ if ( m ) {
895
+ rawMap . set ( m [ 1 ] , m [ 2 ] )
896
+ continue
897
+ }
898
+ if ( line . startsWith ( '- ' ) ) {
899
+ const spec = line . slice ( 2 ) . trim ( )
900
+ const [ domain , constraint = '*' ] = spec . split ( '@' )
901
+ if ( domain )
902
+ rawMap . set ( domain , constraint )
903
+ }
904
+ }
905
+ if ( sniffResult && Array . isArray ( sniffResult . pkgs ) && rawMap . size > 0 ) {
906
+ sniffResult . pkgs = sniffResult . pkgs . map ( ( p : any ) => {
907
+ const domain = String ( p . project || '' )
908
+ const rawC = rawMap . get ( domain )
909
+ if ( rawC && rawC !== '*' && ! rawC . startsWith ( '^' ) && ! rawC . startsWith ( '~' ) ) {
910
+ return { ...p , constraint : rawC }
911
+ }
912
+ return p
913
+ } )
914
+ }
915
+ }
916
+ catch { }
917
+
918
+ // Fallback: if sniff returned no packages, parse deps.yaml minimally for pins
919
+ if ( ( ! sniffResult . pkgs || sniffResult . pkgs . length === 0 ) && dependencyFile ) {
920
+ try {
921
+ const content = fs . readFileSync ( dependencyFile , 'utf8' )
922
+ const lines = content . split ( / \r ? \n / )
923
+ let inDeps = false
924
+ const pkgs : any [ ] = [ ]
925
+ for ( const raw of lines ) {
926
+ const indent = raw . length - raw . trimStart ( ) . length
927
+ const line = raw . trim ( )
928
+ if ( ! inDeps ) {
929
+ if ( line . startsWith ( 'dependencies:' ) ) {
930
+ inDeps = true
931
+ }
932
+ continue
933
+ }
934
+ if ( indent === 0 && line . endsWith ( ':' ) )
935
+ break
936
+ if ( ! line || line . startsWith ( '#' ) )
937
+ continue
938
+ const m = line . match ( / ^ ( [ \w . \- / ] + ) : \s * ( \S .* ) $ / )
939
+ if ( m ) {
940
+ const domain = m [ 1 ]
941
+ const val = m [ 2 ] . trim ( )
942
+ if ( domain && val && ! val . startsWith ( '{' ) ) {
943
+ pkgs . push ( { project : domain , constraint : val , global : false } )
944
+ }
945
+ }
946
+ else if ( line . startsWith ( '- ' ) ) {
947
+ const spec = line . slice ( 2 ) . trim ( )
948
+ const [ domain , constraint = '*' ] = spec . split ( '@' )
949
+ if ( domain )
950
+ pkgs . push ( { project : domain , constraint, global : false } )
951
+ }
952
+ }
953
+ if ( pkgs . length > 0 )
954
+ sniffResult = { pkgs, env : sniffResult . env || { } }
955
+ }
956
+ catch { }
957
+ }
958
+
865
959
// Quick constraint satisfaction check for already-existing environment
866
960
const semverCompare = ( a : string , b : string ) : number => {
867
961
const pa = a . replace ( / ^ v / , '' ) . split ( '.' ) . map ( n => Number . parseInt ( n , 10 ) )
868
962
const pb = b . replace ( / ^ v / , '' ) . split ( '.' ) . map ( n => Number . parseInt ( n , 10 ) )
869
963
for ( let i = 0 ; i < 3 ; i ++ ) {
870
964
const da = pa [ i ] || 0
871
965
const db = pb [ i ] || 0
872
- if ( da > db ) return 1
873
- if ( da < db ) return - 1
966
+ if ( da > db )
967
+ return 1
968
+ if ( da < db )
969
+ return - 1
874
970
}
875
971
return 0
876
972
}
877
973
const satisfies = ( installed : string , constraint ?: string ) : boolean => {
878
- if ( ! constraint || constraint === '*' || constraint === '' ) return true
974
+ if ( ! constraint || constraint === '*' || constraint === '' )
975
+ return true
879
976
const c = constraint . trim ( )
880
977
const ver = installed . replace ( / ^ v / , '' )
881
978
const [ cOp , cVerRaw ] = c . startsWith ( '^' ) || c . startsWith ( '~' ) ? [ c [ 0 ] , c . slice ( 1 ) ] : [ '' , c ]
@@ -888,22 +985,59 @@ export async function dump(dir: string, options: DumpOptions = {}): Promise<void
888
985
if ( cOp === '~' ) {
889
986
return vParts [ 0 ] === cParts [ 0 ] && vParts [ 1 ] === cParts [ 1 ] && cmp >= 0
890
987
}
891
- // exact or >= style : treat as minimum version
892
- return cmp > = 0
988
+ // No operator provided : treat as exact pin
989
+ return cmp == = 0
893
990
}
991
+ const pinInfo : Array < { domain : string , desired : string , installed : string } > = [ ]
992
+ // Extra-robust exact-pin check for well-known tools (like bun.sh)
993
+ const exactPins : Array < { domain : string , version : string } > = [ ]
994
+ try {
995
+ for ( const pkg of sniffResult . pkgs || [ ] ) {
996
+ const domain = String ( pkg . project )
997
+ const constraint = String ( typeof pkg . constraint === 'string' ? pkg . constraint : ( pkg . constraint || '' ) )
998
+ if ( constraint && ! constraint . startsWith ( '^' ) && ! constraint . startsWith ( '~' ) && constraint !== '*' ) {
999
+ exactPins . push ( { domain, version : constraint . replace ( / ^ v / , '' ) } )
1000
+ }
1001
+ }
1002
+ }
1003
+ catch { }
894
1004
const needsUpgrade = ( ( ) => {
895
1005
try {
896
1006
for ( const pkg of sniffResult . pkgs || [ ] ) {
897
1007
const domain = pkg . project as string
898
1008
const constraint = typeof pkg . constraint === 'string' ? pkg . constraint : String ( pkg . constraint || '*' )
899
1009
const domainDir = path . join ( envDir , domain )
900
- if ( ! fs . existsSync ( domainDir ) ) return true
1010
+ if ( ! fs . existsSync ( domainDir ) )
1011
+ return true
901
1012
const versions = fs . readdirSync ( domainDir , { withFileTypes : true } )
902
1013
. filter ( e => e . isDirectory ( ) && e . name . startsWith ( 'v' ) )
903
1014
. map ( e => e . name )
904
- if ( versions . length === 0 ) return true
1015
+ if ( versions . length === 0 )
1016
+ return true
905
1017
const highest = versions . sort ( ( a , b ) => semverCompare ( a . slice ( 1 ) , b . slice ( 1 ) ) ) . slice ( - 1 ) [ 0 ]
906
- if ( ! satisfies ( highest , constraint ) ) return true
1018
+ if ( ! satisfies ( highest , constraint ) ) {
1019
+ pinInfo . push ( { domain, desired : constraint , installed : highest . replace ( / ^ v / , '' ) } )
1020
+ return true
1021
+ }
1022
+ }
1023
+ // Additionally, ensure exact pins are active (symlinks point to pinned version)
1024
+ for ( const pin of exactPins ) {
1025
+ if ( pin . domain === 'bun.sh' ) {
1026
+ const pinDir = path . join ( envDir , 'bun.sh' , `v${ pin . version } ` )
1027
+ if ( ! fs . existsSync ( pinDir ) )
1028
+ return true
1029
+ const bunBin = path . join ( envDir , 'bin' , 'bun' )
1030
+ try {
1031
+ if ( fs . existsSync ( bunBin ) ) {
1032
+ const target = fs . realpathSync ( bunBin )
1033
+ if ( ! target . includes ( path . join ( 'bun.sh' , `v${ pin . version } ` , 'bin' , 'bun' ) ) )
1034
+ return true
1035
+ }
1036
+ }
1037
+ catch {
1038
+ return true
1039
+ }
1040
+ }
907
1041
}
908
1042
}
909
1043
catch { }
@@ -912,6 +1046,13 @@ export async function dump(dir: string, options: DumpOptions = {}): Promise<void
912
1046
913
1047
// If constraints are not satisfied, fall back to install path
914
1048
if ( needsUpgrade ) {
1049
+ if ( isVerbose && pinInfo . length > 0 ) {
1050
+ try {
1051
+ const details = pinInfo . map ( p => `${ p . domain } @${ p . desired } (installed ${ p . installed } )` ) . join ( ', ' )
1052
+ process . stderr . write ( `📌 Updating to satisfy pins: ${ details } \n` )
1053
+ }
1054
+ catch { }
1055
+ }
915
1056
const envBinPath = path . join ( envDir , 'bin' )
916
1057
const envSbinPath = path . join ( envDir , 'sbin' )
917
1058
const globalBinPath = path . join ( globalEnvDir , 'bin' )
@@ -922,7 +1063,8 @@ export async function dump(dir: string, options: DumpOptions = {}): Promise<void
922
1063
for ( const pkg of sniffResult . pkgs ) {
923
1064
const constraintStr = typeof pkg . constraint === 'string' ? pkg . constraint : String ( pkg . constraint || '*' )
924
1065
const packageString = `${ pkg . project } @${ constraintStr } `
925
- if ( pkg . global && ! skipGlobal ) globalPackages . push ( packageString )
1066
+ if ( pkg . global && ! skipGlobal )
1067
+ globalPackages . push ( packageString )
926
1068
else localPackages . push ( packageString )
927
1069
}
928
1070
const tInstallFast = tick ( )
@@ -937,7 +1079,10 @@ export async function dump(dir: string, options: DumpOptions = {}): Promise<void
937
1079
if ( shellOutput && summary ) {
938
1080
const msg = summary . replace ( / " / g, '\\"' )
939
1081
const guard = isVerbose ? 'true' : 'false'
940
- try { process . stdout . write ( `if ${ guard } ; then >&2 echo "${ msg } "; fi\n` ) } catch { }
1082
+ try {
1083
+ process . stdout . write ( `if ${ guard } ; then >&2 echo "${ msg } "; fi\n` )
1084
+ }
1085
+ catch { }
941
1086
}
942
1087
}
943
1088
return
@@ -1126,7 +1271,15 @@ export async function dump(dir: string, options: DumpOptions = {}): Promise<void
1126
1271
1127
1272
// Generate hash for this project
1128
1273
const projectHash = generateProjectHash ( dir )
1129
- const envDir = path . join ( process . env . HOME || '' , '.local' , 'share' , 'launchpad' , 'envs' , projectHash )
1274
+ // Compute dependency fingerprint to ensure env path reflects dependency versions
1275
+ let depSuffix = ''
1276
+ try {
1277
+ const depContent = fs . readFileSync ( dependencyFile )
1278
+ const depHash = crypto . createHash ( 'md5' ) . update ( depContent ) . digest ( 'hex' ) . slice ( 0 , 8 )
1279
+ depSuffix = `-d${ depHash } `
1280
+ }
1281
+ catch { }
1282
+ const envDir = path . join ( process . env . HOME || '' , '.local' , 'share' , 'launchpad' , 'envs' , `${ projectHash } ${ depSuffix } ` )
1130
1283
const globalEnvDir = path . join ( process . env . HOME || '' , '.local' , 'share' , 'launchpad' , 'global' )
1131
1284
1132
1285
// For shell output mode, check if we can skip expensive operations
@@ -1531,7 +1684,27 @@ function outputShellCode(dir: string, envBinPath: string, envSbinPath: string, p
1531
1684
process . stdout . write ( `fi\n` )
1532
1685
1533
1686
// Build PATH with both project and global environments
1534
- const pathComponents = [ ]
1687
+ const pathComponents : string [ ] = [ ]
1688
+
1689
+ // If exact pins exist, prepend their bin directories ahead of generic env bin
1690
+ try {
1691
+ if ( sniffResult && Array . isArray ( sniffResult . pkgs ) ) {
1692
+ const envRoot = fs . existsSync ( envBinPath ) ? path . dirname ( envBinPath ) : ''
1693
+ for ( const pkg of sniffResult . pkgs ) {
1694
+ const domain = String ( pkg . project || '' )
1695
+ const constraint = String ( typeof pkg . constraint === 'string' ? pkg . constraint : ( pkg . constraint || '' ) )
1696
+ if ( ! domain || ! constraint || constraint === '*' || constraint . startsWith ( '^' ) || constraint . startsWith ( '~' ) )
1697
+ continue
1698
+ if ( ! envRoot )
1699
+ continue
1700
+ const pinnedBin = path . join ( envRoot , domain , `v${ constraint . replace ( / ^ v / , '' ) } ` , 'bin' )
1701
+ if ( fs . existsSync ( pinnedBin ) && ! pathComponents . includes ( pinnedBin ) ) {
1702
+ pathComponents . push ( pinnedBin )
1703
+ }
1704
+ }
1705
+ }
1706
+ }
1707
+ catch { }
1535
1708
1536
1709
// Add project-specific paths first (highest priority - can override global versions)
1537
1710
if ( fs . existsSync ( envBinPath ) ) {
@@ -1737,6 +1910,8 @@ function outputShellCode(dir: string, envBinPath: string, envSbinPath: string, p
1737
1910
process . stdout . write ( ` ;;\n` )
1738
1911
process . stdout . write ( ` esac\n` )
1739
1912
process . stdout . write ( `}\n` )
1913
+ // Refresh the command hash so version switches take effect immediately
1914
+ process . stdout . write ( `hash -r 2>/dev/null || true\n` )
1740
1915
}
1741
1916
1742
1917
/**
0 commit comments