@@ -2,32 +2,202 @@ import fs from 'node:fs'
2
2
import path from 'node:path'
3
3
import { getPackageInfo , resolvePackageName } from './install'
4
4
import { Path } from './path'
5
+ import { resolveAllDependencies } from './dependency-resolution'
5
6
6
7
/**
7
8
* Get all possible binary directories where packages might be installed
8
9
*/
9
- function getPossibleBinaryDirectories ( ) : Path [ ] {
10
+ function getPossibleBinaryDirectories ( isGlobal : boolean = false ) : Path [ ] {
10
11
const directories : Path [ ] = [ ]
11
12
12
- // Add /usr/local/bin if it exists
13
- const usrLocalBin = new Path ( '/usr/local/bin' )
14
- if ( usrLocalBin . isDirectory ( ) ) {
15
- directories . push ( usrLocalBin )
16
- }
13
+ if ( isGlobal ) {
14
+ // For global uninstalls, only look in the launchpad global directory
15
+ const globalBin = Path . home ( ) . join ( '.local' , 'share' , 'launchpad' , 'global' , 'bin' )
16
+ if ( globalBin . isDirectory ( ) ) {
17
+ directories . push ( globalBin )
18
+ }
19
+ } else {
20
+ // For local uninstalls, check standard locations
21
+ // Add /usr/local/bin if it exists
22
+ const usrLocalBin = new Path ( '/usr/local/bin' )
23
+ if ( usrLocalBin . isDirectory ( ) ) {
24
+ directories . push ( usrLocalBin )
25
+ }
26
+
27
+ // Add ~/.local/bin if it exists
28
+ const localBin = Path . home ( ) . join ( '.local' , 'bin' )
29
+ if ( localBin . isDirectory ( ) ) {
30
+ directories . push ( localBin )
31
+ }
17
32
18
- // Add ~/.local/bin if it exists
19
- const localBin = Path . home ( ) . join ( '.local' , 'bin' )
20
- if ( localBin . isDirectory ( ) ) {
21
- directories . push ( localBin )
33
+ // Also check launchpad global directory for backwards compatibility
34
+ const globalBin = Path . home ( ) . join ( '.local' , 'share' , 'launchpad' , 'global' , 'bin' )
35
+ if ( globalBin . isDirectory ( ) ) {
36
+ directories . push ( globalBin )
37
+ }
22
38
}
23
39
24
40
return directories
25
41
}
26
42
43
+ /**
44
+ * Get all currently installed packages in the global directory
45
+ */
46
+ function getInstalledGlobalPackages ( ) : string [ ] {
47
+ const globalDir = Path . home ( ) . join ( '.local' , 'share' , 'launchpad' , 'global' )
48
+
49
+ if ( ! globalDir . exists ( ) ) {
50
+ return [ ]
51
+ }
52
+
53
+ const installedPackages : string [ ] = [ ]
54
+
55
+ try {
56
+ // Look for all domain directories (like nodejs.org, openssl.org, etc.)
57
+ const domainDirs = fs . readdirSync ( globalDir . string , { withFileTypes : true } )
58
+ . filter ( dirent => dirent . isDirectory ( ) && ! dirent . name . startsWith ( '.' ) && dirent . name !== 'bin' && dirent . name !== 'sbin' && dirent . name !== 'pkgs' )
59
+ . map ( dirent => dirent . name )
60
+
61
+ for ( const domain of domainDirs ) {
62
+ const domainPath = globalDir . join ( domain )
63
+
64
+ try {
65
+ // Check for version directories inside each domain
66
+ const versionDirs = fs . readdirSync ( domainPath . string , { withFileTypes : true } )
67
+ . filter ( dirent => dirent . isDirectory ( ) && dirent . name . startsWith ( 'v' ) )
68
+ . map ( dirent => dirent . name )
69
+
70
+ if ( versionDirs . length > 0 ) {
71
+ // Convert domain back to package name (reverse of resolvePackageName)
72
+ const packageName = getPackageNameFromDomain ( domain )
73
+ if ( packageName ) {
74
+ installedPackages . push ( packageName )
75
+ }
76
+ }
77
+ } catch {
78
+ // Skip directories we can't read
79
+ continue
80
+ }
81
+ }
82
+ } catch {
83
+ // If we can't read the global directory, return empty array
84
+ return [ ]
85
+ }
86
+
87
+ return installedPackages
88
+ }
89
+
90
+ /**
91
+ * Convert a domain back to package name (best effort)
92
+ */
93
+ function getPackageNameFromDomain ( domain : string ) : string | null {
94
+ // Common mappings from domain to package name
95
+ const domainToPackage : Record < string , string > = {
96
+ 'nodejs.org' : 'node' ,
97
+ 'python.org' : 'python' ,
98
+ 'rust-lang.org' : 'rust' ,
99
+ 'go.dev' : 'go' ,
100
+ 'openjdk.org' : 'java' ,
101
+ 'php.net' : 'php' ,
102
+ 'ruby-lang.org' : 'ruby'
103
+ }
104
+
105
+ return domainToPackage [ domain ] || domain . replace ( / \. ( o r g | c o m | n e t | d e v ) $ / , '' )
106
+ }
107
+
108
+ /**
109
+ * Handle cleanup of dependencies that are no longer needed
110
+ */
111
+ export async function handleDependencyCleanup ( packageName : string , isDryRun : boolean = false ) : Promise < void > {
112
+ try {
113
+ console . log ( `\n🔍 Checking for unused dependencies...` )
114
+
115
+ // Get the dependencies that were installed with this package
116
+ let packageDependencies : string [ ] = [ ]
117
+ try {
118
+ packageDependencies = await resolveAllDependencies ( [ packageName ] )
119
+ // Remove the main package itself from the dependencies list
120
+ packageDependencies = packageDependencies . filter ( dep => {
121
+ const depName = dep . split ( '@' ) [ 0 ]
122
+ const resolvedDep = resolvePackageName ( depName )
123
+ const resolvedMain = resolvePackageName ( packageName )
124
+ return resolvedDep !== resolvedMain
125
+ } )
126
+ } catch ( error ) {
127
+ console . warn ( `⚠️ Could not resolve dependencies for ${ packageName } : ${ error instanceof Error ? error . message : String ( error ) } ` )
128
+ console . log ( ` Skipping dependency cleanup.` )
129
+ return
130
+ }
131
+
132
+ if ( packageDependencies . length === 0 ) {
133
+ console . log ( ` No dependencies found for ${ packageName } ` )
134
+ return
135
+ }
136
+
137
+ // Get all currently installed packages (after removing the main package)
138
+ const installedPackages = getInstalledGlobalPackages ( )
139
+ const remainingPackages = installedPackages . filter ( pkg => {
140
+ const resolvedPkg = resolvePackageName ( pkg )
141
+ const resolvedMain = resolvePackageName ( packageName )
142
+ return resolvedPkg !== resolvedMain
143
+ } )
144
+
145
+ // Find dependencies that are no longer needed
146
+ const unusedDependencies : string [ ] = [ ]
147
+
148
+ for ( const dep of packageDependencies ) {
149
+ const depName = dep . split ( '@' ) [ 0 ]
150
+ let isStillNeeded = false
151
+
152
+ // Check if any remaining package still needs this dependency
153
+ for ( const remainingPkg of remainingPackages ) {
154
+ try {
155
+ const remainingDeps = await resolveAllDependencies ( [ remainingPkg ] )
156
+ const remainingDepNames = remainingDeps . map ( d => d . split ( '@' ) [ 0 ] )
157
+
158
+ if ( remainingDepNames . some ( rdep => resolvePackageName ( rdep ) === resolvePackageName ( depName ) ) ) {
159
+ isStillNeeded = true
160
+ break
161
+ }
162
+ } catch {
163
+ // If we can't resolve dependencies for a remaining package,
164
+ // assume its dependencies might be needed
165
+ isStillNeeded = true
166
+ break
167
+ }
168
+ }
169
+
170
+ if ( ! isStillNeeded ) {
171
+ unusedDependencies . push ( depName )
172
+ }
173
+ }
174
+
175
+ if ( unusedDependencies . length === 0 ) {
176
+ console . log ( ` All dependencies are still needed by other packages` )
177
+ return
178
+ }
179
+
180
+ const depWord = unusedDependencies . length === 1 ? 'dependency' : 'dependencies'
181
+ const actionWord = isDryRun ? 'Would find' : 'Found'
182
+ console . log ( `\n🧹 ${ actionWord } ${ unusedDependencies . length } unused ${ depWord } : ${ unusedDependencies . join ( ', ' ) } ` )
183
+ console . log ( ` These ${ unusedDependencies . length === 1 ? 'was' : 'were' } installed as ${ depWord } of ${ packageName } but ${ unusedDependencies . length === 1 ? 'is' : 'are' } no longer needed.` )
184
+
185
+ // Show removal suggestions
186
+ console . log ( `\n💡 To remove unused ${ depWord } , run:` )
187
+ for ( const dep of unusedDependencies ) {
188
+ console . log ( ` launchpad uninstall -g ${ dep } ` )
189
+ }
190
+ console . log ( `\n Or remove all at once: launchpad uninstall -g ${ unusedDependencies . join ( ' ' ) } ` )
191
+
192
+ } catch ( error ) {
193
+ console . warn ( `⚠️ Error during dependency cleanup: ${ error instanceof Error ? error . message : String ( error ) } ` )
194
+ }
195
+ }
196
+
27
197
/**
28
198
* Uninstall a package by name (supports aliases like 'node' -> 'nodejs.org')
29
199
*/
30
- export async function uninstall ( arg : string ) : Promise < boolean > {
200
+ export async function uninstall ( arg : string , isGlobal : boolean = false ) : Promise < boolean > {
31
201
// Extract package name without version
32
202
const [ packageName ] = arg . split ( '@' )
33
203
@@ -48,10 +218,13 @@ export async function uninstall(arg: string): Promise<boolean> {
48
218
}
49
219
50
220
// Get all possible binary directories
51
- const binDirectories = getPossibleBinaryDirectories ( )
221
+ const binDirectories = getPossibleBinaryDirectories ( isGlobal )
52
222
53
223
if ( binDirectories . length === 0 ) {
54
- console . error ( `❌ No binary directories found (checked /usr/local/bin and ~/.local/bin)` )
224
+ const checkedPaths = isGlobal
225
+ ? '~/.local/share/launchpad/global/bin'
226
+ : '/usr/local/bin, ~/.local/bin, and ~/.local/share/launchpad/global/bin'
227
+ console . error ( `❌ No binary directories found (checked ${ checkedPaths } )` )
55
228
return false
56
229
}
57
230
@@ -139,6 +312,11 @@ export async function uninstall(arg: string): Promise<boolean> {
139
312
return false
140
313
}
141
314
315
+ // Handle dependency cleanup for global uninstalls
316
+ if ( isGlobal && removedFiles . length > 0 ) {
317
+ await handleDependencyCleanup ( packageName , false )
318
+ }
319
+
142
320
return true
143
321
}
144
322
0 commit comments