diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 3d493c82b3..ab9ecf06a3 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -28,6 +28,7 @@ Release with new features and bugfixes: * https://github.com/devonfw/IDEasy/issues/2219[#2219]: Add unpack commandlet * https://github.com/devonfw/IDEasy/issues/2251[#2251]: Provide generic uninstall support for globally installed tools (windows) * https://github.com/devonfw/IDEasy/issues/1135[#1135]: Fix PowerShell env variable initialization on Windows by sourcing functions from the PowerShell profile +* https://github.com/devonfw/IDEasy/issues/2252[#2252]: Add uninstall support for globally installed tools on macOS * https://github.com/devonfw/IDEasy/issues/741[#741]: Add a warning message for legacy devonfw-ide settings users The full list of changes for this release can be found in https://github.com/devonfw/IDEasy/milestone/49?closed=1[milestone 2026.08.002]. diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/GlobalToolCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/tool/GlobalToolCommandlet.java index fb276ce37b..10784c0710 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/GlobalToolCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/GlobalToolCommandlet.java @@ -32,6 +32,10 @@ public abstract class GlobalToolCommandlet extends ToolCommandlet { private static final Logger LOG = LoggerFactory.getLogger(GlobalToolCommandlet.class); + private static final String MAC_APPLICATIONS_FOLDER_NAME = "Applications"; + + private static final Path MAC_SYSTEM_APPLICATIONS_DIR = Path.of("/" + MAC_APPLICATIONS_FOLDER_NAME); + /** * The constructor. * @@ -85,11 +89,13 @@ protected boolean runWithPackageManager(boolean silent, List applicationsDirs = List.of(MAC_SYSTEM_APPLICATIONS_DIR, this.context.getUserHome().resolve(MAC_APPLICATIONS_FOLDER_NAME)); + for (Path applicationsDir : applicationsDirs) { + Path candidate = applicationsDir.resolve(bundleFileName); + if (Files.isDirectory(candidate)) { + return candidate; + } + } + return null; + } + + /** + * @return the name (without the ".app" suffix) of this tool's application bundle as it appears in the macOS Applications folder, or {@code null} if + * unknown so that {@link #uninstall() uninstall} cannot try to automatically remove it and instead gives the user manual guidance. Override this in + * subclasses that know their application bundle name (which may differ from {@link #getName() the tool name}, e.g. "Docker" for the tool "docker"). + */ + public String getMacApplicationName() { + return null; + } } diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/NativePackageManager.java b/cli/src/main/java/com/devonfw/tools/ide/tool/NativePackageManager.java index a742228c3c..b3280ff849 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/NativePackageManager.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/NativePackageManager.java @@ -8,30 +8,41 @@ */ public enum NativePackageManager { /** Advanced Package Tool (APT) is the package manager of Debian based Linux distributions. */ - APT("install -y", "-y autoremove --purge", "=", "*"), + APT("apt", "install -y", "-y autoremove --purge", "=", "*", true), /** Zypper is the package manager of SUSE based Linux distributions. */ - ZYPPER("--non-interactive install", "remove", "=", ""), + ZYPPER("zypper", "--non-interactive install", "remove", "=", "", true), /** Yellowdog Updater Modified (YUM) is the package manager of RPM package based Linux distributions like Fedora, Red Hat, or CentOS. */ - YUM("install -y", "remove -y", "-", "*"), + YUM("yum", "install -y", "remove -y", "-", "*", true), /** DaNdiFied yum (DNF) is the package manager of RPM package based Linux distributions like Fedora. It is the successor of {@link #YUM}. */ - DNF("install -y", "remove -y", "-", "*"); + DNF("dnf", "install -y", "remove -y", "-", "*", true), + + /** Homebrew formula installation, the closest thing macOS has to a standard package manager. */ + BREW("brew", "install", "uninstall", "@", "", false), + + /** Homebrew cask installation, used for macOS GUI applications distributed as *.app bundles. */ + BREW_CASK("brew", "install --cask", "uninstall --cask", "@", "", false); private static final String DPKG_STATUS_INSTALLED = "installed"; private static final String SUDO = "sudo"; + private final String binaryName; private final String installCommand; private final String uninstallCommand; private final String versionSeparator; private final String versionWildCard; + private final boolean sudo; - NativePackageManager(String installCommand, String uninstallCommand, String versionSeparator, String versionWildCard) { + NativePackageManager(String binaryName, String installCommand, String uninstallCommand, String versionSeparator, String versionWildCard, + boolean sudo) { + this.binaryName = binaryName; this.installCommand = installCommand; this.uninstallCommand = uninstallCommand; this.versionSeparator = versionSeparator; this.versionWildCard = versionWildCard; + this.sudo = sudo; } /** @@ -61,7 +72,16 @@ public static NativePackageManager extractPackageManager(String command) { public String getBinaryName() { - return name().toLowerCase(); + return this.binaryName; + } + + /** + * @return {@code true} if commands of this {@link NativePackageManager} need to be run with {@code sudo} (root permissions), {@code false} otherwise (e.g. + * for {@link #BREW}/{@link #BREW_CASK} that must never be run as root). + */ + public boolean needsSudo() { + + return this.sudo; } /** @@ -91,6 +111,8 @@ public List getVersionQueryCommand(String pkg) { List command = new ArrayList<>(switch (this) { case APT -> List.of("dpkg-query", "-W", "-f=${db:Status-Status}|${Version}"); case ZYPPER, YUM, DNF -> List.of("rpm", "-q", "--queryformat", "%{VERSION}"); + case BREW -> List.of(getBinaryName(), "list", "--versions"); + case BREW_CASK -> List.of(getBinaryName(), "list", "--cask", "--versions"); }); command.add(pkg); return command; @@ -111,6 +133,12 @@ public String parseVersionQueryOutput(String output) { return null; } version = parts[1].trim(); + } else if ((this == BREW) || (this == BREW_CASK)) { + // output of "brew list --versions " is " " (possibly multiple space-separated versions, we take the last/newest one) + int lastSpace = version.lastIndexOf(' '); + if (lastSpace >= 0) { + version = version.substring(lastSpace + 1).trim(); + } } return version.isEmpty() ? null : version; } @@ -123,7 +151,11 @@ public String parseVersionQueryOutput(String output) { public PackageManagerCommand install(NativePackage nativePackage, String version) { verifyPackageManager(nativePackage); List commands = new ArrayList<>(nativePackage.getSetupCommands()); - StringBuilder command = new StringBuilder(SUDO).append(' ').append(getBinaryName()); + StringBuilder command = new StringBuilder(); + if (this.sudo) { + command.append(SUDO).append(' '); + } + command.append(getBinaryName()); for (String option : nativePackage.getExtraInstallOptions()) { command.append(' ').append(option); } @@ -142,7 +174,11 @@ public PackageManagerCommand install(NativePackage nativePackage, String version */ public PackageManagerCommand uninstall(NativePackage nativePackage) { verifyPackageManager(nativePackage); - StringBuilder command = new StringBuilder(SUDO).append(' ').append(getBinaryName()).append(' ').append(this.uninstallCommand); + StringBuilder command = new StringBuilder(); + if (this.sudo) { + command.append(SUDO).append(' '); + } + command.append(getBinaryName()).append(' ').append(this.uninstallCommand); for (String pkg : nativePackage.getPackages()) { command.append(' ').append(pkg); } diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/docker/Docker.java b/cli/src/main/java/com/devonfw/tools/ide/tool/docker/Docker.java index 5e924d6e2d..3320992497 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/docker/Docker.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/docker/Docker.java @@ -92,10 +92,17 @@ protected List getNativePackages() { "sudo rm -f /etc/apt/sources.list.d/isv-rancher-stable.list", "sudo rm -f /usr/share/keyrings/isv-rancher-stable-archive-keyring.gpg" ) - ) + ), + new NativePackage(NativePackageManager.BREW_CASK, List.of("docker")) ); } + @Override + public String getMacApplicationName() { + + return "Docker"; + } + @Override public boolean isExtract() { diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/pgadmin/PgAdmin.java b/cli/src/main/java/com/devonfw/tools/ide/tool/pgadmin/PgAdmin.java index a07f1bce3a..0c01627137 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/pgadmin/PgAdmin.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/pgadmin/PgAdmin.java @@ -32,16 +32,26 @@ public PgAdmin(IdeContext context) { @Override protected List getNativePackages() { - return List.of(new NativePackage( - NativePackageManager.APT, - List.of("pgadmin4", "pgadmin4-server", "pgadmin4-desktop", "pgadmin4-web"), - List.of("--allow-downgrades"), - List.of("curl -fsS https://www.pgadmin.org/static/packages_pgadmin_org.pub | sudo gpg --yes --dearmor -o /usr/share/keyrings/packages-pgadmin-org.gpg", - "sudo sh -c 'echo \"deb [signed-by=/usr/share/keyrings/packages-pgadmin-org.gpg] " - + "https://ftp.postgresql.org/pub/pgadmin/pgadmin4/apt/$(lsb_release -cs) pgadmin4 main\" " - + "> /etc/apt/sources.list.d/pgadmin4.list && apt update'"), - List.of("sudo rm -f /etc/apt/sources.list.d/pgadmin4.list", "sudo rm -f /usr/share/keyrings/packages-pgadmin-org.gpg") - )); + return List.of( + new NativePackage( + NativePackageManager.APT, + List.of("pgadmin4", "pgadmin4-server", "pgadmin4-desktop", "pgadmin4-web"), + List.of("--allow-downgrades"), + List.of( + "curl -fsS https://www.pgadmin.org/static/packages_pgadmin_org.pub | sudo gpg --yes --dearmor -o /usr/share/keyrings/packages-pgadmin-org.gpg", + "sudo sh -c 'echo \"deb [signed-by=/usr/share/keyrings/packages-pgadmin-org.gpg] " + + "https://ftp.postgresql.org/pub/pgadmin/pgadmin4/apt/$(lsb_release -cs) pgadmin4 main\" " + + "> /etc/apt/sources.list.d/pgadmin4.list && apt update'"), + List.of("sudo rm -f /etc/apt/sources.list.d/pgadmin4.list", "sudo rm -f /usr/share/keyrings/packages-pgadmin-org.gpg") + ), + new NativePackage(NativePackageManager.BREW_CASK, List.of("pgadmin4")) + ); + } + + @Override + public String getMacApplicationName() { + + return "pgAdmin 4"; } @Override diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/GlobalToolCommandletTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/GlobalToolCommandletTest.java index ede372170f..ca1d0b6fde 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/GlobalToolCommandletTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/GlobalToolCommandletTest.java @@ -1,5 +1,6 @@ package com.devonfw.tools.ide.tool; +import java.nio.file.Path; import java.util.List; import java.util.Set; @@ -9,6 +10,7 @@ import com.devonfw.tools.ide.context.AbstractIdeContextTest; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.context.IdeTestContext; +import com.devonfw.tools.ide.log.IdeLogEntry; import com.devonfw.tools.ide.os.SystemInfoMock; import com.devonfw.tools.ide.os.WindowsAppInstallation; import com.devonfw.tools.ide.os.WindowsHelperMock; @@ -199,4 +201,117 @@ void testGetUninstallPackageManagerCommandsDerivesFromNativePackages() { "sudo apt -y autoremove --purge mytool", "sudo rm -f /etc/apt/sources.list.d/mytool.list"); } + + /** + * Dummy {@link GlobalToolCommandlet} that declares a Homebrew cask for testing macOS uninstall via {@link NativePackageManager#BREW_CASK}. + */ + static class BrewCaskToolCommandlet extends GlobalToolCommandlet { + + private static final String TOOL_NAME = "mytool"; + + BrewCaskToolCommandlet(IdeContext context) { + + super(context, TOOL_NAME, Set.of(Tag.MISC)); + } + + @Override + protected List getNativePackages() { + + return List.of(new NativePackage(NativePackageManager.BREW_CASK, List.of(TOOL_NAME))); + } + + @Override + protected String getBinaryName() { + return TOOL_NAME; + } + } + + /** + * Verifies that {@link GlobalToolCommandlet#getUninstallPackageManagerCommands()} derives a Homebrew cask uninstall command without a {@code sudo} prefix + * (Homebrew must never be run as root). + */ + @Test + void testGetUninstallPackageManagerCommandsDerivesBrewCaskWithoutSudo() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + context.setSystemInfo(SystemInfoMock.MAC_X64); + BrewCaskToolCommandlet commandlet = new BrewCaskToolCommandlet(context); + + // act + List uninstallCommands = commandlet.getUninstallPackageManagerCommands(); + + // assert + assertThat(uninstallCommands).hasSize(1); + PackageManagerCommand cmd = uninstallCommands.getFirst(); + assertThat(cmd.packageManager()).isEqualTo(NativePackageManager.BREW_CASK); + assertThat(cmd.commands()).containsExactly("brew uninstall --cask mytool"); + } + + /** + * Dummy {@link GlobalToolCommandlet} that declares a known macOS application bundle name but no package manager, for testing the *.app removal fallback of + * {@link GlobalToolCommandlet#uninstall()}. + */ + static class MacAppBundleToolCommandlet extends GlobalToolCommandlet { + + private static final String TOOL_NAME = "mytool"; + + MacAppBundleToolCommandlet(IdeContext context) { + + super(context, TOOL_NAME, Set.of(Tag.MISC)); + } + + @Override + protected String getBinaryName() { + return TOOL_NAME; + } + + @Override + public String getMacApplicationName() { + return "MyTool"; + } + } + + /** + * Verifies that on macOS, when no package manager can uninstall the tool, {@link GlobalToolCommandlet#uninstall()} removes the known *.app bundle from the + * user's Applications folder. + */ + @Test + void testUninstallOnMacRemovesKnownApplicationBundle() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + context.setSystemInfo(SystemInfoMock.MAC_X64); + MacAppBundleToolCommandlet commandlet = new MacAppBundleToolCommandlet(context); + Path appBundle = context.getUserHome().resolve("Applications").resolve("MyTool.app"); + context.getFileAccess().mkdirs(appBundle); + + // act + commandlet.uninstall(); + + // assert + assertThat(appBundle).doesNotExist(); + assertThat(context).log().hasEntries(IdeLogEntry.ofSuccess("Successfully uninstalled mytool by removing " + appBundle)); + } + + /** + * Verifies that on macOS, when neither a package manager nor a known *.app bundle can be found, {@link GlobalToolCommandlet#uninstall()} logs actionable + * manual-uninstall guidance instead of the previous generic "uninstall manually" error. + */ + @Test + void testUninstallOnMacLogsManualGuidanceWhenNothingFound() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + context.setSystemInfo(SystemInfoMock.MAC_X64); + AsyncInstallerToolCommandlet commandlet = new AsyncInstallerToolCommandlet(context); + + // act + commandlet.uninstall(); + + // assert + assertThat(context).logAtError().hasMessageContaining( + "Couldn't automatically uninstall " + TOOL_NAME + " on macOS. Please uninstall it manually, e.g. by moving it from the Applications folder to the " + + "Trash"); + } } diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/NativePackageManagerTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/NativePackageManagerTest.java index b57c8d8e98..7fb8b848fb 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/NativePackageManagerTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/NativePackageManagerTest.java @@ -192,4 +192,49 @@ void testParseVersionQueryOutputForRpmBasedPackageManager() { assertThat(NativePackageManager.YUM.parseVersionQueryOutput("1.0.0")).isEqualTo("1.0.0"); assertThat(NativePackageManager.DNF.parseVersionQueryOutput("1.0.0")).isEqualTo("1.0.0"); } + + @Test + void testBrewInstallAndUninstallCommandsDoNotUseSudo() { + NativePackage np = NativePackage.of(NativePackageManager.BREW, "pkg1"); + + var installCmd = NativePackageManager.BREW.install(np, "1.0.0"); + var uninstallCmd = NativePackageManager.BREW.uninstall(np); + + assertThat(installCmd.commands()).containsExactly("brew install pkg1@1.0.0"); + assertThat(uninstallCmd.commands()).containsExactly("brew uninstall pkg1"); + } + + @Test + void testBrewCaskInstallAndUninstallCommandsDoNotUseSudo() { + NativePackage np = NativePackage.of(NativePackageManager.BREW_CASK, "docker"); + + var installCmd = NativePackageManager.BREW_CASK.install(np, null); + var uninstallCmd = NativePackageManager.BREW_CASK.uninstall(np); + + assertThat(installCmd.commands()).containsExactly("brew install --cask docker"); + assertThat(uninstallCmd.commands()).containsExactly("brew uninstall --cask docker"); + } + + @Test + void testVersionQueryCommandForBrew() { + assertThat(NativePackageManager.BREW.getVersionQueryCommand("pkg1")).containsExactly("brew", "list", "--versions", "pkg1"); + assertThat(NativePackageManager.BREW_CASK.getVersionQueryCommand("pkg1")).containsExactly("brew", "list", "--cask", "--versions", "pkg1"); + } + + @Test + void testParseVersionQueryOutputForBrew() { + assertThat(NativePackageManager.BREW.parseVersionQueryOutput("pkg1 1.2.3")).isEqualTo("1.2.3"); + assertThat(NativePackageManager.BREW_CASK.parseVersionQueryOutput("docker 24.0.0")).isEqualTo("24.0.0"); + assertThat(NativePackageManager.BREW.parseVersionQueryOutput("")).isNull(); + } + + @Test + void testNeedsSudo() { + assertThat(NativePackageManager.APT.needsSudo()).isTrue(); + assertThat(NativePackageManager.ZYPPER.needsSudo()).isTrue(); + assertThat(NativePackageManager.YUM.needsSudo()).isTrue(); + assertThat(NativePackageManager.DNF.needsSudo()).isTrue(); + assertThat(NativePackageManager.BREW.needsSudo()).isFalse(); + assertThat(NativePackageManager.BREW_CASK.needsSudo()).isFalse(); + } }