Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move up

* 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].
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -85,11 +89,13 @@ protected boolean runWithPackageManager(boolean silent, List<PackageManagerComma
private void logPackageManagerCommands(PackageManagerCommand pmCommand) {

IdeLogLevel level = IdeLogLevel.INTERACTION;
level.log(LOG, "We need to run the following privileged command(s):");
level.log(LOG, "We need to run the following command(s):");
for (String command : pmCommand.commands()) {
level.log(LOG, command);
}
level.log(LOG, "This will require root permissions!");
if (pmCommand.packageManager().needsSudo()) {
level.log(LOG, "This will require root permissions!");
}
}

/**
Expand Down Expand Up @@ -310,8 +316,61 @@ public void uninstall() {
WindowsHelper.get(this.context).uninstallApplication(getWindowsRegistryAppName());
} else if (this.context.getSystemInfo().isLinux()) {
runWithPackageManager(false, getUninstallPackageManagerCommands(), NativePackageAction.UNINSTALL);
} else if (this.context.getSystemInfo().isMac()) {
uninstallMac();
} else {
LOG.error("Couldn't uninstall {} on this OS. Please uninstall manually.", this.getName());
}
}

/**
* Uninstalls this tool on macOS. Unlike Linux, macOS has no single standardized package manager, so we try the best-effort options in order and finally
* fall back to giving the user actionable guidance if nothing could be done automatically.
*/
private void uninstallMac() {
if (runWithPackageManager(false, getUninstallPackageManagerCommands(), NativePackageAction.UNINSTALL)) {
return;
}
Path appBundle = findMacApplicationBundle();
if (appBundle != null) {
this.context.getFileAccess().delete(appBundle);
IdeLogLevel.SUCCESS.log(LOG, "Successfully uninstalled {} by removing {}", this.tool, appBundle);
return;
}
String brewHint = "";
if (isPackageManagerAvailable(NativePackageManager.BREW_CASK)) {
brewHint = " or via Homebrew (e.g. 'brew uninstall " + this.tool + "' or 'brew uninstall --cask " + this.tool + "')";
}
LOG.error("Couldn't automatically uninstall {} on macOS. Please uninstall it manually, e.g. by moving it from the Applications folder to the Trash{}.",
this.getName(), brewHint);
}

/**
* @return the {@link Path} to the *.app bundle of this tool as found in one of the well-known macOS application folders, or {@code null} if
* {@link #getMacApplicationName() unknown} or not found there.
*/
private Path findMacApplicationBundle() {
String appName = getMacApplicationName();
if (appName == null) {
return null;
}
String bundleFileName = appName + ".app";
List<Path> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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),

/** <a href="https://brew.sh/">Homebrew</a> formula installation, the closest thing macOS has to a standard package manager. */
BREW("brew", "install", "uninstall", "@", "", false),

/** <a href="https://brew.sh/">Homebrew</a> 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;
}

/**
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -91,6 +111,8 @@ public List<String> getVersionQueryCommand(String pkg) {
List<String> 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;
Expand All @@ -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 <pkg>" is "<pkg> <version>" (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;
}
Expand All @@ -123,7 +151,11 @@ public String parseVersionQueryOutput(String output) {
public PackageManagerCommand install(NativePackage nativePackage, String version) {
verifyPackageManager(nativePackage);
List<String> 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);
}
Expand All @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,17 @@ protected List<NativePackage> 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() {

Expand Down
30 changes: 20 additions & 10 deletions cli/src/main/java/com/devonfw/tools/ide/tool/pgadmin/PgAdmin.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,26 @@ public PgAdmin(IdeContext context) {

@Override
protected List<NativePackage> 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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.devonfw.tools.ide.tool;

import java.nio.file.Path;
import java.util.List;
import java.util.Set;

Expand All @@ -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;
Expand Down Expand Up @@ -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<NativePackage> 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<PackageManagerCommand> 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");
}
}
Loading