Skip to content

Commit d6114bc

Browse files
committed
chore: wip
1 parent 9508751 commit d6114bc

File tree

4 files changed

+203
-17
lines changed

4 files changed

+203
-17
lines changed

packages/launchpad/bin/cli.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -954,9 +954,11 @@ cli
954954
.option('--verbose', 'Enable verbose output')
955955
.option('--force', 'Skip confirmation prompts')
956956
.option('--dry-run', 'Show what would be removed without actually removing it')
957+
.option('-g, --global', 'Remove packages from global installation directory')
957958
.example('launchpad uninstall node python')
958959
.example('launchpad remove node@18 --force')
959-
.action(async (packages: string[], options?: { verbose?: boolean, force?: boolean, dryRun?: boolean }) => {
960+
.example('launchpad uninstall -g node')
961+
.action(async (packages: string[], options?: { verbose?: boolean, force?: boolean, dryRun?: boolean, global?: boolean }) => {
960962
if (options?.verbose) {
961963
config.verbose = true
962964
}
@@ -981,6 +983,8 @@ cli
981983
argv.push('--force')
982984
if (options?.dryRun)
983985
argv.push('--dry-run')
986+
if (options?.global)
987+
argv.push('--global')
984988
const cmd = await resolveCommand('uninstall')
985989
if (!cmd)
986990
return

packages/launchpad/src/binary-downloader.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -279,8 +279,6 @@ export class PrecompiledBinaryDownloader {
279279
return phpConfig.configuration
280280
}
281281

282-
// PHP auto-detection has been removed - using fallback detection
283-
284282
// Fallback to basic detection
285283
try {
286284
// eslint-disable-next-line ts/no-require-imports

packages/launchpad/src/commands/uninstall.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const cmd: Command = {
1111
const verbose = argv.includes('--verbose')
1212
const force = argv.includes('--force')
1313
const dryRun = argv.includes('--dry-run')
14+
const global = argv.includes('--global')
1415

1516
if (verbose)
1617
config.verbose = true
@@ -44,9 +45,14 @@ const cmd: Command = {
4445
if (dryRun) {
4546
console.log(`Would uninstall: ${pkg}`)
4647
results.push({ package: pkg, success: true, message: 'dry run' })
48+
// Still check dependencies in dry-run mode
49+
if (global) {
50+
const { handleDependencyCleanup } = await import('../uninstall')
51+
await handleDependencyCleanup(pkg, true) // Pass true for dry-run mode
52+
}
4753
}
4854
else {
49-
const success = await uninstall(pkg)
55+
const success = await uninstall(pkg, global)
5056
results.push({ package: pkg, success })
5157
if (!success)
5258
allSuccess = false

packages/launchpad/src/uninstall.ts

Lines changed: 191 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,202 @@ import fs from 'node:fs'
22
import path from 'node:path'
33
import { getPackageInfo, resolvePackageName } from './install'
44
import { Path } from './path'
5+
import { resolveAllDependencies } from './dependency-resolution'
56

67
/**
78
* Get all possible binary directories where packages might be installed
89
*/
9-
function getPossibleBinaryDirectories(): Path[] {
10+
function getPossibleBinaryDirectories(isGlobal: boolean = false): Path[] {
1011
const directories: Path[] = []
1112

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+
}
1732

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+
}
2238
}
2339

2440
return directories
2541
}
2642

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(/\.(org|com|net|dev)$/, '')
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+
27197
/**
28198
* Uninstall a package by name (supports aliases like 'node' -> 'nodejs.org')
29199
*/
30-
export async function uninstall(arg: string): Promise<boolean> {
200+
export async function uninstall(arg: string, isGlobal: boolean = false): Promise<boolean> {
31201
// Extract package name without version
32202
const [packageName] = arg.split('@')
33203

@@ -48,10 +218,13 @@ export async function uninstall(arg: string): Promise<boolean> {
48218
}
49219

50220
// Get all possible binary directories
51-
const binDirectories = getPossibleBinaryDirectories()
221+
const binDirectories = getPossibleBinaryDirectories(isGlobal)
52222

53223
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})`)
55228
return false
56229
}
57230

@@ -139,6 +312,11 @@ export async function uninstall(arg: string): Promise<boolean> {
139312
return false
140313
}
141314

315+
// Handle dependency cleanup for global uninstalls
316+
if (isGlobal && removedFiles.length > 0) {
317+
await handleDependencyCleanup(packageName, false)
318+
}
319+
142320
return true
143321
}
144322

0 commit comments

Comments
 (0)