From e4c624ca3cda8387cbf5d54711942607ceff8fe2 Mon Sep 17 00:00:00 2001 From: ShodiBoy1 Date: Sat, 13 Jun 2026 00:30:38 +0200 Subject: [PATCH 1/6] fixed pgAdmin macOS dmg installation --- .../tools/ide/tool/GlobalToolCommandlet.java | 14 +- .../tools/ide/tool/pgadmin/PgAdmin.java | 149 +++++++++++++++++- .../tools/ide/tool/pgadmin/PgAdminTest.java | 118 ++++++++++++++ 3 files changed, 275 insertions(+), 6 deletions(-) create mode 100644 cli/src/test/java/com/devonfw/tools/ide/tool/pgadmin/PgAdminTest.java 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 acff7cc211..fa992c6619 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 @@ -77,16 +77,26 @@ protected boolean runWithPackageManager(boolean silent, List commands) { IdeLogLevel level = IdeLogLevel.INTERACTION; level.log(LOG, "We need to run the following privileged command(s):"); - for (String command : pmCommand.commands()) { + for (String command : commands) { level.log(LOG, command); } level.log(LOG, "This will require root permissions!"); } + private void logPackageManagerCommands(PackageManagerCommand pmCommand) { + + logPrivilegedCommands(pmCommand.commands()); + } + /** * Executes the provided package manager command. * 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 9652701931..00f27d941e 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 @@ -8,12 +8,24 @@ import java.util.List; import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.devonfw.tools.ide.cli.CliException; import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.io.FileAccess; +import com.devonfw.tools.ide.log.IdeLogLevel; import com.devonfw.tools.ide.os.WindowsHelper; +import com.devonfw.tools.ide.process.ProcessErrorHandling; +import com.devonfw.tools.ide.process.ProcessMode; +import com.devonfw.tools.ide.step.Step; import com.devonfw.tools.ide.tool.GlobalToolCommandlet; import com.devonfw.tools.ide.tool.NativePackageManager; import com.devonfw.tools.ide.tool.PackageManagerCommand; +import com.devonfw.tools.ide.tool.ToolEdition; +import com.devonfw.tools.ide.tool.ToolInstallRequest; +import com.devonfw.tools.ide.tool.ToolInstallation; import com.devonfw.tools.ide.tool.repository.ToolRepository; import com.devonfw.tools.ide.version.VersionIdentifier; @@ -22,6 +34,10 @@ */ public class PgAdmin extends GlobalToolCommandlet { + private static final Logger LOG = LoggerFactory.getLogger(PgAdmin.class); + + private static final String PGADMIN_APP = "pgAdmin 4.app"; + /** * The constructor. * @@ -32,6 +48,106 @@ public PgAdmin(IdeContext context) { super(context, "pgadmin", Set.of(Tag.DB, Tag.ADMIN)); } + @Override + protected ToolInstallation doInstall(ToolInstallRequest request) { + + if (this.context.getSystemInfo().isMac()) { + return doInstallOnMac(request); + } + return super.doInstall(request); + } + + private ToolInstallation doInstallOnMac(ToolInstallRequest request) { + + VersionIdentifier resolvedVersion = request.getRequested().getResolvedVersion(); + ToolEdition toolEdition = getToolWithConfiguredEdition(); + Path installationPath = getInstallationPath(toolEdition.edition(), resolvedVersion); + if ((installationPath != null) && !this.context.isForceMode()) { + return toolAlreadyInstalled(request); + } + + resolvedVersion = cveCheck(request); + Path dmg = getToolRepository().download(this.tool, toolEdition.edition(), resolvedVersion, this); + Path appPath = installMacDmg(dmg); + + installationPath = getInstallationPath(toolEdition.edition(), resolvedVersion); + if (installationPath == null) { + throw new CliException("The tool " + this.tool + " was installed but the pgAdmin app could not be found in " + getMacApplicationsPath() + "."); + } + IdeLogLevel.SUCCESS.log(LOG, "Successfully installed {} in version {} at {}", this.tool, resolvedVersion, appPath); + Step step = request.getStep(); + if (step != null) { + step.success(true); + } + return createToolInstallation(installationPath, resolvedVersion, true, request.getProcessContext(), request.isAdditionalInstallation()); + } + + private Path installMacDmg(Path dmg) { + + FileAccess fileAccess = this.context.getFileAccess(); + Path mountPath = fileAccess.createTempDir("pgadmin-dmg"); + boolean mounted = false; + try { + this.context.newProcess().executable("hdiutil").addArgs("attach", "-quiet", "-nobrowse", "-mountpoint", mountPath, dmg).run(); + mounted = true; + + Path sourceApp = getMacOsHelper().findAppDir(mountPath); + if (sourceApp == null) { + throw new CliException("No pgAdmin .app bundle was found in " + dmg + "."); + } + + Path targetApp = getMacApplicationsPath().resolve(PGADMIN_APP); + if (Files.exists(targetApp) && this.context.isForceMode()) { + runPrivileged("/bin/rm", "-rf", targetApp.toString()); + } + runPrivileged("/usr/bin/ditto", sourceApp.toString(), targetApp.toString()); + return targetApp; + } finally { + if (mounted) { + this.context.newProcess().errorHandling(ProcessErrorHandling.LOG_WARNING).executable("hdiutil") + .addArgs("detach", "-force", mountPath).run(ProcessMode.DEFAULT_SILENT); + } + deleteMountPath(mountPath, fileAccess); + } + } + + private void deleteMountPath(Path mountPath, FileAccess fileAccess) { + + try { + if (Files.isDirectory(mountPath) && (fileAccess.findFirst(mountPath, p -> true, false) != null)) { + LOG.warn("Skipping deletion of temporary pgAdmin DMG mount path {} because it still contains files.", mountPath); + return; + } + fileAccess.delete(mountPath); + } catch (RuntimeException e) { + LOG.warn("Failed to delete temporary pgAdmin DMG mount path {}.", mountPath, e); + } + } + + private void runPrivileged(String... command) { + + logPrivilegedCommands(List.of(toSudoCommandLine(command))); + this.context.newProcess().executable("sudo").addArgs(command).run(); + } + + private static String toSudoCommandLine(String... command) { + + StringBuilder commandLine = new StringBuilder("sudo"); + for (String argument : command) { + commandLine.append(' '); + commandLine.append(quoteShellArgument(argument)); + } + return commandLine.toString(); + } + + private static String quoteShellArgument(String argument) { + + if (argument.indexOf(' ') < 0 && argument.indexOf('\'') < 0) { + return argument; + } + return "'" + argument.replace("'", "'\"'\"'") + "'"; + } + @Override protected List getInstallPackageManagerCommands() { @@ -74,15 +190,40 @@ private List getPackageManagerCommandsUninstall() { @Override protected String getBinaryName() { + if (this.context.getSystemInfo().isMac()) { + return "pgAdmin4"; + } return "pgadmin4"; } @Override protected Path getInstallationPath(String edition, VersionIdentifier resolvedVersion) { - if (super.getInstallationPath(edition, resolvedVersion) == null) { - if (this.context.getSystemInfo().isWindows()) { - return getExecutableFolderFromWindowsRegistry(); - } + + Path installationPath = super.getInstallationPath(edition, resolvedVersion); + if (installationPath != null) { + return installationPath; + } + if (this.context.getSystemInfo().isMac()) { + return getMacOsAppPath(); + } + if (this.context.getSystemInfo().isWindows()) { + return getExecutableFolderFromWindowsRegistry(); + } + return null; + } + + protected Path getMacApplicationsPath() { + + return Path.of("/Applications"); + } + + private Path getMacOsAppPath() { + + Path appPath = getMacApplicationsPath().resolve(PGADMIN_APP); + Path binary = appPath.resolve("Contents").resolve("MacOS").resolve(getBinaryName()); + if (Files.isExecutable(binary)) { + this.context.getPath().setPath(getName(), binary.getParent()); + return appPath; } return null; } diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/pgadmin/PgAdminTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/pgadmin/PgAdminTest.java new file mode 100644 index 0000000000..5d09b821d2 --- /dev/null +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/pgadmin/PgAdminTest.java @@ -0,0 +1,118 @@ +package com.devonfw.tools.ide.tool.pgadmin; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import com.devonfw.tools.ide.context.AbstractIdeContextTest; +import com.devonfw.tools.ide.context.IdeTestContext; +import com.devonfw.tools.ide.os.SystemInfoMock; +import com.devonfw.tools.ide.version.VersionIdentifier; + +/** + * Test of {@link PgAdmin}. + */ +class PgAdminTest extends AbstractIdeContextTest { + + /** + * Tests that the pgAdmin binary name matches the target operating system. + * + * @param os the operating system to simulate. + * @param binaryName the expected binary name. + */ + @ParameterizedTest + @CsvSource({ "windows, pgadmin4", "linux, pgadmin4", "mac, pgAdmin4" }) + void testGetBinaryName(String os, String binaryName) { + + // arrange + IdeTestContext context = new IdeTestContext(); + context.setSystemInfo(SystemInfoMock.of(os)); + PgAdmin pgAdmin = new PgAdmin(context); + + // act & assert + assertThat(pgAdmin.getBinaryName()).isEqualTo(binaryName); + } + + /** + * Tests that {@link PgAdmin#getInstallationPath(String, VersionIdentifier)} preserves the generic PATH based result. + * + * @param tempDir the temporary directory. + * @throws IOException on test setup failure. + */ + @Test + @DisabledOnOs(OS.WINDOWS) + void testGetInstallationPathKeepsPathResult(@TempDir Path tempDir) throws IOException { + + // arrange + IdeTestContext context = new IdeTestContext(); + context.setSystemInfo(SystemInfoMock.LINUX_X64); + Path installationPath = tempDir.resolve("pgadmin"); + Path binPath = installationPath.resolve("bin"); + Path binary = binPath.resolve("pgadmin4"); + Files.createDirectories(binPath); + Files.writeString(binary, "test"); + context.getFileAccess().makeExecutable(binary); + context.getPath().setPath("pgadmin", binPath); + PgAdmin pgAdmin = new TestPgAdmin(context, tempDir.resolve("Applications")); + + // act + Path result = pgAdmin.getInstallationPath("pgadmin", VersionIdentifier.of("8.13")); + + // assert + assertThat(result).isEqualTo(installationPath); + } + + /** + * Tests that macOS installation detection resolves the pgAdmin app bundle and registers its executable folder. + * + * @param tempDir the temporary directory. + * @throws IOException on test setup failure. + */ + @Test + @DisabledOnOs(OS.WINDOWS) + void testGetInstallationPathFindsMacApp(@TempDir Path tempDir) throws IOException { + + // arrange + IdeTestContext context = new IdeTestContext(); + context.setSystemInfo(SystemInfoMock.MAC_X64); + Path applicationsPath = tempDir.resolve("Applications"); + Path appPath = applicationsPath.resolve("pgAdmin 4.app"); + Path appBinPath = appPath.resolve("Contents").resolve("MacOS"); + Path binary = appBinPath.resolve("pgAdmin4"); + Files.createDirectories(appBinPath); + Files.writeString(binary, "test"); + context.getFileAccess().makeExecutable(binary); + PgAdmin pgAdmin = new TestPgAdmin(context, applicationsPath); + + // act + Path result = pgAdmin.getInstallationPath("pgadmin", VersionIdentifier.of("8.13")); + + // assert + assertThat(result).isEqualTo(appPath); + assertThat(context.getPath().getPath("pgadmin")).isEqualTo(appBinPath); + } + + private static class TestPgAdmin extends PgAdmin { + + private final Path applicationsPath; + + TestPgAdmin(IdeTestContext context, Path applicationsPath) { + + super(context); + this.applicationsPath = applicationsPath; + } + + @Override + protected Path getMacApplicationsPath() { + + return this.applicationsPath; + } + } +} From 50e7c0260120912589d48a8bedc03f9a7e4938b9 Mon Sep 17 00:00:00 2001 From: ShodiBoy1 Date: Sat, 13 Jun 2026 00:49:05 +0200 Subject: [PATCH 2/6] changelog upd --- CHANGELOG.adoc | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 53a168492d..3c92ce321c 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -8,6 +8,7 @@ Release with new features and bugfixes: * https://github.com/devonfw/IDEasy/issues/1846[#1846]: Fixed macOS Gatekeeper workaround * https://github.com/devonfw/IDEasy/issues/1906[#1906]: Fixed `ide uninstall` failing on macOS +* https://github.com/devonfw/IDEasy/issues/798[#798]: Fixed pgAdmin DMG install on macOS * https://github.com/devonfw/IDEasy/issues/1985[#1985]: Fix path traversal errors when extracting ZIPs on Windows * https://github.com/devonfw/IDEasy/issues/1255[#1255]: Enhance snapshot version recognition in IDEasy * https://github.com/devonfw/IDEasy/issues/1964[#1964]: Fixed gui not launching with older project java versions From 393bea5a483d8d6728c57b97a328970d0a832dfc Mon Sep 17 00:00:00 2001 From: ShodiBoy1 Date: Sat, 13 Jun 2026 02:01:40 +0200 Subject: [PATCH 3/6] avoid unnecessary sudo for pgAdmin --- .../devonfw/tools/ide/tool/pgadmin/PgAdmin.java | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) 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 00f27d941e..2802273b45 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 @@ -19,6 +19,7 @@ import com.devonfw.tools.ide.os.WindowsHelper; import com.devonfw.tools.ide.process.ProcessErrorHandling; import com.devonfw.tools.ide.process.ProcessMode; +import com.devonfw.tools.ide.process.ProcessResult; import com.devonfw.tools.ide.step.Step; import com.devonfw.tools.ide.tool.GlobalToolCommandlet; import com.devonfw.tools.ide.tool.NativePackageManager; @@ -98,9 +99,9 @@ private Path installMacDmg(Path dmg) { Path targetApp = getMacApplicationsPath().resolve(PGADMIN_APP); if (Files.exists(targetApp) && this.context.isForceMode()) { - runPrivileged("/bin/rm", "-rf", targetApp.toString()); + runWithPrivilegeFallback("/bin/rm", "-rf", targetApp.toString()); } - runPrivileged("/usr/bin/ditto", sourceApp.toString(), targetApp.toString()); + runWithPrivilegeFallback("/usr/bin/ditto", sourceApp.toString(), targetApp.toString()); return targetApp; } finally { if (mounted) { @@ -130,6 +131,16 @@ private void runPrivileged(String... command) { this.context.newProcess().executable("sudo").addArgs(command).run(); } + private void runWithPrivilegeFallback(String... command) { + + ProcessResult result = this.context.newProcess().errorHandling(ProcessErrorHandling.NONE).executable(command[0]) + .addArgs(Arrays.copyOfRange(command, 1, command.length)).run(ProcessMode.DEFAULT); + if (!result.isSuccessful()) { + LOG.debug("Command {} failed without elevated privileges. Retrying with sudo.", List.of(command)); + runPrivileged(command); + } + } + private static String toSudoCommandLine(String... command) { StringBuilder commandLine = new StringBuilder("sudo"); From e98a6e89fce2e689c984be43b00eda07c32af5b1 Mon Sep 17 00:00:00 2001 From: ShodiBoy1 Date: Sat, 13 Jun 2026 02:11:54 +0200 Subject: [PATCH 4/6] value fix --- .../main/java/com/devonfw/tools/ide/tool/pgadmin/PgAdmin.java | 2 +- .../java/com/devonfw/tools/ide/tool/pgadmin/PgAdminTest.java | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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 2802273b45..7129221724 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 @@ -202,7 +202,7 @@ private List getPackageManagerCommandsUninstall() { protected String getBinaryName() { if (this.context.getSystemInfo().isMac()) { - return "pgAdmin4"; + return "pgAdmin 4"; } return "pgadmin4"; } diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/pgadmin/PgAdminTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/pgadmin/PgAdminTest.java index 5d09b821d2..4391ffda24 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/pgadmin/PgAdminTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/pgadmin/PgAdminTest.java @@ -28,7 +28,7 @@ class PgAdminTest extends AbstractIdeContextTest { * @param binaryName the expected binary name. */ @ParameterizedTest - @CsvSource({ "windows, pgadmin4", "linux, pgadmin4", "mac, pgAdmin4" }) + @CsvSource({ "windows, pgadmin4", "linux, pgadmin4", "mac, pgAdmin 4" }) void testGetBinaryName(String os, String binaryName) { // arrange @@ -85,7 +85,7 @@ void testGetInstallationPathFindsMacApp(@TempDir Path tempDir) throws IOExceptio Path applicationsPath = tempDir.resolve("Applications"); Path appPath = applicationsPath.resolve("pgAdmin 4.app"); Path appBinPath = appPath.resolve("Contents").resolve("MacOS"); - Path binary = appBinPath.resolve("pgAdmin4"); + Path binary = appBinPath.resolve("pgAdmin 4"); Files.createDirectories(appBinPath); Files.writeString(binary, "test"); context.getFileAccess().makeExecutable(binary); From 9b06783580b2bd1ab4b0c2cef0a0b5a287dd8bb1 Mon Sep 17 00:00:00 2001 From: ShodiBoy1 Date: Wed, 17 Jun 2026 00:22:06 +0200 Subject: [PATCH 5/6] fix pgAdmin --- .../devonfw/tools/ide/context/IdeContext.java | 3 + .../devonfw/tools/ide/io/FileAccessImpl.java | 34 ++-- .../tools/ide/tool/GlobalToolCommandlet.java | 140 ++++++++++++++--- .../tools/ide/tool/pgadmin/PgAdmin.java | 148 +----------------- .../ide/tool/GlobalToolCommandletTest.java | 105 +++++++++++++ 5 files changed, 251 insertions(+), 179 deletions(-) create mode 100644 cli/src/test/java/com/devonfw/tools/ide/tool/GlobalToolCommandletTest.java diff --git a/cli/src/main/java/com/devonfw/tools/ide/context/IdeContext.java b/cli/src/main/java/com/devonfw/tools/ide/context/IdeContext.java index 83030135d0..b9cbc2080b 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/context/IdeContext.java +++ b/cli/src/main/java/com/devonfw/tools/ide/context/IdeContext.java @@ -112,6 +112,9 @@ public interface IdeContext extends IdeStartContext { /** The name of the Contents folder inside a MacOS app. */ String FOLDER_CONTENTS = "Contents"; + /** The name of the MacOS folder inside the Contents of a MacOS app, holding the executable. */ + String FOLDER_MAC_OS = "MacOS"; + /** The name of the Resources folder inside a MacOS app. */ String FOLDER_RESOURCES = "Resources"; diff --git a/cli/src/main/java/com/devonfw/tools/ide/io/FileAccessImpl.java b/cli/src/main/java/com/devonfw/tools/ide/io/FileAccessImpl.java index 8681aa9bba..a8318aee5d 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/io/FileAccessImpl.java +++ b/cli/src/main/java/com/devonfw/tools/ide/io/FileAccessImpl.java @@ -62,6 +62,7 @@ import com.devonfw.tools.ide.io.ini.IniSection; import com.devonfw.tools.ide.os.SystemInfoImpl; import com.devonfw.tools.ide.process.ProcessContext; +import com.devonfw.tools.ide.process.ProcessErrorHandling; import com.devonfw.tools.ide.process.ProcessMode; import com.devonfw.tools.ide.process.ProcessResult; import com.devonfw.tools.ide.util.DateTimeUtil; @@ -1000,18 +1001,29 @@ public void extractDmg(Path file, Path targetDir) { Path mountPath = this.context.getIdeHome().resolve(IdeContext.FOLDER_UPDATES).resolve(IdeContext.FOLDER_VOLUME); mkdirs(mountPath); - ProcessContext pc = this.context.newProcess(); - pc.executable("hdiutil"); - pc.addArgs("attach", "-quiet", "-nobrowse", "-mountpoint", mountPath, file); - pc.run(); - Path appPath = findFirst(mountPath, p -> p.getFileName().toString().endsWith(".app"), false); - if (appPath == null) { - throw new IllegalStateException("Failed to unpack DMG as no MacOS *.app was found in file " + file); - } + boolean mounted = false; + try { + ProcessContext pc = this.context.newProcess(); + pc.executable("hdiutil"); + pc.addArgs("attach", "-quiet", "-nobrowse", "-mountpoint", mountPath, file); + pc.run(); + mounted = true; + Path appPath = findFirst(mountPath, p -> p.getFileName().toString().endsWith(".app"), false); + if (appPath == null) { + throw new IllegalStateException("Failed to unpack DMG as no MacOS *.app was found in file " + file); + } - copy(appPath, targetDir, FileCopyMode.COPY_TREE_OVERRIDE_TREE); - pc.addArgs("detach", "-force", mountPath); - pc.run(); + // use ditto to copy the .app: a normal recursive copy breaks on the symlinks inside macOS frameworks (e.g. Python/Tcl) + Path targetApp = targetDir.resolve(appPath.getFileName().toString()); + mkdirs(targetDir); + delete(targetApp); + this.context.newProcess().executable("ditto").addArgs(appPath, targetApp).run(); + } finally { + if (mounted) { + this.context.newProcess().errorHandling(ProcessErrorHandling.LOG_WARNING).executable("hdiutil").addArgs("detach", "-force", mountPath) + .run(ProcessMode.DEFAULT_SILENT); + } + } } @Override 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 fa992c6619..6d37d877cf 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 @@ -19,6 +19,7 @@ import com.devonfw.tools.ide.process.ProcessMode; import com.devonfw.tools.ide.step.Step; import com.devonfw.tools.ide.tool.repository.ToolRepository; +import com.devonfw.tools.ide.util.FilenameUtil; import com.devonfw.tools.ide.version.VersionIdentifier; /** @@ -157,30 +158,35 @@ protected ToolInstallation doInstall(ToolInstallRequest request) { ToolRepository toolRepository = this.context.getDefaultToolRepository(); resolvedVersion = cveCheck(request); // download and install the global tool - FileAccess fileAccess = this.context.getFileAccess(); Path target = toolRepository.download(this.tool, edition, resolvedVersion, this); - Path executable = target; - Path tmpDir = null; - boolean extract = isExtract(); - if (extract) { - tmpDir = fileAccess.createTempDir(getName()); - Path downloadBinaryPath = tmpDir.resolve(target.getFileName()); - fileAccess.extract(target, downloadBinaryPath); - executable = fileAccess.findFirst(downloadBinaryPath, Files::isExecutable, false); - } - ProcessContext pc = this.context.newProcess().errorHandling(ProcessErrorHandling.LOG_WARNING).executable(executable); - int exitCode = pc.run(ProcessMode.BACKGROUND_SILENT).getExitCode(); - if (tmpDir != null) { - fileAccess.delete(tmpDir); - } - if (exitCode == 0) { - IdeLogLevel.SUCCESS.log(LOG, "Installation process for {} in version {} has started", this.tool, resolvedVersion); - Step step = request.getStep(); - if (step != null) { - step.success(true); - } + ProcessContext pc; + if (isMacDmg(target)) { + installMacDmg(target); + pc = request.getProcessContext(); } else { - throw new CliException("Installation process for " + this.tool + " in version " + resolvedVersion + " failed with exit code " + exitCode + "!"); + FileAccess fileAccess = this.context.getFileAccess(); + Path executable = target; + Path tmpDir = null; + boolean extract = isExtract(); + if (extract) { + tmpDir = fileAccess.createTempDir(getName()); + Path downloadBinaryPath = tmpDir.resolve(target.getFileName()); + fileAccess.extract(target, downloadBinaryPath); + executable = fileAccess.findFirst(downloadBinaryPath, Files::isExecutable, false); + } + pc = this.context.newProcess().errorHandling(ProcessErrorHandling.LOG_WARNING).executable(executable); + int exitCode = pc.run(ProcessMode.BACKGROUND_SILENT).getExitCode(); + if (tmpDir != null) { + fileAccess.delete(tmpDir); + } + if (exitCode != 0) { + throw new CliException("Installation process for " + this.tool + " in version " + resolvedVersion + " failed with exit code " + exitCode + "!"); + } + } + IdeLogLevel.SUCCESS.log(LOG, "Installation process for {} in version {} has started", this.tool, resolvedVersion); + Step step = request.getStep(); + if (step != null) { + step.success(true); } installationPath = getInstallationPath(toolEdition.edition(), resolvedVersion); if (installationPath == null) { @@ -190,6 +196,69 @@ protected ToolInstallation doInstall(ToolInstallRequest request) { return createToolInstallation(installationPath, resolvedVersion, true, pc, false); } + private void installMacDmg(Path downloadedToolFile) { + + FileAccess fileAccess = this.context.getFileAccess(); + Path tmpDir = fileAccess.createTempDir(getName()); + try { + fileAccess.extractDmg(downloadedToolFile, tmpDir); + Path sourceApp = getMacOsHelper().findAppDir(tmpDir); + if (sourceApp == null) { + throw new CliException("Failed to install " + this.tool + " from " + downloadedToolFile + " because no MacOS *.app was found."); + } + Path targetApp = getMacApplicationsPath().resolve(sourceApp.getFileName().toString()); + copyMacApplicationToApplications(sourceApp, targetApp); + } finally { + fileAccess.delete(tmpDir); + } + } + + /** + * Copies a macOS application bundle to the global applications folder. + * + * @param sourceApp the extracted source {@code .app}. + * @param targetApp the target {@code .app} in {@link #getMacApplicationsPath()}. + */ + protected void copyMacApplicationToApplications(Path sourceApp, Path targetApp) { + + runPrivilegedCommands(List.of( + List.of("/bin/rm", "-rf", targetApp.toString()), + List.of("/usr/bin/ditto", sourceApp.toString(), targetApp.toString()))); + } + + private void runPrivilegedCommands(List> commands) { + + logPrivilegedCommands(commands.stream().map(this::toSudoCommandLine).toList()); + for (List command : commands) { + int exitCode = this.context.newProcess().errorHandling(ProcessErrorHandling.LOG_WARNING).executable("sudo").addArgs(command).run(); + if (exitCode != 0) { + throw new CliException("Privileged command failed with exit code " + exitCode + ": " + toSudoCommandLine(command)); + } + } + } + + private String toSudoCommandLine(List command) { + + return "sudo " + String.join(" ", command); + } + + private boolean isMacDmg(Path file) { + + if (!this.context.getSystemInfo().isMac()) { + return false; + } + String extension = FilenameUtil.getExtension(file.toString()); + return "dmg".equals(extension); + } + + /** + * @return the macOS applications folder where global {@code .dmg} tools are installed. + */ + protected Path getMacApplicationsPath() { + + return Path.of("/Applications"); + } + /** * @return the {@link List} of {@link PackageManagerCommand}s to use on Linux to install this tool. If empty, no package manager installation will be * triggered on Linux. @@ -216,6 +285,9 @@ protected Path getInstallationPath(String edition, VersionIdentifier resolvedVer Path toolBinary = Path.of(getBinaryName()); Path binaryPath = this.context.getPath().findBinary(toolBinary); if ((binaryPath == toolBinary) || !Files.exists(binaryPath)) { + if (this.context.getSystemInfo().isMac()) { + return getMacApplicationInstallationPath(); + } return null; } Path binPath = binaryPath.getParent(); @@ -225,6 +297,30 @@ protected Path getInstallationPath(String edition, VersionIdentifier resolvedVer return this.context.getFileAccess().getBinParentPath(binPath); } + private Path getMacApplicationInstallationPath() { + + Path appPath = this.context.getFileAccess().findFirst(getMacApplicationsPath(), this::isMacApplicationForTool, false); + if (appPath == null) { + return null; + } + Path binaryPath = getMacApplicationBinaryPath(appPath); + this.context.getPath().setPath(getName(), binaryPath.getParent()); + return appPath; + } + + private boolean isMacApplicationForTool(Path appPath) { + + if (!Files.isDirectory(appPath) || !appPath.getFileName().toString().endsWith(".app")) { + return false; + } + return Files.isExecutable(getMacApplicationBinaryPath(appPath)); + } + + private Path getMacApplicationBinaryPath(Path appPath) { + + return appPath.resolve(IdeContext.FOLDER_CONTENTS).resolve(IdeContext.FOLDER_MAC_OS).resolve(getBinaryName()); + } + @Override public void uninstall() { //TODO: handle "uninstall " 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 7129221724..ed0e866da1 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 @@ -8,25 +8,12 @@ import java.util.List; import java.util.Set; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.devonfw.tools.ide.cli.CliException; import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.io.FileAccess; -import com.devonfw.tools.ide.log.IdeLogLevel; import com.devonfw.tools.ide.os.WindowsHelper; -import com.devonfw.tools.ide.process.ProcessErrorHandling; -import com.devonfw.tools.ide.process.ProcessMode; -import com.devonfw.tools.ide.process.ProcessResult; -import com.devonfw.tools.ide.step.Step; import com.devonfw.tools.ide.tool.GlobalToolCommandlet; import com.devonfw.tools.ide.tool.NativePackageManager; import com.devonfw.tools.ide.tool.PackageManagerCommand; -import com.devonfw.tools.ide.tool.ToolEdition; -import com.devonfw.tools.ide.tool.ToolInstallRequest; -import com.devonfw.tools.ide.tool.ToolInstallation; import com.devonfw.tools.ide.tool.repository.ToolRepository; import com.devonfw.tools.ide.version.VersionIdentifier; @@ -35,9 +22,7 @@ */ public class PgAdmin extends GlobalToolCommandlet { - private static final Logger LOG = LoggerFactory.getLogger(PgAdmin.class); - - private static final String PGADMIN_APP = "pgAdmin 4.app"; + private static final String PGADMIN_MAC_BINARY = "pgAdmin 4"; /** * The constructor. @@ -49,116 +34,6 @@ public PgAdmin(IdeContext context) { super(context, "pgadmin", Set.of(Tag.DB, Tag.ADMIN)); } - @Override - protected ToolInstallation doInstall(ToolInstallRequest request) { - - if (this.context.getSystemInfo().isMac()) { - return doInstallOnMac(request); - } - return super.doInstall(request); - } - - private ToolInstallation doInstallOnMac(ToolInstallRequest request) { - - VersionIdentifier resolvedVersion = request.getRequested().getResolvedVersion(); - ToolEdition toolEdition = getToolWithConfiguredEdition(); - Path installationPath = getInstallationPath(toolEdition.edition(), resolvedVersion); - if ((installationPath != null) && !this.context.isForceMode()) { - return toolAlreadyInstalled(request); - } - - resolvedVersion = cveCheck(request); - Path dmg = getToolRepository().download(this.tool, toolEdition.edition(), resolvedVersion, this); - Path appPath = installMacDmg(dmg); - - installationPath = getInstallationPath(toolEdition.edition(), resolvedVersion); - if (installationPath == null) { - throw new CliException("The tool " + this.tool + " was installed but the pgAdmin app could not be found in " + getMacApplicationsPath() + "."); - } - IdeLogLevel.SUCCESS.log(LOG, "Successfully installed {} in version {} at {}", this.tool, resolvedVersion, appPath); - Step step = request.getStep(); - if (step != null) { - step.success(true); - } - return createToolInstallation(installationPath, resolvedVersion, true, request.getProcessContext(), request.isAdditionalInstallation()); - } - - private Path installMacDmg(Path dmg) { - - FileAccess fileAccess = this.context.getFileAccess(); - Path mountPath = fileAccess.createTempDir("pgadmin-dmg"); - boolean mounted = false; - try { - this.context.newProcess().executable("hdiutil").addArgs("attach", "-quiet", "-nobrowse", "-mountpoint", mountPath, dmg).run(); - mounted = true; - - Path sourceApp = getMacOsHelper().findAppDir(mountPath); - if (sourceApp == null) { - throw new CliException("No pgAdmin .app bundle was found in " + dmg + "."); - } - - Path targetApp = getMacApplicationsPath().resolve(PGADMIN_APP); - if (Files.exists(targetApp) && this.context.isForceMode()) { - runWithPrivilegeFallback("/bin/rm", "-rf", targetApp.toString()); - } - runWithPrivilegeFallback("/usr/bin/ditto", sourceApp.toString(), targetApp.toString()); - return targetApp; - } finally { - if (mounted) { - this.context.newProcess().errorHandling(ProcessErrorHandling.LOG_WARNING).executable("hdiutil") - .addArgs("detach", "-force", mountPath).run(ProcessMode.DEFAULT_SILENT); - } - deleteMountPath(mountPath, fileAccess); - } - } - - private void deleteMountPath(Path mountPath, FileAccess fileAccess) { - - try { - if (Files.isDirectory(mountPath) && (fileAccess.findFirst(mountPath, p -> true, false) != null)) { - LOG.warn("Skipping deletion of temporary pgAdmin DMG mount path {} because it still contains files.", mountPath); - return; - } - fileAccess.delete(mountPath); - } catch (RuntimeException e) { - LOG.warn("Failed to delete temporary pgAdmin DMG mount path {}.", mountPath, e); - } - } - - private void runPrivileged(String... command) { - - logPrivilegedCommands(List.of(toSudoCommandLine(command))); - this.context.newProcess().executable("sudo").addArgs(command).run(); - } - - private void runWithPrivilegeFallback(String... command) { - - ProcessResult result = this.context.newProcess().errorHandling(ProcessErrorHandling.NONE).executable(command[0]) - .addArgs(Arrays.copyOfRange(command, 1, command.length)).run(ProcessMode.DEFAULT); - if (!result.isSuccessful()) { - LOG.debug("Command {} failed without elevated privileges. Retrying with sudo.", List.of(command)); - runPrivileged(command); - } - } - - private static String toSudoCommandLine(String... command) { - - StringBuilder commandLine = new StringBuilder("sudo"); - for (String argument : command) { - commandLine.append(' '); - commandLine.append(quoteShellArgument(argument)); - } - return commandLine.toString(); - } - - private static String quoteShellArgument(String argument) { - - if (argument.indexOf(' ') < 0 && argument.indexOf('\'') < 0) { - return argument; - } - return "'" + argument.replace("'", "'\"'\"'") + "'"; - } - @Override protected List getInstallPackageManagerCommands() { @@ -202,7 +77,7 @@ private List getPackageManagerCommandsUninstall() { protected String getBinaryName() { if (this.context.getSystemInfo().isMac()) { - return "pgAdmin 4"; + return PGADMIN_MAC_BINARY; } return "pgadmin4"; } @@ -214,31 +89,12 @@ protected Path getInstallationPath(String edition, VersionIdentifier resolvedVer if (installationPath != null) { return installationPath; } - if (this.context.getSystemInfo().isMac()) { - return getMacOsAppPath(); - } if (this.context.getSystemInfo().isWindows()) { return getExecutableFolderFromWindowsRegistry(); } return null; } - protected Path getMacApplicationsPath() { - - return Path.of("/Applications"); - } - - private Path getMacOsAppPath() { - - Path appPath = getMacApplicationsPath().resolve(PGADMIN_APP); - Path binary = appPath.resolve("Contents").resolve("MacOS").resolve(getBinaryName()); - if (Files.isExecutable(binary)) { - this.context.getPath().setPath(getName(), binary.getParent()); - return appPath; - } - return null; - } - private Path getExecutableFolderFromWindowsRegistry() { WindowsHelper windowsHelper = WindowsHelper.get(this.context); 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 new file mode 100644 index 0000000000..d2f1ff293b --- /dev/null +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/GlobalToolCommandletTest.java @@ -0,0 +1,105 @@ +package com.devonfw.tools.ide.tool; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.io.TempDir; + +import com.devonfw.tools.ide.common.Tag; +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.os.SystemInfoMock; +import com.devonfw.tools.ide.version.VersionIdentifier; + +/** + * Test of {@link GlobalToolCommandlet}. + */ +class GlobalToolCommandletTest extends AbstractIdeContextTest { + + private static final String DUMMY_BINARY = "dummy"; + + /** + * Tests that on macOS the installation path is resolved from the app bundle in the applications folder and its executable folder is registered. + * + * @param tempDir the temporary directory. + * @throws IOException on test setup failure. + */ + @Test + @DisabledOnOs(OS.WINDOWS) + void testGetInstallationPathFindsMacApp(@TempDir Path tempDir) throws IOException { + + // arrange + IdeTestContext context = new IdeTestContext(); + context.setSystemInfo(SystemInfoMock.MAC_X64); + Path applicationsPath = tempDir.resolve("Applications"); + Path appPath = applicationsPath.resolve("Dummy.app"); + Path appBinPath = appPath.resolve("Contents").resolve("MacOS"); + Path binary = appBinPath.resolve(DUMMY_BINARY); + Files.createDirectories(appBinPath); + Files.writeString(binary, "test"); + context.getFileAccess().makeExecutable(binary); + GlobalToolCommandlet globalTool = new GlobalToolDummyCommandlet(context, applicationsPath); + + // act + Path result = globalTool.getInstallationPath("default", VersionIdentifier.of("1.0")); + + // assert + assertThat(result).isEqualTo(appPath); + assertThat(context.getPath().getPath(DUMMY_BINARY)).isEqualTo(appBinPath); + } + + /** + * Tests that the applications folder is only searched on macOS and not on other operating systems. + * + * @param tempDir the temporary directory. + * @throws IOException on test setup failure. + */ + @Test + @DisabledOnOs(OS.WINDOWS) + void testGetInstallationPathDoesNotSearchApplicationsOnLinux(@TempDir Path tempDir) throws IOException { + + // arrange + IdeTestContext context = new IdeTestContext(); + context.setSystemInfo(SystemInfoMock.LINUX_X64); + Path applicationsPath = tempDir.resolve("Applications"); + Path appBinPath = applicationsPath.resolve("Dummy.app").resolve("Contents").resolve("MacOS"); + Path binary = appBinPath.resolve(DUMMY_BINARY); + Files.createDirectories(appBinPath); + Files.writeString(binary, "test"); + context.getFileAccess().makeExecutable(binary); + GlobalToolCommandlet globalTool = new GlobalToolDummyCommandlet(context, applicationsPath); + + // act & assert + assertThat(globalTool.getInstallationPath("default", VersionIdentifier.of("1.0"))).isNull(); + assertThat(context.getPath().getPath(DUMMY_BINARY)).isNull(); + } + + private static class GlobalToolDummyCommandlet extends GlobalToolCommandlet { + + private final Path applicationsPath; + + GlobalToolDummyCommandlet(IdeContext context, Path applicationsPath) { + + super(context, DUMMY_BINARY, Set.of(Tag.TEST)); + this.applicationsPath = applicationsPath; + } + + @Override + protected String getBinaryName() { + + return DUMMY_BINARY; + } + + @Override + protected Path getMacApplicationsPath() { + + return this.applicationsPath; + } + } +} From 8831cfb7533dcd2f9b0d9a7467d2b84e3c813638 Mon Sep 17 00:00:00 2001 From: T9 <103292348+shodiBoy1@users.noreply.github.com> Date: Wed, 17 Jun 2026 19:00:25 +0200 Subject: [PATCH 6/6] Remove duplicate entry for issue #1906 in CHANGELOG Removed duplicate entry for issue #1906 regarding 'ide uninstall' failing on macOS. --- CHANGELOG.adoc | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 9d7b6d4ab8..7283048e7d 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -10,7 +10,6 @@ Release with new features and bugfixes: * https://github.com/devonfw/IDEasy/issues/1978[#1978]: Enhanced the quality status documentation * https://github.com/devonfw/IDEasy/issues/1946[#1946]: Add Spyder Python IDE * https://github.com/devonfw/IDEasy/issues/1846[#1846]: Fixed macOS Gatekeeper workaround -* https://github.com/devonfw/IDEasy/issues/1906[#1906]: Fixed `ide uninstall` failing on macOS * https://github.com/devonfw/IDEasy/issues/798[#798]: Fixed pgAdmin DMG install on macOS * https://github.com/devonfw/IDEasy/issues/1906[#1906]: Fixed `ide uninstall` failing on macOS * https://github.com/devonfw/IDEasy/issues/1985[#1985]: Fix path traversal errors when extracting ZIPs on Windows