From 160440a9ae6d8f91767554165ef8d2a4f71efa7a Mon Sep 17 00:00:00 2001 From: krystynaShatkovska Date: Mon, 17 Aug 2026 11:40:21 +0200 Subject: [PATCH 1/2] feat: improve support to get installed version and edition (#2296) Introduce EditionAndVersion to resolve a tool's installed edition and version in a single cached lookup, with a protected computeInstalledEditionAndVersion() hook. - KubeCtl: read version from 'kubectl version --client' and route through the public getInstalledEditionAndVersion() override so it is actually used; add KubeCtlTest. - LocalToolCommandlet: resolve edition and version in one tool-path lookup. - Docker: keep the resolved edition consistent ('docker'/'rancher') across all OSes instead of the bogus 'desktop'; detect the Docker Desktop version on macOS from the Docker.app bundle; add DockerTest. - CHANGELOG: add #2296 line under 2026.08.002. Rebased onto upstream/main (drops unrelated #2278 work). --- CHANGELOG.adoc | 1 + .../devonfw/tools/ide/context/IdeContext.java | 2 +- .../ide/tool/DelegatingToolCommandlet.java | 9 +- .../tools/ide/tool/EditionAndVersion.java | 10 + .../tools/ide/tool/GlobalToolCommandlet.java | 22 +- .../tools/ide/tool/IdeasyCommandlet.java | 10 +- .../tools/ide/tool/LocalToolCommandlet.java | 20 +- ...ackageManagerBasedLocalToolCommandlet.java | 25 +-- .../tools/ide/tool/ToolCommandlet.java | 93 +++++++- .../devonfw/tools/ide/tool/docker/Docker.java | 72 ++++--- .../tools/ide/tool/kubectl/KubeCtl.java | 8 +- .../ide/tool/node/NodeBasedCommandlet.java | 9 - .../tools/ide/tool/IdeasyCommandletTest.java | 8 +- .../tools/ide/tool/docker/DockerTest.java | 201 ++++++++++++++++++ .../tools/ide/tool/kubectl/KubeCtlTest.java | 94 ++++++++ 15 files changed, 499 insertions(+), 85 deletions(-) create mode 100644 cli/src/main/java/com/devonfw/tools/ide/tool/EditionAndVersion.java create mode 100644 cli/src/test/java/com/devonfw/tools/ide/tool/docker/DockerTest.java create mode 100644 cli/src/test/java/com/devonfw/tools/ide/tool/kubectl/KubeCtlTest.java diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 8866a010de..ad55bfd215 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -6,6 +6,7 @@ This file documents all notable changes to https://github.com/devonfw/IDEasy[IDE Release with new features and bugfixes: +* https://github.com/devonfw/IDEasy/issues/2296[#2296]: Improve support to get installed version and edition 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/context/IdeContext.java b/cli/src/main/java/com/devonfw/tools/ide/context/IdeContext.java index e6add8a5f1..e37d4af31d 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 @@ -436,7 +436,7 @@ default List findProjects() { * @param ideRoot the IDE root containing the IDEasy projects. * @return the paths of all detected IDEasy projects. */ - static List findProjects(Path ideRoot) { + public static List findProjects(Path ideRoot) { if ((ideRoot == null) || !Files.isDirectory(ideRoot)) { return List.of(); diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/DelegatingToolCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/tool/DelegatingToolCommandlet.java index d2d5b7e17f..b395696f64 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/DelegatingToolCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/DelegatingToolCommandlet.java @@ -46,13 +46,8 @@ protected ToolInstallation doInstall(ToolInstallRequest request) { } @Override - public VersionIdentifier getInstalledVersion() { - return getDelegate().getInstalledVersion(); - } - - @Override - public String getInstalledEdition() { - return getDelegate().getInstalledEdition(); + public EditionAndVersion getInstalledEditionAndVersion() { + return getDelegate().getInstalledEditionAndVersion(); } @Override diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/EditionAndVersion.java b/cli/src/main/java/com/devonfw/tools/ide/tool/EditionAndVersion.java new file mode 100644 index 0000000000..f79e0ce91c --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/EditionAndVersion.java @@ -0,0 +1,10 @@ +package com.devonfw.tools.ide.tool; + +import com.devonfw.tools.ide.version.VersionIdentifier; + +/** + * Simple record holding an installed tool's edition and version together. + */ +public record EditionAndVersion(String edition, VersionIdentifier version) { + +} 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 5770d2e413..b699474f01 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 @@ -4,6 +4,7 @@ import java.nio.file.Path; import java.util.Arrays; import java.util.List; +import java.util.Map; import java.util.Set; import org.slf4j.Logger; @@ -197,20 +198,31 @@ public String getWindowsRegistryAppName() { return this.tool; } + /** + * @return a {@link Map} that maps edition names to the app name to look for in the Windows registry. Default + * returns a single entry with {@code tool -> tool}. Override for tools with multiple editions on Windows. + */ + public Map getWindowsRegistryAppNames() { + + return Map.of(this.tool, getWindowsRegistryAppName()); + } + @Override - public VersionIdentifier getInstalledVersion() { + protected EditionAndVersion computeInstalledEditionAndVersion() { if (this.context.getSystemInfo().isWindows()) { - WindowsAppInstallation installation = WindowsHelper.get(this.context).getAppInstallationFromRegistry(getWindowsRegistryAppName()); - if (installation != null) { - return VersionIdentifier.of(installation.version()); + for (Map.Entry entry : getWindowsRegistryAppNames().entrySet()) { + WindowsAppInstallation installation = WindowsHelper.get(this.context).getAppInstallationFromRegistry(entry.getValue()); + if (installation != null) { + return new EditionAndVersion(entry.getKey(), VersionIdentifier.of(installation.version())); + } } } return null; } @Override - public String getInstalledEdition() { + protected String getInstalledEditionDeprecated() { //TODO: handle "get-edition " return null; } diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/IdeasyCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/tool/IdeasyCommandlet.java index 36b3747710..cfd5473090 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/IdeasyCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/IdeasyCommandlet.java @@ -118,15 +118,9 @@ public IdeasyCommandlet(IdeContext context, UpgradeMode mode) { } @Override - public VersionIdentifier getInstalledVersion() { + protected EditionAndVersion computeInstalledEditionAndVersion() { - return IdeVersion.getVersionIdentifier(); - } - - @Override - public String getInstalledEdition() { - - return this.tool; + return new EditionAndVersion(this.tool, IdeVersion.getVersionIdentifier()); } @Override diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/LocalToolCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/tool/LocalToolCommandlet.java index 228a96567c..63c75d9843 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/LocalToolCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/LocalToolCommandlet.java @@ -366,7 +366,23 @@ protected void postExtract(Path extractedDir) { } @Override - public VersionIdentifier getInstalledVersion() { + protected EditionAndVersion computeInstalledEditionAndVersion() { + + Path toolPath = getToolPath(); + if (isToolNotInstalled(toolPath)) { + return null; + } + // Resolve edition and version from a single tool-path lookup (one pass) instead of two separate lookups. + String edition = getInstalledEdition(toolPath); + VersionIdentifier version = getInstalledVersion(toolPath); + if ((edition == null) && (version == null)) { + return null; + } + return new EditionAndVersion(edition, version); + } + + @Override + protected VersionIdentifier getInstalledVersionDeprecated() { return getInstalledVersion(getToolPath()); } @@ -399,7 +415,7 @@ protected VersionIdentifier getInstalledVersion(Path toolPath) { } @Override - public String getInstalledEdition() { + protected String getInstalledEditionDeprecated() { return getInstalledEdition(getToolPath()); } diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/PackageManagerBasedLocalToolCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/tool/PackageManagerBasedLocalToolCommandlet.java index 3d4a77d1d0..bb0dcc9dff 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/PackageManagerBasedLocalToolCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/PackageManagerBasedLocalToolCommandlet.java @@ -7,7 +7,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.devonfw.tools.ide.cache.CachedValue; import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.process.ProcessContext; @@ -25,8 +24,6 @@ public abstract class PackageManagerBasedLocalToolCommandlet

installedVersion; - /** * The constructor. * @@ -37,7 +34,6 @@ public abstract class PackageManagerBasedLocalToolCommandlet

tags) { super(context, tool, tags); - this.installedVersion = new CachedValue<>(this::determineInstalledVersion); } @Override @@ -156,10 +152,15 @@ protected boolean isIgnoreMissingSoftwareVersionFile() { return true; } - private VersionIdentifier determineInstalledVersion() { + @Override + protected EditionAndVersion computeInstalledEditionAndVersion() { try { - return computeInstalledVersion(); + VersionIdentifier version = computeInstalledVersion(); + if (version == null) { + return null; + } + return new EditionAndVersion(this.tool, version); } catch (Exception e) { LOG.debug("Failed to compute installed version of {}", this.tool, e); return null; @@ -167,19 +168,13 @@ private VersionIdentifier determineInstalledVersion() { } /** - * @return the computed value of the {@link #getInstalledVersion() installed version}. + * @return the computed value of the installed version. * @implNote Implementations of this method should NOT trigger any tool installation or download. If you need to call * {@link #runPackageManager(PackageManagerRequest)}, make sure to use {@link #runPackageManager(PackageManagerRequest, boolean)} with * {@code skipInstallation=true} to avoid inadvertently triggering installations when only checking the version. */ protected abstract VersionIdentifier computeInstalledVersion(); - @Override - public VersionIdentifier getInstalledVersion() { - - return this.installedVersion.get(); - } - /** * Override to ignore the {@code toolPath} parameter and use the package-manager based detection of the actually installed version. * @@ -197,7 +192,7 @@ protected final void performToolInstallation(ToolInstallRequest request, Path in PackageManagerRequest packageManagerRequest = new PackageManagerRequest(PackageManagerRequest.TYPE_INSTALL, getPackageName()) .setProcessContext(request.getProcessContext()).setVersion(request.getRequested().getResolvedVersion()); runPackageManager(packageManagerRequest, isSkipInstallation()).failOnError(); - this.installedVersion.invalidate(); + invalidateInstalledEditionAndVersion(); } /** @@ -220,7 +215,7 @@ protected final void performUninstall(Path toolPath) { if (canBeUninstalled()) { PackageManagerRequest request = new PackageManagerRequest(PackageManagerRequest.TYPE_UNINSTALL, getPackageName()); runPackageManager(request).failOnError(); - this.installedVersion.invalidate(); + invalidateInstalledEditionAndVersion(); } else { LOG.info("IDEasy does not support uninstalling the tool {} since this will break your installation.\n" + "If you really want to uninstall it, please uninstall its parent tool via:\n" diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/ToolCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/tool/ToolCommandlet.java index 59d43957c4..4412b2ba10 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/ToolCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/ToolCommandlet.java @@ -14,6 +14,7 @@ import org.slf4j.LoggerFactory; import org.slf4j.event.Level; +import com.devonfw.tools.ide.cache.CachedValue; import com.devonfw.tools.ide.commandlet.Commandlet; import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.common.Tags; @@ -63,6 +64,9 @@ public abstract class ToolCommandlet extends Commandlet implements Tags { private MacOsHelper macOsHelper; + /** Cached result for {@link #getInstalledEditionAndVersion()}. */ + private CachedValue installedEditionAndVersion; + /** * Registry for tool-specific auto-completion candidates. */ @@ -608,6 +612,8 @@ protected ToolInstallation createToolInstallation(Path rootDir, VersionIdentifie protected ToolInstallation createToolInstallation(Path rootDir, Path linkDir, Path binDir, VersionIdentifier version, boolean newInstallation, EnvironmentContext environmentContext, boolean additionalInstallation) { + // Invalidate cached edition/version so that subsequent calls reflect the new installation + invalidateInstalledEditionAndVersion(); // do not copy the version file into macOS .app bundles: changing the bundle after codesigning breaks the seal. ToolInstallation toolInstallation = new ToolInstallation(rootDir, linkDir, binDir, version, newInstallation); setEnvironment(environmentContext, toolInstallation, additionalInstallation); @@ -811,10 +817,43 @@ protected MacOsHelper getMacOsHelper() { return this.macOsHelper; } + /** + * Gets the installed edition and version together, resolving both in a single operation. + * + * @return the {@link EditionAndVersion} or {@code null} if not installed. + */ + public EditionAndVersion getInstalledEditionAndVersion() { + + if (this.installedEditionAndVersion == null) { + this.installedEditionAndVersion = new CachedValue<>(this::computeInstalledEditionAndVersion); + } + return this.installedEditionAndVersion.get(); + } + + /** + * Hook to compute the installed edition and version together. Override this method in subclasses to resolve both + * edition and version in a single operation, avoiding redundant expensive lookups. + * + * @return the {@link EditionAndVersion} or {@code null} if not installed. + */ + protected EditionAndVersion computeInstalledEditionAndVersion() { + + String edition = computeInstalledEdition(); + VersionIdentifier version = computeInstalledVersion(); + if ((edition == null) && (version == null)) { + return null; + } + return new EditionAndVersion(edition, version); + } + /** * @return the currently installed {@link VersionIdentifier version} of this tool or {@code null} if not installed. */ - public abstract VersionIdentifier getInstalledVersion(); + public final VersionIdentifier getInstalledVersion() { + + EditionAndVersion ev = getInstalledEditionAndVersion(); + return (ev != null) ? ev.version() : null; + } /** * @return {@code true} if this tool is installed, {@code false} otherwise. @@ -827,7 +866,57 @@ public boolean isInstalled() { /** * @return the installed edition of this tool or {@code null} if not installed. */ - public abstract String getInstalledEdition(); + public final String getInstalledEdition() { + + EditionAndVersion ev = getInstalledEditionAndVersion(); + return (ev != null) ? ev.edition() : null; + } + + /** + * @deprecated Override {@link #computeInstalledEditionAndVersion()} instead. + * @return the currently installed version. + */ + @Deprecated + protected VersionIdentifier computeInstalledVersion() { + + return getInstalledVersionDeprecated(); + } + + /** + * @deprecated Override {@link #computeInstalledEditionAndVersion()} instead. + * @return the installed edition. + */ + @Deprecated + protected String computeInstalledEdition() { + + return getInstalledEditionDeprecated(); + } + + /** + * @deprecated Override {@link #computeInstalledEditionAndVersion()} instead. + * @return the installed version. + */ + @Deprecated + protected VersionIdentifier getInstalledVersionDeprecated() { + + return null; + } + + /** + * @deprecated Override {@link #computeInstalledEditionAndVersion()} instead. + * @return the installed edition. + */ + @Deprecated + protected String getInstalledEditionDeprecated() { + + return null; + } + + /** Invalidates the cached installed edition and version so the next call to {@link #getInstalledEditionAndVersion()} recomputes the result. */ + protected void invalidateInstalledEditionAndVersion() { + + this.installedEditionAndVersion = null; + } /** * Uninstalls the {@link #getName() tool}. 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 46dc0d167e..8bff74127d 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 @@ -2,6 +2,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.regex.Pattern; @@ -10,7 +11,9 @@ import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.log.IdeLogLevel; import com.devonfw.tools.ide.os.SystemArchitecture; +import com.devonfw.tools.ide.tool.EditionAndVersion; import com.devonfw.tools.ide.tool.GlobalToolCommandlet; import com.devonfw.tools.ide.tool.NativePackageManager; import com.devonfw.tools.ide.tool.PackageManagerCommand; @@ -30,7 +33,7 @@ public class Docker extends GlobalToolCommandlet { private static final Pattern RDCTL_CLIENT_VERSION_PATTERN = Pattern.compile("client version:\\s*v([\\d.]+)", Pattern.CASE_INSENSITIVE); - private static final Pattern DOCKER_DESKTOP_LINUX_VERSION_PATTERN = Pattern.compile("^([0-9]+(?:\\.[0-9]+){1,2})"); + private static final Pattern DOCKER_DESKTOP_VERSION_PATTERN = Pattern.compile("^([0-9]+(?:\\.[0-9]+){1,2})"); /** * The constructor. @@ -96,54 +99,65 @@ protected List getInstallPackageManagerCommands() { } @Override - public VersionIdentifier getInstalledVersion() { + protected EditionAndVersion computeInstalledEditionAndVersion() { if (!isDockerInstalled()) { return null; } if (isRancherDesktopInstalled()) { - return getRancherDesktopClientVersion(); - } else { - VersionIdentifier parsedVersion = switch (this.context.getSystemInfo().getOs()) { - case WINDOWS -> super.getInstalledVersion(); - case LINUX -> getDockerDesktopVersionLinux(); - default -> null; - }; - - if (parsedVersion == null) { - LOG.error("Couldn't get installed version of " + this.getName()); + VersionIdentifier version = getRancherDesktopClientVersion(); + return new EditionAndVersion("rancher", version); + } + + // Docker Desktop: the edition is always "docker" (matching getWindowsRegistryAppNames()); on Windows it is + // resolved from the registry by super. Only the version source differs per OS: Windows reads the registry app + // version, Linux the docker-desktop package version, macOS the Docker.app bundle version. + VersionIdentifier version = switch (this.context.getSystemInfo().getOs()) { + case WINDOWS -> { + EditionAndVersion fromRegistry = super.computeInstalledEditionAndVersion(); + yield (fromRegistry != null) ? fromRegistry.version() : null; } + case LINUX -> getDockerDesktopVersionLinux(); + case MAC -> getDockerDesktopVersionMac(); + default -> null; + }; - return parsedVersion; + if (version == null) { + LOG.error("Couldn't get installed version of " + this.getName()); } + + return new EditionAndVersion("docker", version); + } + + @Override + public Map getWindowsRegistryAppNames() { + + return Map.of("docker", "Docker Desktop", "rancher", "Rancher Desktop"); } private VersionIdentifier getDockerDesktopVersionLinux() { String dockerDesktopVersionLinuxCommand = "apt list --installed | grep docker-desktop | awk '{print $2}'"; - String output = this.context.newProcess().runAndGetSingleOutput("bash", "-lc", dockerDesktopVersionLinuxCommand); - return super.resolveVersionWithPattern(output, DOCKER_DESKTOP_LINUX_VERSION_PATTERN); + // Log a warning and return null (instead of throwing) when the command produces no usable output, e.g. when + // Docker Desktop is not installed via apt. + String output = this.context.newProcess().runAndGetSingleOutput(IdeLogLevel.WARNING, "bash", "-lc", dockerDesktopVersionLinuxCommand); + return (output != null) ? super.resolveVersionWithPattern(output, DOCKER_DESKTOP_VERSION_PATTERN) : null; } - private VersionIdentifier getRancherDesktopClientVersion() { + private VersionIdentifier getDockerDesktopVersionMac() { - String output = this.context.newProcess().runAndGetSingleOutput("rdctl", "version"); - return super.resolveVersionWithPattern(output, RDCTL_CLIENT_VERSION_PATTERN); + String dockerDesktopVersionMacCommand = "plutil -extract CFBundleShortVersionString raw /Applications/Docker.app/Contents/Info.plist"; + // Log a warning and return null (instead of throwing) when the command produces no usable output, e.g. when + // Docker Desktop is not installed at /Applications/Docker.app. + String output = this.context.newProcess().runAndGetSingleOutput(IdeLogLevel.WARNING, "bash", "-lc", dockerDesktopVersionMacCommand); + return (output != null) ? super.resolveVersionWithPattern(output, DOCKER_DESKTOP_VERSION_PATTERN) : null; } - @Override - public String getInstalledEdition() { - - if (!isDockerInstalled()) { - return null; - } + private VersionIdentifier getRancherDesktopClientVersion() { - if (isRancherDesktopInstalled()) { - return "rancher"; - } else { - return "desktop"; - } + String output = this.context.newProcess().runAndGetSingleOutput("rdctl", "version"); + return super.resolveVersionWithPattern(output, RDCTL_CLIENT_VERSION_PATTERN); } @Override diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/kubectl/KubeCtl.java b/cli/src/main/java/com/devonfw/tools/ide/tool/kubectl/KubeCtl.java index 50b3d72fc2..8f6a2f8291 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/kubectl/KubeCtl.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/kubectl/KubeCtl.java @@ -7,6 +7,7 @@ import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.tool.DelegatingToolCommandlet; +import com.devonfw.tools.ide.tool.EditionAndVersion; import com.devonfw.tools.ide.tool.docker.Docker; import com.devonfw.tools.ide.version.VersionIdentifier; @@ -28,15 +29,16 @@ public KubeCtl(IdeContext context) { } @Override - public VersionIdentifier getInstalledVersion() { + public EditionAndVersion getInstalledEditionAndVersion() { if (!isCommandAvailable(this.tool)) { - return super.getInstalledVersion(); + return super.getInstalledEditionAndVersion(); } List outputs = this.context.newProcess().runAndGetOutput(this.tool, "version", "--client"); String singleLineOutput = String.join("\n", outputs); - return resolveVersionWithPattern(singleLineOutput, KUBECTL_VERSION_PATTERN); + VersionIdentifier version = resolveVersionWithPattern(singleLineOutput, KUBECTL_VERSION_PATTERN); + return new EditionAndVersion(this.tool, version); } } diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/node/NodeBasedCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/tool/node/NodeBasedCommandlet.java index fae9f29e7a..39138b460d 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/node/NodeBasedCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/node/NodeBasedCommandlet.java @@ -39,15 +39,6 @@ protected Node getParentTool() { return this.context.getCommandletManager().getCommandlet(Node.class); } - @Override - public String getInstalledEdition() { - - if (getInstalledVersion() != null) { - return this.tool; - } - return null; - } - /** * Checks if a provided binary can be found within node. * diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/IdeasyCommandletTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/IdeasyCommandletTest.java index de86e8c9ed..f5d68580d6 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/IdeasyCommandletTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/IdeasyCommandletTest.java @@ -231,8 +231,8 @@ void testCheckIfUpdateIsAvailableWithSameSnapshotVersions() { context.getStartContext().setOfflineMode(false); IdeasyCommandlet ideasy = new IdeasyCommandlet(context) { @Override - public VersionIdentifier getInstalledVersion() { - return VersionIdentifier.of("2025.04.002-04_17_02-SNAPSHOT"); + public EditionAndVersion getInstalledEditionAndVersion() { + return new EditionAndVersion("ideasy", VersionIdentifier.of("2025.04.002-04_17_02-SNAPSHOT")); } @Override @@ -260,8 +260,8 @@ void testCheckIfUpdateIsAvailableWithDifferentSnapshotVersions() { context.getStartContext().setOfflineMode(false); IdeasyCommandlet ideasy = new IdeasyCommandlet(context) { @Override - public VersionIdentifier getInstalledVersion() { - return VersionIdentifier.of("2025.04.002-04_17_02-SNAPSHOT"); + public EditionAndVersion getInstalledEditionAndVersion() { + return new EditionAndVersion("ideasy", VersionIdentifier.of("2025.04.002-04_17_02-SNAPSHOT")); } @Override diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/docker/DockerTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/docker/DockerTest.java new file mode 100644 index 0000000000..b7c14abf7f --- /dev/null +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/docker/DockerTest.java @@ -0,0 +1,201 @@ +package com.devonfw.tools.ide.tool.docker; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import com.devonfw.tools.ide.context.AbstractIdeContextTest; +import com.devonfw.tools.ide.context.IdeTestContext; +import com.devonfw.tools.ide.log.IdeLogLevel; +import com.devonfw.tools.ide.os.SystemInfoMock; +import com.devonfw.tools.ide.os.WindowsAppInstallation; +import com.devonfw.tools.ide.os.WindowsHelperMock; +import com.devonfw.tools.ide.process.ProcessContext; +import com.devonfw.tools.ide.tool.EditionAndVersion; +import com.devonfw.tools.ide.version.VersionIdentifier; + +/** + * Test of {@link Docker}. + */ +class DockerTest extends AbstractIdeContextTest { + + private static final String APT_LIST_COMMAND = "apt list --installed | grep docker-desktop | awk '{print $2}'"; + + private static final String PLUTIL_MAC_COMMAND = "plutil -extract CFBundleShortVersionString raw /Applications/Docker.app/Contents/Info.plist"; + + /** + * Creates a minimal {@link IdeTestContext} that returns a mocked {@link ProcessContext} from {@code createProcessContext()} + * (which is what {@code newProcess()} delegates to), so no real process is started. + * + * @param processContext the {@link ProcessContext} to use. + * @return the {@link IdeTestContext}. + */ + private IdeTestContext newContext(ProcessContext processContext) { + + return new IdeTestContext(Path.of("/"), null) { + @Override + protected ProcessContext createProcessContext() { + return processContext; + } + }; + } + + /** + * A {@link Docker} that reports only the given commands as available. + * + * @param context the {@link IdeTestContext}. + * @param availableCommands the command names that {@code isCommandAvailable} should report as available. + * @return the {@link Docker}. + */ + private Docker docker(IdeTestContext context, String... availableCommands) { + + return new Docker(context) { + @Override + protected boolean isCommandAvailable(String command) { + return List.of(availableCommands).contains(command); + } + }; + } + + /** + * Verifies that the edition of a (non-rancher) Docker Desktop installation is reported as {@code "docker"} — the consistent + * name used by {@link Docker#getWindowsRegistryAppNames()} — and not the previously hard-coded, non-existent edition + * {@code "desktop"}. The version is resolved from the per-OS source (here the Linux {@code apt list} output). + */ + @Test + void testDockerDesktopEditionIsDockerOnLinux() { + + // arrange + ProcessContext processContext = Mockito.mock(ProcessContext.class); + IdeTestContext context = newContext(processContext); + context.setSystemInfo(SystemInfoMock.LINUX_X64); + Docker docker = docker(context, "docker"); + Mockito.when(processContext.runAndGetSingleOutput(IdeLogLevel.WARNING, "bash", "-lc", APT_LIST_COMMAND)).thenReturn("20.10.5-ubuntu-focal"); + + // act + EditionAndVersion editionAndVersion = docker.getInstalledEditionAndVersion(); + + // assert + assertThat(editionAndVersion).isNotNull(); + assertThat(editionAndVersion.edition()).isEqualTo("docker"); + assertThat(editionAndVersion.version()).isEqualTo(VersionIdentifier.of("20.10.5")); + Mockito.verify(processContext).runAndGetSingleOutput(IdeLogLevel.WARNING, "bash", "-lc", APT_LIST_COMMAND); + } + + /** + * Verifies that on macOS the version of a Docker Desktop installation is resolved from the {@code Docker.app} bundle + * (via {@code plutil}), so the returned version is not {@code null}, while the edition stays the consistent {@code "docker"}. + */ + @Test + void testDockerDesktopEditionAndVersionOnMac() { + + // arrange + ProcessContext processContext = Mockito.mock(ProcessContext.class); + IdeTestContext context = newContext(processContext); + context.setSystemInfo(SystemInfoMock.MAC_X64); + Docker docker = docker(context, "docker"); + Mockito.when(processContext.runAndGetSingleOutput(IdeLogLevel.WARNING, "bash", "-lc", PLUTIL_MAC_COMMAND)).thenReturn("4.44.0"); + + // act + EditionAndVersion editionAndVersion = docker.getInstalledEditionAndVersion(); + + // assert + assertThat(editionAndVersion).isNotNull(); + assertThat(editionAndVersion.edition()).isEqualTo("docker"); + assertThat(editionAndVersion.version()).isEqualTo(VersionIdentifier.of("4.44.0")); + Mockito.verify(processContext).runAndGetSingleOutput(IdeLogLevel.WARNING, "bash", "-lc", PLUTIL_MAC_COMMAND); + } + + /** + * Verifies that on macOS the lookup degrades gracefully (no exception) when the Docker Desktop app is not present: the + * {@code plutil} call yields no usable output, so the version is {@code null} while the edition stays {@code "docker"}. + */ + @Test + void testDockerDesktopOnMacIsGracefulWhenAppMissing() { + + // arrange + ProcessContext processContext = Mockito.mock(ProcessContext.class); + IdeTestContext context = newContext(processContext); + context.setSystemInfo(SystemInfoMock.MAC_X64); + Docker docker = docker(context, "docker"); + Mockito.when(processContext.runAndGetSingleOutput(IdeLogLevel.WARNING, "bash", "-lc", PLUTIL_MAC_COMMAND)).thenReturn(null); + + // act + EditionAndVersion editionAndVersion = docker.getInstalledEditionAndVersion(); + + // assert + assertThat(editionAndVersion).isNotNull(); + assertThat(editionAndVersion.edition()).isEqualTo("docker"); + assertThat(editionAndVersion.version()).isNull(); + Mockito.verify(processContext).runAndGetSingleOutput(IdeLogLevel.WARNING, "bash", "-lc", PLUTIL_MAC_COMMAND); + } + + /** + * Verifies that when the installed runtime is Rancher Desktop, the edition is reported as {@code "rancher"} and the version + * comes from {@code rdctl version} (not from {@code apt list}). + */ + @Test + void testRancherDesktopEditionAndVersion() { + + // arrange + ProcessContext processContext = Mockito.mock(ProcessContext.class); + IdeTestContext context = newContext(processContext); + context.setSystemInfo(SystemInfoMock.LINUX_X64); + Docker docker = docker(context, "docker", "rdctl"); + Mockito.when(processContext.runAndGetSingleOutput("rdctl", "version")).thenReturn("client version: v1.13.0"); + + // act + EditionAndVersion editionAndVersion = docker.getInstalledEditionAndVersion(); + + // assert + assertThat(editionAndVersion).isNotNull(); + assertThat(editionAndVersion.edition()).isEqualTo("rancher"); + assertThat(editionAndVersion.version()).isEqualTo(VersionIdentifier.of("1.13.0")); + Mockito.verify(processContext).runAndGetSingleOutput("rdctl", "version"); + } + + /** + * Verifies that on Windows the version of a Docker Desktop installation is read from the Windows registry (via + * {@code super}), and the edition is the consistent {@code "docker"} (not the previously hard-coded {@code "desktop"}). + */ + @Test + void testDockerDesktopEditionAndVersionOnWindows() { + + // arrange + IdeTestContext context = newContext(Mockito.mock(ProcessContext.class)); + context.setSystemInfo(SystemInfoMock.WINDOWS_X64); + WindowsHelperMock helper = (WindowsHelperMock) context.getWindowsHelper(); + helper.setAppInstallationFromRegistry("Docker Desktop", new WindowsAppInstallation("4.44.0", null, null, null)); + Docker docker = docker(context, "docker"); + + // act + EditionAndVersion editionAndVersion = docker.getInstalledEditionAndVersion(); + + // assert + assertThat(editionAndVersion).isNotNull(); + assertThat(editionAndVersion.edition()).isEqualTo("docker"); + assertThat(editionAndVersion.version()).isEqualTo(VersionIdentifier.of("4.44.0")); + } + + /** + * Sanity check that the Windows registry edition names are keyed by the same consistent edition names used by + * {@link Docker#computeInstalledEditionAndVersion()} (the non-rancher edition is {@code "docker"}, not {@code "desktop"}). + */ + @Test + void testWindowsRegistryAppNamesUseConsistentEditionNames() { + + // arrange + IdeTestContext context = newContext(Mockito.mock(ProcessContext.class)); + Docker docker = new Docker(context); + + // act + Map appNames = docker.getWindowsRegistryAppNames(); + + // assert + assertThat(appNames).containsEntry("docker", "Docker Desktop").containsEntry("rancher", "Rancher Desktop"); + } + +} diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/kubectl/KubeCtlTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/kubectl/KubeCtlTest.java new file mode 100644 index 0000000000..cb00c72cb2 --- /dev/null +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/kubectl/KubeCtlTest.java @@ -0,0 +1,94 @@ +package com.devonfw.tools.ide.tool.kubectl; + +import java.nio.file.Path; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import com.devonfw.tools.ide.context.AbstractIdeContextTest; +import com.devonfw.tools.ide.context.IdeTestContext; +import com.devonfw.tools.ide.process.ProcessContext; +import com.devonfw.tools.ide.tool.EditionAndVersion; +import com.devonfw.tools.ide.version.VersionIdentifier; + +/** + * Test of {@link KubeCtl}. + */ +class KubeCtlTest extends AbstractIdeContextTest { + + private ProcessContext processContext; + + /** + * Creates a minimal {@link IdeTestContext} that returns the given output when {@code kubectl version --client} is executed. + * + * @param kubectlVersionClientOutput the output lines of {@code kubectl version --client}. + * @return the {@link IdeTestContext}. + */ + private IdeTestContext newContextWithKubectlOutput(String... kubectlVersionClientOutput) { + + this.processContext = Mockito.mock(ProcessContext.class); + Mockito.when(this.processContext.runAndGetOutput("kubectl", "version", "--client")).thenReturn(List.of(kubectlVersionClientOutput)); + return new IdeTestContext(Path.of("/"), null) { + @Override + protected ProcessContext createProcessContext() { + return KubeCtlTest.this.processContext; + } + }; + } + + /** + * Verifies that the installed version of KubeCtl is determined by running {@code kubectl version --client} (and not by delegating to + * {@link com.devonfw.tools.ide.tool.docker.Docker}). This is the behavior that regressed when the logic was moved to the protected + * {@code computeInstalledEditionAndVersion()} hook, which {@link com.devonfw.tools.ide.tool.DelegatingToolCommandlet} never invokes. + */ + @Test + void testGetInstalledVersionComesFromKubectlVersionClient() { + + // arrange + IdeTestContext context = newContextWithKubectlOutput("Client Version: v1.29.3", "Kustomize Version: v5.0.1"); + KubeCtl kubeCtl = new KubeCtl(context) { + @Override + protected boolean isCommandAvailable(String command) { + return true; + } + }; + + // act + EditionAndVersion editionAndVersion = kubeCtl.getInstalledEditionAndVersion(); + + // assert: version is parsed from the kubectl output and edition is the kubectl tool itself, not the Docker delegate + assertThat(editionAndVersion).isNotNull(); + assertThat(editionAndVersion.edition()).isEqualTo("kubectl"); + assertThat(editionAndVersion.version()).isEqualTo(VersionIdentifier.of("1.29.3")); + // the version must have come from invoking "kubectl version --client" + Mockito.verify(this.processContext).runAndGetOutput("kubectl", "version", "--client"); + } + + /** + * Verifies that the edition is still reported (the tool name) while the version is {@code null} when the output of + * {@code kubectl version --client} does not contain a recognizable client version. + */ + @Test + void testGetInstalledVersionIsNullWhenNoVersionFoundInOutput() { + + // arrange + IdeTestContext context = newContextWithKubectlOutput("some unexpected output without version"); + KubeCtl kubeCtl = new KubeCtl(context) { + @Override + protected boolean isCommandAvailable(String command) { + return true; + } + }; + + // act + EditionAndVersion editionAndVersion = kubeCtl.getInstalledEditionAndVersion(); + + // assert + assertThat(editionAndVersion).isNotNull(); + assertThat(editionAndVersion.edition()).isEqualTo("kubectl"); + assertThat(editionAndVersion.version()).isNull(); + Mockito.verify(this.processContext).runAndGetOutput("kubectl", "version", "--client"); + } + +} From 215439a6c008d9117e29fba95e7fb83de943050a Mon Sep 17 00:00:00 2001 From: krystynaShatkovska Date: Fri, 21 Aug 2026 10:47:56 +0200 Subject: [PATCH 2/2] #2296: remove accidental 'public' from IdeContext.findProjects(Path) --- cli/src/main/java/com/devonfw/tools/ide/context/IdeContext.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 e37d4af31d..e6add8a5f1 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 @@ -436,7 +436,7 @@ default List findProjects() { * @param ideRoot the IDE root containing the IDEasy projects. * @return the paths of all detected IDEasy projects. */ - public static List findProjects(Path ideRoot) { + static List findProjects(Path ideRoot) { if ((ideRoot == null) || !Files.isDirectory(ideRoot)) { return List.of();