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 @@ -7,6 +7,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/1653[#1653]: Implementation of getEdition and getVersion for Docker
* https://github.com/devonfw/IDEasy/issues/1602[#1602]: Being offline can block ide startup

The full list of changes for this release can be found in https://github.com/devonfw/IDEasy/milestone/39?closed=1[milestone 2026.01.001].

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import java.util.Collection;
import java.util.Set;

import com.devonfw.tools.ide.cli.CliOfflineException;
import com.devonfw.tools.ide.common.Tag;
import com.devonfw.tools.ide.context.IdeContext;
import com.devonfw.tools.ide.io.FileAccess;
Expand Down Expand Up @@ -198,8 +199,13 @@ public ToolInstallation installTool(ToolInstallRequest request) {
}
}
}
performToolInstallation(request, installationPath);
return createToolInstallation(installationPath, resolvedVersion, true, processContext, additionalInstallation);
VersionIdentifier actualInstalledVersion = performToolInstallation(request, installationPath, resolvedVersion);
// If offline and could not download, actualInstalledVersion will be the old version, not resolvedVersion
// In that case, we need to recalculate the installation path for the actually installed version
if (!actualInstalledVersion.equals(resolvedVersion)) {
installationPath = getInstallationPath(edition, actualInstalledVersion);
}
return createToolInstallation(installationPath, actualInstalledVersion, true, processContext, additionalInstallation);
}

