diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc
index f14b20da19..2dd0f8405e 100644
--- a/CHANGELOG.adoc
+++ b/CHANGELOG.adoc
@@ -23,6 +23,7 @@ Release with new features and bugfixes:
* https://github.com/devonfw/IDEasy/issues/1165[#1165]: Fix automatic project import for Eclipse
* https://github.com/devonfw/IDEasy/issues/2040[#2040]: Fixed buggy workspace selection in the GUI
* https://github.com/devonfw/IDEasy/issues/2253[#2253]: Fix structure and log documentation
+* https://github.com/devonfw/IDEasy/issues/2219[#2219]: Add unpack commandlet
* https://github.com/devonfw/IDEasy/issues/2251[#2251]: Provide generic uninstall support for globally installed tools (windows)
* https://github.com/devonfw/IDEasy/issues/1135[#1135]: Fix PowerShell env variable initialization on Windows by sourcing functions from the PowerShell profile
* https://github.com/devonfw/IDEasy/issues/741[#741]: Add a warning message for legacy devonfw-ide settings users
diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManagerImpl.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManagerImpl.java
index 0d45e767ca..f98a63decd 100644
--- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManagerImpl.java
+++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManagerImpl.java
@@ -131,6 +131,7 @@ public CommandletManagerImpl(IdeContext context) {
add(new UninstallPluginCommandlet(context));
add(new UpgradeCommandlet(context));
add(new TruststoreCommandlet(context));
+ add(new UnpackCommandlet(context));
add(new Gh(context));
add(new Helm(context));
add(new Java(context));
diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/UnpackCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/UnpackCommandlet.java
new file mode 100644
index 0000000000..cf3251fe90
--- /dev/null
+++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/UnpackCommandlet.java
@@ -0,0 +1,89 @@
+package com.devonfw.tools.ide.commandlet;
+
+import java.nio.file.Path;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.devonfw.tools.ide.cli.CliException;
+import com.devonfw.tools.ide.context.IdeContext;
+import com.devonfw.tools.ide.property.FileProperty;
+import com.devonfw.tools.ide.property.PathProperty;
+import com.devonfw.tools.ide.util.FilenameUtil;
+
+/**
+ * {@link Commandlet} to extract an archive file to a target directory.
+ *
+ * Supports ZIP, TAR, TAR.GZ, TAR.BZ2, 7Z, JAR archives (cross-platform), as well as MSI (Windows) and DMG/PKG (Mac).
+ *
+ */
+public final class UnpackCommandlet extends Commandlet {
+
+ private static final Logger LOG = LoggerFactory.getLogger(UnpackCommandlet.class);
+
+ /** The archive file to extract. */
+ public final FileProperty archive;
+
+ /** The target directory to extract into. If not specified, defaults to {@code /}. */
+ public final PathProperty target;
+
+ /**
+ * The constructor.
+ *
+ * @param context the {@link IdeContext}.
+ */
+ public UnpackCommandlet(IdeContext context) {
+
+ super(context);
+ addKeyword(getName());
+
+ this.archive = add(new FileProperty("", true, "archive", true));
+ this.target = add(new PathProperty("", false, "target", false));
+ }
+
+ @Override
+ public String getName() {
+
+ return "unpack";
+ }
+
+ @Override
+ public boolean isIdeHomeRequired() {
+
+ return false;
+ }
+
+ @Override
+ public boolean isWriteLogFile() {
+
+ return false;
+ }
+
+ @Override
+ protected void doRun() {
+
+ Path cwd = this.context.getCwd();
+ if (cwd == null) {
+ throw new CliException("Missing current working directory!");
+ }
+
+ Path archivePath = this.archive.getValue();
+ if (!archivePath.isAbsolute()) {
+ archivePath = cwd.resolve(archivePath).normalize();
+ }
+
+ Path targetDir = this.target.getValue();
+ if (targetDir == null) {
+ // Derive default target from archive filename without extension
+ String targetName = FilenameUtil.getFilenameWithoutExtension(archivePath);
+ targetDir = cwd.resolve(targetName);
+ }
+ if (!targetDir.isAbsolute()) {
+ targetDir = cwd.resolve(targetDir).normalize();
+ }
+
+ LOG.info("Extracting {} to {}", archivePath, targetDir);
+ this.context.getFileAccess().extract(archivePath, targetDir);
+ LOG.info("Extraction completed successfully.");
+ }
+}
diff --git a/cli/src/main/resources/nls/Help.properties b/cli/src/main/resources/nls/Help.properties
index dd8b8b4580..e678805cc2 100644
--- a/cli/src/main/resources/nls/Help.properties
+++ b/cli/src/main/resources/nls/Help.properties
@@ -167,6 +167,8 @@ cmd.uninstall=Uninstall selected tool(s).
cmd.uninstall-plugin=Uninstall the selected plugin for the selected tool.
cmd.uninstall-plugin.detail=Plugins can be only installed or uninstalled for tools that support such. Using the command "ide install-plugin", an uninstalled plugin can be restored.
cmd.uninstall.detail=Can be used to uninstall selected tool(s). E.g. to uninstall java simply call 'ide uninstall java'. To uninstall IDEasy itself, run 'ide uninstall' without further arguments. To fully delete the selected tool from your machine, use force mode. E.g. 'ide --force uninstall java'.
+cmd.unpack=Unpack the selected archive.
+cmd.unpack.detail=Can be used to unpack an archive file. E.g. to unpack an archive call 'ide unpack archive.zip'.
cmd.update=Pull your settings and apply updates (software, configuration and repositories).
cmd.update.detail=To update your IDE (if instructed by your ide-admin), you only need to run the following command: 'ide update'.
cmd.upgrade=Upgrade the version of IDEasy to the latest version available.
@@ -208,6 +210,7 @@ options.global=Global options:
options.local=Local options:
toolcommandlets=Available tool commandlets:
usage=Usage:
+val.archive=The archive file to unpack.
val.args=The commandline arguments to pass to the tool.
val.cfg=Selection of the configuration file (settings | home | conf | workspace).
val.commandlet=The selected commandlet (use 'ide help' to list all commandlets).
@@ -216,6 +219,7 @@ val.link=The path where the link is created.
val.plugin=The plugin to select
val.settingsRepository=The settings git repository with the IDEasy configuration for the project.
val.source=The source path the link points to (existing file or directory).
+val.target=The target directory to extract the archive into.
val.tool=The tool commandlet to select.
val.version=The tool version.
values=Values:
diff --git a/cli/src/main/resources/nls/Help_de.properties b/cli/src/main/resources/nls/Help_de.properties
index e14d531489..8276ff9bae 100644
--- a/cli/src/main/resources/nls/Help_de.properties
+++ b/cli/src/main/resources/nls/Help_de.properties
@@ -167,6 +167,8 @@ cmd.uninstall=Deinstalliert ausgewählte Werkzeug(e).
cmd.uninstall-plugin=Deinstalliert die selektierte Erweiterung für das selektierte Werkzeug.
cmd.uninstall-plugin.detail=Erweiterung können nur für Werkzeuge installiert und deinstalliert werden die diese unterstützen. Mit dem Befehl "ide install-plugin" kann die Erweiterung wieder hergestellt werden.
cmd.uninstall.detail=Wird dazu verwendet um ausgewählte Werkzeuge zu deinstallieren. Um z.B. Java zu deinstallieren, dient der Befehl 'ide uninstall java'. Um IDEasy selbst zu installieren, dient der Befehl 'ide uninstall' ohne weitere Parameter. Um ein aktuelles Werzeug vollständig von der Maschine zu löschen, wird der Force-Modus verwendet. Z.B. 'ide --force uninstall java'.
+cmd.unpack=Entpackt das ausgewählte Archiv.
+cmd.unpack.detail=Dies wird das ausgewählte Archiv entpacken. Um ein Archiv zu entpacken, dient der Befehl 'ide unpack datei.zip'.
cmd.update=Updatet die Settings, Software und Repositories.
cmd.update.detail=Um die IDE auf den neuesten Stand zu bringen (falls von Ihrem Admin angewiesen) geben Sie einfach 'ide update' in die Konsole ein.
cmd.upgrade=Aktualisiere IDEasy auf die neueste Version.
@@ -208,6 +210,7 @@ options.global=Globale Optionen:
options.local=Lokale Optionen:
toolcommandlets=Verfügbare Werkzeug Kommandos:
usage=Verwendung:
+val.archive=Die zu entpackende Archivdatei.
val.args=Die Kommandozeilen-Argumente zur Übergabe an das Werkzeug.
val.cfg=Auswahl der Konfigurationsdatei (settings | home | conf | workspace).
val.commandlet=Das ausgewählte Commandlet ("ide help" verwenden, um alle Commandlets aufzulisten).
@@ -216,6 +219,7 @@ val.link=Pfad des zu erstellenden Links.
val.plugin=Die zu selektierende Erweiterung.
val.settingsRepository=Das settings git Repository mit den IDEasy Einstellungen für das Projekt.
val.source=Ziel des Links (existierender Pfad).
+val.target=Ziel des zu extrahierenden Archives (neuer Pfad).
val.tool=Das zu selektierende Werkzeug Kommando.
val.version=Die Werkzeug Version.
values=Werte:
diff --git a/cli/src/test/java/com/devonfw/tools/ide/commandlet/UnpackCommandletTest.java b/cli/src/test/java/com/devonfw/tools/ide/commandlet/UnpackCommandletTest.java
new file mode 100644
index 0000000000..9c6c9f2f39
--- /dev/null
+++ b/cli/src/test/java/com/devonfw/tools/ide/commandlet/UnpackCommandletTest.java
@@ -0,0 +1,159 @@
+package com.devonfw.tools.ide.commandlet;
+
+import java.io.IOException;
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.Test;
+
+import com.devonfw.tools.ide.cli.CliArguments;
+import com.devonfw.tools.ide.context.AbstractIdeContextTest;
+import com.devonfw.tools.ide.context.IdeTestContext;
+
+/**
+ * Test of {@link UnpackCommandlet}.
+ */
+class UnpackCommandletTest extends AbstractIdeContextTest {
+
+ /** Base filename of the test archive without extension. */
+ private static final String TEST_ARCHIVE_BASENAME = "executable_and_non_executable";
+
+ /** Path to the test archive directory. */
+ private static final Path TEST_ARCHIVE_DIR = Path.of("src/test/resources/com/devonfw/tools/ide/io");
+
+ /** Test ZIP archive. */
+ private static final Path TEST_ARCHIVE_ZIP = TEST_ARCHIVE_DIR.resolve(TEST_ARCHIVE_BASENAME + ".zip");
+
+ /** Test TAR.GZ archive. */
+ private static final Path TEST_ARCHIVE_TAR_GZ = TEST_ARCHIVE_DIR.resolve(TEST_ARCHIVE_BASENAME + ".tar.gz");
+
+ /** Test 7Z archive. */
+ private static final Path TEST_ARCHIVE_7Z = TEST_ARCHIVE_DIR.resolve(TEST_ARCHIVE_BASENAME + ".7z");
+
+ /**
+ * Tests extraction of a ZIP archive to the default target directory derived from the archive filename.
+ */
+ @Test
+ void testUnpackZipWithDefaultTarget() throws IOException {
+
+ IdeTestContext context = newContext(PROJECT_BASIC);
+
+ Path archive = TEST_ARCHIVE_ZIP.toAbsolutePath();
+ UnpackCommandlet cmd = new UnpackCommandlet(context);
+ cmd.archive.setValue(archive);
+
+ cmd.run();
+
+ Path expectedTarget = context.getCwd().resolve(TEST_ARCHIVE_BASENAME);
+ assertThat(expectedTarget).isDirectory();
+ assertThat(expectedTarget.resolve("executableFile.txt")).isRegularFile();
+ assertThat(expectedTarget.resolve("nonExecutableFile.txt")).isRegularFile();
+ }
+
+ /**
+ * Tests extraction of a ZIP archive to an explicit target directory via --target.
+ */
+ @Test
+ void testUnpackZipWithExplicitTarget() throws IOException {
+
+ IdeTestContext context = newContext(PROJECT_BASIC);
+
+ Path testDir = context.getWorkspacePath().resolve("unpack-test");
+ context.getFileAccess().mkdirs(testDir);
+ context.setCwd(testDir, context.getWorkspaceName(), context.getIdeHome());
+
+ Path archive = TEST_ARCHIVE_ZIP.toAbsolutePath();
+ Path target = testDir.resolve("my-extraction");
+
+ UnpackCommandlet cmd = new UnpackCommandlet(context);
+ cmd.archive.setValue(archive);
+ cmd.target.setValue(target);
+
+ cmd.run();
+
+ assertThat(target).isDirectory();
+ assertThat(target.resolve("executableFile.txt")).isRegularFile();
+ assertThat(target.resolve("nonExecutableFile.txt")).isRegularFile();
+ }
+
+ /**
+ * Tests that extracting a non-existing archive fails with an appropriate error.
+ */
+ @Test
+ void testUnpackNonExistingArchiveFails() {
+
+ IdeTestContext context = newContext(PROJECT_BASIC);
+
+ UnpackCommandlet cmd = new UnpackCommandlet(context);
+ cmd.archive.setValue(Path.of("does_not_exist.zip"));
+
+ assertThatExceptionOfType(IllegalStateException.class)
+ .isThrownBy(cmd::run)
+ .withMessageContaining("does_not_exist.zip")
+ .withMessageContaining("Failed to extract");
+ }
+
+ /**
+ * Tests extraction of a tar.gz archive with default target directory.
+ */
+ @Test
+ void testUnpackTarGzWithDefaultTarget() throws IOException {
+
+ IdeTestContext context = newContext(PROJECT_BASIC);
+
+ Path archive = TEST_ARCHIVE_TAR_GZ.toAbsolutePath();
+ UnpackCommandlet cmd = new UnpackCommandlet(context);
+ cmd.archive.setValue(archive);
+
+ cmd.run();
+
+ Path expectedTarget = context.getCwd().resolve(TEST_ARCHIVE_BASENAME);
+ assertThat(expectedTarget).isDirectory();
+ }
+
+ /**
+ * Tests extraction of a 7z archive with default target directory.
+ */
+ @Test
+ void testUnpack7zWithDefaultTarget() throws IOException {
+
+ IdeTestContext context = newContext(PROJECT_BASIC);
+
+ Path archive = TEST_ARCHIVE_7Z.toAbsolutePath();
+ UnpackCommandlet cmd = new UnpackCommandlet(context);
+ cmd.archive.setValue(archive);
+
+ cmd.run();
+
+ Path expectedTarget = context.getCwd().resolve(TEST_ARCHIVE_BASENAME);
+ assertThat(expectedTarget).isDirectory();
+ }
+
+ /**
+ * End-to-end test that a positional {@code target} argument on the command line ({@code ide unpack })
+ * is bound to the target property and the archive is extracted into the given directory.
+ *
+ * The {@code target} property has no long option (it is a positional value argument); this guards that the positional
+ * binding works end-to-end, which the property-level tests above (which set the property directly) cannot verify.
+ *
+ */
+ @Test
+ void testUnpackWithPositionalTarget() throws IOException {
+
+ IdeTestContext context = newContext(PROJECT_BASIC);
+
+ Path archive = TEST_ARCHIVE_ZIP.toAbsolutePath();
+ Path target = context.getCwd().resolve("e2e-unpack-target");
+ CliArguments args = new CliArguments("unpack", archive.toString(), target.toString());
+ args.next();
+
+ int exitCode = context.run(args);
+
+ assertThat(exitCode).isEqualTo(0);
+ assertThat(context).logAtError().hasNoMessageContaining("Unknown command");
+ assertThat(context).logAtError().hasNoMessageContaining("Invalid option");
+ assertThat(context).logAtError().hasNoMessageContaining("No matching property");
+ assertThat(target).isDirectory();
+ assertThat(target.resolve("executableFile.txt")).isRegularFile();
+ assertThat(target.resolve("nonExecutableFile.txt")).isRegularFile();
+ }
+}