/**
Expand All @@ -211,13 +217,29 @@ public ToolInstallation installTool(ToolInstallRequest request) {
*
* @param request the {@link ToolInstallRequest}.
* @param installationPath the target {@link Path} where the {@link #getName() tool} should be installed.
* @param resolvedVersion the {@link VersionIdentifier} that should be installed.
* @return the {@link VersionIdentifier} of the version that was actually installed. In offline scenarios where download fails, this may be different from
* {@code resolvedVersion} (returning the existing installed version instead).
*/
protected void performToolInstallation(ToolInstallRequest request, Path installationPath) {
protected VersionIdentifier performToolInstallation(ToolInstallRequest request, Path installationPath, VersionIdentifier resolvedVersion) {

FileAccess fileAccess = this.context.getFileAccess();
ToolEditionAndVersion requested = request.getRequested();
VersionIdentifier resolvedVersion = requested.getResolvedVersion();
Path downloadedToolFile = downloadTool(requested.getEdition().edition(), resolvedVersion);
Path downloadedToolFile;
try {
downloadedToolFile = downloadTool(requested.getEdition().edition(), resolvedVersion);
} catch (CliOfflineException e) {
// If we are offline and cannot download, check if we can continue with an existing installation
ToolEditionAndVersion installed = request.getInstalled();
if ((installed != null) && (installed.getResolvedVersion() != null)) {
this.context.warning("Cannot download {} in version {} because we are offline. Continuing with already installed version {}.",
this.tool, resolvedVersion, installed.getResolvedVersion());
// Return the existing installed version to indicate fallback
return installed.getResolvedVersion();
}
// No existing installation available, re-throw the exception
throw e;
}
boolean extract = isExtract();
if (!extract) {
this.context.trace("Extraction is disabled for '{}' hence just moving the downloaded file {}.", this.tool, downloadedToolFile);
Expand All @@ -233,6 +255,7 @@ protected void performToolInstallation(ToolInstallRequest request, Path installa
fileAccess.extract(downloadedToolFile, installationPath, this::postExtract, extract);
this.context.writeVersionFile(resolvedVersion, installationPath);
this.context.debug("Installed {} in version {} at {}", this.tool, resolvedVersion, installationPath);
return resolvedVersion;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,12 +161,13 @@ public VersionIdentifier getInstalledVersion() {
}

@Override
protected final void performToolInstallation(ToolInstallRequest request, Path installationPath) {
protected final VersionIdentifier performToolInstallation(ToolInstallRequest request, Path installationPath, VersionIdentifier resolvedVersion) {

PackageManagerRequest packageManagerRequest = new PackageManagerRequest(PackageManagerRequest.TYPE_INSTALL, getPackageName())
.setProcessContext(request.getProcessContext()).setVersion(request.getRequested().getResolvedVersion());
.setProcessContext(request.getProcessContext()).setVersion(resolvedVersion);
runPackageManager(packageManagerRequest, true).failOnError();
this.installedVersion.invalidate();
return resolvedVersion;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,8 @@ public Python(IdeContext context) {
}

@Override
protected void performToolInstallation(ToolInstallRequest request, Path installationPath) {
protected VersionIdentifier performToolInstallation(ToolInstallRequest request, Path installationPath, VersionIdentifier resolvedVersion) {

VersionIdentifier resolvedVersion = request.getRequested().getResolvedVersion();
if (resolvedVersion.compareVersion(PYTHON_MIN_VERSION).isLess()) {
throw new CliException("Python version must be at least " + this.PYTHON_MIN_VERSION);
}
Expand All @@ -53,7 +52,7 @@ protected void performToolInstallation(ToolInstallRequest request, Path installa
renameVenvFolderToPython(fileAccess, softwarePath, installationPath);
this.context.writeVersionFile(resolvedVersion, installationPath);
createWindowsSymlinkBinFolder(fileAccess, installationPath);
this.context.debug("Installed {} in version {} at {}", this.tool, resolvedVersion, installationPath);
return resolvedVersion;
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,6 @@ public Path download(String tool, String edition, VersionIdentifier version, Too
*/
public Path download(UrlDownloadFileMetadata metadata) {

VersionIdentifier version = metadata.getVersion();
if (context.isOffline()) {
throw CliOfflineException.ofDownloadOfTool(metadata.getTool(), metadata.getEdition(), version);
}
Set<String> urlCollection = metadata.getUrls();
if (urlCollection.isEmpty()) {
throw new IllegalStateException("Invalid download metadata with empty urls file for " + metadata);
Expand All @@ -92,9 +88,19 @@ protected Path doDownload(UrlDownloadFileMetadata metadata) {
Path downloadCache = this.context.getDownloadPath().resolve(getId());
this.context.getFileAccess().mkdirs(downloadCache);
Path target = downloadCache.resolve(downloadFilename);

if (Files.exists(target)) {
this.context.interaction("Artifact already exists at {}\nTo force update please delete the file and run again.", target);
// File is already cached
if (this.context.getNetworkStatus().isOffline()) {
this.context.debug("Using cached download of {} in version {} from {} (offline mode)",
metadata.getTool(), metadata.getVersion(), target);
} else {
this.context.interaction("Artifact already exists at {}\nTo force update please delete the file and run again.", target);
}
} else {
if (this.context.getNetworkStatus().isOffline()) {
throw CliOfflineException.ofDownloadOfTool(metadata.getTool(), metadata.getEdition(), metadata.getVersion());
}
target = download(metadata, target);
}
return target;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import org.junit.jupiter.api.Test;

import com.devonfw.tools.ide.cli.CliOfflineException;
import com.devonfw.tools.ide.context.AbstractIdeContextTest;
import com.devonfw.tools.ide.context.IdeContext;
import com.devonfw.tools.ide.context.IdeTestContext;
Expand All @@ -12,6 +13,8 @@
import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
import com.github.tomakehurst.wiremock.junit5.WireMockTest;

import static org.junit.jupiter.api.Assertions.assertThrows;

/**
* Integration test of {@link InstallCommandlet}.
*/
Expand Down Expand Up @@ -202,4 +205,102 @@ public void testInstallCommandletWithSkipUpdatesInstallsWhenVersionMismatch(Wire
assertThat(hasDownloadMessage).as("Should download when --skip-updates is enabled but version does not match").isTrue();
assertThat(context.getSoftwarePath().resolve("java/.ide.software.version")).exists().hasContent("21.0.8_9");
}

/**
* Test of {@link InstallCommandlet} when offline but tool version is already downloaded. Verifies that the tool can be installed from cached download even
* when offline.
*
* @param wmRuntimeInfo wireMock server on a random port
*/
@Test
public void testInstallCommandletOfflineWithCachedDownload(WireMockRuntimeInfo wmRuntimeInfo) {

// arrange - install java while online to ensure it's downloaded
IdeTestContext context = newContext(PROJECT_INSTALL, wmRuntimeInfo);
InstallCommandlet install = context.getCommandletManager().getCommandlet(InstallCommandlet.class);
install.tool.setValueAsString("java", context);
String version = "17.0.6";
install.version.setValueAsString(version, context);
install.run();
assertThat(context.getSoftwarePath().resolve("java/.ide.software.version")).exists().hasContent(version);

// arrange - delete the installed tool but keep the cached download
context.getFileAccess().delete(context.getSoftwarePath().resolve("java"));
context.getFileAccess().delete(context.getSoftwareRepositoryPath().resolve(DefaultToolRepository.ID_DEFAULT).resolve("java"));

// arrange - simulate offline mode
context.getNetworkStatus().simulateNetworkError();

// act - try to install again while offline
install = context.getCommandletManager().getCommandlet(InstallCommandlet.class);
install.tool.setValueAsString("java", context);
install.version.setValueAsString(version, context);
install.run();

// assert - should successfully install from cached download
assertThat(context.getSoftwarePath().resolve("java/.ide.software.version")).exists().hasContent(version);
// Determine the correct file extension and OS name based on the current OS
String fileExtension = context.getSystemInfo().isWindows() ? "zip" : "tgz";
String osName = context.getSystemInfo().getOs().toString().toLowerCase();
assertThat(context).logAtDebug().hasMessage("Using cached download of java in version " + version + " from "
+ context.getDownloadPath().resolve("default").resolve("java-" + version + "-" + osName + "-x64." + fileExtension) + " (offline mode)");
}

/**
* Test of {@link InstallCommandlet} when offline and tool version is not cached. Verifies that installation fails with appropriate exception when tool is not
* available locally and we are offline.
*
* @param wmRuntimeInfo wireMock server on a random port
*/
@Test
public void testInstallCommandletOfflineWithoutCachedDownload(WireMockRuntimeInfo wmRuntimeInfo) {

// arrange - create context without pre-installing anything
IdeTestContext context = newContext(PROJECT_INSTALL, wmRuntimeInfo);
context.getNetworkStatus().simulateNetworkError();
InstallCommandlet install = context.getCommandletManager().getCommandlet(InstallCommandlet.class);
install.tool.setValueAsString("java", context);
String version = "17.0.6";
install.version.setValueAsString(version, context);

// act & assert - should fail with CliOfflineException
CliOfflineException e = assertThrows(CliOfflineException.class, install::run);
assertThat(e).hasMessageContaining("Not able to download tool java");
assertThat(e).hasMessageContaining("because we are offline");
}

/**
* Test of {@link InstallCommandlet} when offline and trying to update to a version that is not cached. Verifies that IDEasy continues using the currently
* installed version instead of blocking the user.
*
* @param wmRuntimeInfo wireMock server on a random port
*/
@Test
public void testInstallCommandletOfflineUpdateWithoutCachedDownload(WireMockRuntimeInfo wmRuntimeInfo) {

// arrange - install java 17.0.6 while online
IdeTestContext context = newContext(PROJECT_INSTALL, wmRuntimeInfo);
InstallCommandlet install = context.getCommandletManager().getCommandlet(InstallCommandlet.class);
install.tool.setValueAsString("java", context);
String installedVersion = "17.0.6";
install.version.setValueAsString(installedVersion, context);
install.run();
assertThat(context.getSoftwarePath().resolve("java/.ide.software.version")).exists().hasContent(installedVersion);

// arrange - try to update to 17.0.10 while offline (version not cached)
context.getNetworkStatus().simulateNetworkError();
install = context.getCommandletManager().getCommandlet(InstallCommandlet.class);
install.tool.setValueAsString("java", context);
String targetVersion = "17.0.10";
install.version.setValueAsString(targetVersion, context);

// act - try to install/update while offline
install.run();

// assert - should continue using the old version and log a warning
assertThat(context.getSoftwarePath().resolve("java/.ide.software.version")).exists().hasContent(installedVersion);
assertThat(context).logAtWarning().hasMessage("Cannot download java in version " + targetVersion
+ " because we are offline. Continuing with already installed version " + installedVersion + ".");
}
}