• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

devonfw / IDEasy / 31004215456

05 Aug 2026 12:06PM UTC coverage: 72.673% (+0.09%) from 72.587%
31004215456

Pull #2232

github

web-flow
Merge 7163be88d into cd9770b9c
Pull Request #2232: #1135: Add PowerShell environment initialization

5037 of 7675 branches covered (65.63%)

Branch coverage included in aggregate %.

13121 of 17311 relevant lines covered (75.8%)

3.22 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

76.1
cli/src/main/java/com/devonfw/tools/ide/tool/IdeasyCommandlet.java
1
package com.devonfw.tools.ide.tool;
2

3
import java.io.IOException;
4
import java.io.Reader;
5
import java.io.Writer;
6
import java.nio.file.Files;
7
import java.nio.file.Path;
8
import java.util.ArrayList;
9
import java.util.Iterator;
10
import java.util.List;
11
import java.util.Map;
12
import java.util.Objects;
13
import java.util.Set;
14
import java.util.regex.Matcher;
15
import java.util.regex.Pattern;
16

17
import org.slf4j.Logger;
18
import org.slf4j.LoggerFactory;
19

20
import com.devonfw.tools.ide.cli.CliException;
21
import com.devonfw.tools.ide.commandlet.UpgradeMode;
22
import com.devonfw.tools.ide.common.SimpleSystemPath;
23
import com.devonfw.tools.ide.common.Tag;
24
import com.devonfw.tools.ide.context.IdeContext;
25
import com.devonfw.tools.ide.io.FileAccess;
26
import com.devonfw.tools.ide.io.ini.IniFile;
27
import com.devonfw.tools.ide.io.ini.IniSection;
28
import com.devonfw.tools.ide.log.IdeLogLevel;
29
import com.devonfw.tools.ide.os.WindowsHelper;
30
import com.devonfw.tools.ide.os.WindowsPathSyntax;
31
import com.devonfw.tools.ide.process.ProcessMode;
32
import com.devonfw.tools.ide.process.ProcessResult;
33
import com.devonfw.tools.ide.tool.mvn.MvnArtifact;
34
import com.devonfw.tools.ide.tool.mvn.MvnBasedLocalToolCommandlet;
35
import com.devonfw.tools.ide.tool.mvn.MvnRepository;
36
import com.devonfw.tools.ide.variable.IdeVariables;
37
import com.devonfw.tools.ide.version.IdeVersion;
38
import com.devonfw.tools.ide.version.VersionIdentifier;
39
import com.fasterxml.jackson.databind.JsonNode;
40
import com.fasterxml.jackson.databind.ObjectMapper;
41
import com.fasterxml.jackson.databind.node.ArrayNode;
42
import com.fasterxml.jackson.databind.node.ObjectNode;
43

44
/**
45
 * {@link MvnBasedLocalToolCommandlet} for IDEasy (ide-cli).
46
 */
47
public class IdeasyCommandlet extends MvnBasedLocalToolCommandlet {
48

49
  private static final Logger LOG = LoggerFactory.getLogger(IdeasyCommandlet.class);
3✔
50

51
  /** The {@link MvnArtifact} for IDEasy. */
52
  public static final MvnArtifact ARTIFACT = MvnArtifact.ofIdeasyCli("*!", "tar.gz", "${os}-${arch}");
5✔
53

54
  private static final String BASH_CODE_SOURCE_FUNCTIONS = "source \"$IDE_ROOT/_ide/installation/functions\"";
55

56
  private static final String POWERSHELL_CODE_SOURCE_FUNCTIONS =
57
      ". \"$env:IDE_ROOT\\_ide\\installation\\functions.ps1\"";
58

59
  /** The {@link #getName() tool name}. */
60
  public static final String TOOL_NAME = "ideasy";
61
  public static final String BASHRC = ".bashrc";
62
  public static final String ZSHRC = ".zshrc";
63
  public static final String IDE_BIN = "\\_ide\\bin";
64
  public static final String IDE_INSTALLATION_BIN = "\\_ide\\installation\\bin";
65

66

67
  private static final Map<String, Boolean> REQUIRED_INSTALLATION_ARTIFACTS = Map.of(
4✔
68
      //artifactName: String, required: boolean
69
      "bin", true,
3✔
70
      "functions", true,
3✔
71
      "functions.ps1", true,
3✔
72
      "internal", true,
3✔
73
      "gui", true,
3✔
74
      "system", true,
3✔
75
      "IDEasy.pdf", true,
3✔
76
      "setup", true,
3✔
77
      "setup.bat", false
1✔
78
  );
79

80
  private final UpgradeMode mode;
81

82
  /** Pattern for IDEasy SNAPSHOT versions built locally. */
83
  // ..............................................................................1..........................2........3........4
84
  private static final Pattern PATTERN_IDEASY_SNAPSHOT_VERSION = Pattern.compile("^(\\d{4}\\.\\d{2}\\.\\d{3})-(\\d{2})_(\\d{2})_(\\d{2}).*-SNAPSHOT$");
3✔
85

86
  /** Pattern for Maven/Nexus SNAPSHOT versions from downloads . */
87
  // .............................................................................1..........................2.......3.......4..........5
88
  private static final Pattern PATTERN_MAVEN_SNAPSHOT_VERSION = Pattern.compile("^(\\d{4}\\.\\d{2}\\.\\d{3})-(\\d{4})(\\d{2})(\\d{2})\\.(\\d{2})\\d{4}.*$");
4✔
89

90
  // Group numbers for PATTERN_IDEASY_SNAPSHOT_VERSION
91
  private static final int GROUP_IDEASY_BASE = 1;
92
  private static final int GROUP_IDEASY_MONTH = 2;
93
  private static final int GROUP_IDEASY_DAY = 3;
94
  private static final int GROUP_IDEASY_HOUR = 4;
95

96
  // Group numbers for PATTERN_MAVEN_SNAPSHOT_VERSION
97
  private static final int GROUP_MAVEN_BASE = 1;
98
  private static final int GROUP_MAVEN_YEAR = 2;
99
  private static final int GROUP_MAVEN_MONTH = 3;
100
  private static final int GROUP_MAVEN_DAY = 4;
101
  private static final int GROUP_MAVEN_HOUR = 5;
102

103
  /**
104
   * The constructor.
105
   *
106
   * @param context the {@link IdeContext}.
107
   */
108
  public IdeasyCommandlet(IdeContext context) {
109
    this(context, UpgradeMode.STABLE);
4✔
110
  }
1✔
111

112
  /**
113
   * The constructor.
114
   *
115
   * @param context the {@link IdeContext}.
116
   * @param mode the {@link UpgradeMode}.
117
   */
118
  public IdeasyCommandlet(IdeContext context, UpgradeMode mode) {
119

120
    super(context, TOOL_NAME, ARTIFACT, Set.of(Tag.PRODUCTIVITY, Tag.IDE));
8✔
121
    this.mode = mode;
3✔
122
  }
1✔
123

124
  @Override
125
  public VersionIdentifier getInstalledVersion() {
126

127
    return IdeVersion.getVersionIdentifier();
2✔
128
  }
129

130
  @Override
131
  public String getInstalledEdition() {
132

133
    return this.tool;
3✔
134
  }
135

136
  @Override
137
  public String getConfiguredEdition() {
138

139
    return this.tool;
3✔
140
  }
141

142
  @Override
143
  public VersionIdentifier getConfiguredVersion() {
144

145
    UpgradeMode upgradeMode = this.mode;
3✔
146
    if (upgradeMode == null) {
2!
147
      if (IdeVersion.isSnapshot()) {
2✔
148
        upgradeMode = UpgradeMode.SNAPSHOT;
3✔
149
      } else {
150
        if (IdeVersion.getVersionIdentifier().getDevelopmentPhase().isStable()) {
4!
151
          upgradeMode = UpgradeMode.STABLE;
3✔
152
        } else {
153
          upgradeMode = UpgradeMode.UNSTABLE;
×
154
        }
155
      }
156
    }
157
    return upgradeMode.getVersion();
3✔
158
  }
159

160
  @Override
161
  public Path getToolPath() {
162

163
    return this.context.getIdeInstallationPath();
4✔
164
  }
165

166
  @Override
167
  protected ToolInstallation doInstall(ToolInstallRequest request) {
168

169
    this.context.requireOnline("upgrade of IDEasy", true);
5✔
170

171
    if (IdeVersion.isUndefined() && !this.context.isForceMode()) {
6!
172
      VersionIdentifier version = IdeVersion.getVersionIdentifier();
2✔
173
      LOG.warn("You are using IDEasy version {} which indicates local development - skipping upgrade.", version);
4✔
174
      return toolAlreadyInstalled(request);
4✔
175
    }
176
    return super.doInstall(request);
×
177
  }
178

179
  /**
180
   * @return the latest released {@link VersionIdentifier version} of IDEasy.
181
   */
182
  public VersionIdentifier getLatestVersion() {
183

184
    if (!this.context.isForceMode()) {
4!
185
      VersionIdentifier currentVersion = IdeVersion.getVersionIdentifier();
2✔
186
      if (IdeVersion.isUndefined()) {
2✔
187
        return currentVersion;
2✔
188
      }
189
    }
190
    VersionIdentifier configuredVersion = getConfiguredVersion();
3✔
191
    return getToolRepository().resolveVersion(this.tool, getConfiguredEdition(), configuredVersion, this);
10✔
192
  }
193

194
  /**
195
   * Checks if an update is available and logs according information.
196
   *
197
   * @return {@code true} if an update is available, {@code false} otherwise.
198
   */
199
  public boolean checkIfUpdateIsAvailable() {
200
    VersionIdentifier installedVersion = getInstalledVersion();
3✔
201
    IdeLogLevel.SUCCESS.log(LOG, "Your version of IDEasy is {}.", installedVersion);
10✔
202
    if (IdeVersion.isSnapshot()) {
2✔
203
      LOG.warn("You are using a SNAPSHOT version of IDEasy. For stability consider switching to a stable release via 'ide upgrade --mode=stable'");
3✔
204
    }
205
    if (this.context.isOffline()) {
4✔
206
      LOG.warn("Skipping check for newer version of IDEasy because you are offline.");
3✔
207
      return false;
2✔
208
    }
209
    VersionIdentifier latestVersion = getLatestVersion();
3✔
210
    if (IdeVersion.isSnapshot()) {
2✔
211
      if (isSameSnapshotVersion(installedVersion.toString(), latestVersion.toString())) {
7✔
212
        IdeLogLevel.SUCCESS.log(LOG, "Your are using the latest snapshot version of IDEasy and no update is available.");
4✔
213
        return false;
2✔
214
      }
215
    } else if (installedVersion.equals(latestVersion)) {
4!
216
      IdeLogLevel.SUCCESS.log(LOG, "Your are using the latest stable version of IDEasy and no update is available.");
×
217
      return false;
×
218
    }
219
    IdeLogLevel.INTERACTION.log(LOG,
14✔
220
        "Your version of IDEasy is {} but version {} is available. Please run the following command to upgrade to the latest version:\n"
221
            + "ide upgrade", installedVersion, latestVersion);
222
    return true;
2✔
223
  }
224

225
  /**
226
   * Checks if two snapshot versions represent the version
227
   *
228
   * @param installed the installed version string
229
   * @param latest the latest available version string
230
   * @return {@code true} if both versions represent the same version, {@code false} otherwise.
231
   */
232
  private boolean isSameSnapshotVersion(String installed, String latest) {
233
    if (installed == null || latest == null) {
4!
234
      return false;
×
235
    }
236

237
    Matcher installedMatcher = PATTERN_IDEASY_SNAPSHOT_VERSION.matcher(installed);
4✔
238
    Matcher latestMatcher = PATTERN_MAVEN_SNAPSHOT_VERSION.matcher(latest);
4✔
239

240
    if (!installedMatcher.matches() || !latestMatcher.matches()) {
6!
241
      return false;
2✔
242
    }
243

244
    // Compare base versions
245
    String baseInstalled = installedMatcher.group(GROUP_IDEASY_BASE);
4✔
246
    String baseLatest = latestMatcher.group(GROUP_MAVEN_BASE);
4✔
247
    if (!baseInstalled.equals(baseLatest)) {
4✔
248
      return false;
2✔
249
    }
250

251
    // Compare year
252
    String yearLatest = latestMatcher.group(GROUP_MAVEN_YEAR);
4✔
253
    String baseYear = baseInstalled.split("\\.")[0];
6✔
254
    if (!baseYear.equals(yearLatest)) {
4!
255
      return false;
×
256
    }
257

258
    // Compare MMDD.HH for both versions
259
    String keyInstalled =
2✔
260
        installedMatcher.group(GROUP_IDEASY_MONTH) + installedMatcher.group(GROUP_IDEASY_DAY) + "." + installedMatcher.group(GROUP_IDEASY_HOUR);
9✔
261
    String keyLatest = latestMatcher.group(GROUP_MAVEN_MONTH) + latestMatcher.group(GROUP_MAVEN_DAY) + "." + latestMatcher.group(GROUP_MAVEN_HOUR);
11✔
262

263
    return keyInstalled.equals(keyLatest);
4✔
264
  }
265

266
  /**
267
   * Initial installation of IDEasy.
268
   *
269
   * @param cwd the {@link Path} to the current working directory.
270
   * @see com.devonfw.tools.ide.commandlet.InstallCommandlet
271
   */
272
  public void installIdeasy(Path cwd) {
273
    Path ideRoot = determineIdeRoot(cwd);
4✔
274
    // During a fresh (MSI) installation the IDE_ROOT environment variable is not yet available, so context.getIdeRoot() would return null for the whole run.
275
    // The installation target is already known here, so we make the context consistent for any downstream code reading context.getIdeRoot() (see #1517).
276
    this.context.setIdeRoot(ideRoot);
4✔
277
    Path idePath = ideRoot.resolve(IdeContext.FOLDER_UNDERSCORE_IDE);
4✔
278
    Path installationPath = idePath.resolve(IdeContext.FOLDER_INSTALLATION);
4✔
279
    Path ideasySoftwarePath = idePath.resolve(IdeContext.FOLDER_SOFTWARE).resolve(MvnRepository.ID).resolve(IdeasyCommandlet.TOOL_NAME)
8✔
280
        .resolve(IdeasyCommandlet.TOOL_NAME);
2✔
281
    Path ideasyVersionPath = ideasySoftwarePath.resolve(IdeVersion.getVersionString());
4✔
282
    FileAccess fileAccess = this.context.getFileAccess();
4✔
283
    if (Files.isDirectory(ideasyVersionPath)) {
5✔
284
      LOG.error("IDEasy is already installed at {} - if your installation is broken, delete it manually and rerun setup!", ideasyVersionPath);
5✔
285
    } else {
286
      List<Path> installationArtifacts = new ArrayList<>();
4✔
287
      for (Map.Entry<String, Boolean> artifactEntry : REQUIRED_INSTALLATION_ARTIFACTS.entrySet()) {
11✔
288
        String artifactName = artifactEntry.getKey();
4✔
289
        boolean required = artifactEntry.getValue();
5✔
290
        boolean success = addInstallationArtifact(cwd, artifactName, required, installationArtifacts);
7✔
291
        if (!success) {
2!
292
          throw new CliException("IDEasy release is inconsistent at %s [artifact=%s]".formatted(cwd, artifactName));
×
293
        }
294
      }
1✔
295

296
      fileAccess.mkdirs(ideasyVersionPath);
3✔
297
      for (Path installationArtifact : installationArtifacts) {
10✔
298
        fileAccess.copy(installationArtifact, ideasyVersionPath);
4✔
299
      }
1✔
300
      this.context.writeVersionFile(IdeVersion.getVersionIdentifier(), ideasyVersionPath);
5✔
301
    }
302
    fileAccess.symlink(ideasyVersionPath, installationPath);
4✔
303
    addToShellRc(BASHRC, ideRoot, null);
5✔
304
    addToShellRc(ZSHRC, ideRoot, "autoload -U +X bashcompinit && bashcompinit");
5✔
305
    installIdeasyWindowsEnv(ideRoot, installationPath);
4✔
306
    configurePowerShellProfiles(true);
3✔
307
    installDesktopShortcut(installationPath);
3✔
308
    IdeLogLevel.SUCCESS.log(LOG, "IDEasy has been installed successfully on your system.");
4✔
309
    LOG.warn("IDEasy has been setup for new shells but it cannot work in your current shell(s).\n"
3✔
310
        + "To use it here, reload your shell configuration (e.g. 'source ~/.bashrc' in bash "
311
        + "or '. $PROFILE.CurrentUserAllHosts' in PowerShell). Otherwise, open a new terminal or reboot.");
312
  }
1✔
313

314
  private void installIdeasyWindowsEnv(Path ideRoot, Path installationPath) {
315
    if (!this.context.getSystemInfo().isWindows()) {
5✔
316
      return;
1✔
317
    }
318
    WindowsHelper helper = WindowsHelper.get(this.context);
4✔
319
    helper.setUserEnvironmentValue(IdeVariables.IDE_ROOT.getName(), ideRoot.toString());
6✔
320
    String userPath = helper.getUserEnvironmentValue(IdeVariables.PATH.getName());
5✔
321
    if (userPath == null) {
2!
322
      LOG.error("Could not read user PATH from registry!");
×
323
    } else {
324
      LOG.info("Found user PATH={}", userPath);
4✔
325
      Path ideasyBinPath = installationPath.resolve("bin");
4✔
326
      SimpleSystemPath path = SimpleSystemPath.of(userPath, ';');
4✔
327
      if (path.getEntries().isEmpty()) {
4!
328
        LOG.warn("ATTENTION:\n"
×
329
            + "Your user specific PATH variable seems to be empty.\n"
330
            + "You can double check this by pressing [Windows][r] and launch the program SystemPropertiesAdvanced.\n"
331
            + "Then click on 'Environment variables' and check if 'PATH' is set in the 'user variables' from the upper list.\n"
332
            + "In case 'PATH' is defined there non-empty and you get this message, please abort and give us feedback:\n"
333
            + "https://github.com/devonfw/IDEasy/issues\n"
334
            + "Otherwise all is correct and you can continue.");
335
        this.context.askToContinue("Are you sure you want to override your PATH?");
×
336
      } else {
337
        path.removeEntries(s -> s.endsWith(IDE_INSTALLATION_BIN));
7✔
338
      }
339
      path.getEntries().add(ideasyBinPath.toString());
6✔
340
      helper.setUserEnvironmentValue(IdeVariables.PATH.getName(), path.toString());
6✔
341
      setGitLongpaths();
2✔
342
    }
343
  }
1✔
344
  private void installDesktopShortcut(Path installationPath) {
345

346
    try {
347
      if (this.context.getSystemInfo().isLinux()) {
5✔
348
        installLinuxDesktopShortcut(installationPath);
4✔
349
      } else if (this.context.getSystemInfo().isMac()) {
5✔
350
        installMacDesktopShortcut(installationPath);
4✔
351
      } else if (this.context.getSystemInfo().isWindows()) {
5!
352
        installWindowsDesktopShortcut(installationPath);
3✔
353
      }
354
    } catch (Exception e) {
×
355
      LOG.warn("Failed to create desktop shortcut.", e);
×
356
    }
1✔
357
  }
1✔
358

359
  private void installLinuxDesktopShortcut(Path installationPath) throws IOException {
360

361
    Path templateFile = installationPath.resolve("gui/linux/ideasy-gui.desktop");
4✔
362
    if (!Files.exists(templateFile)) {
5!
363
      LOG.warn("Desktop file template not found at {}. Skipping desktop shortcut creation.", templateFile);
×
364
      return;
×
365
    }
366
    Path ideasyBin = installationPath.resolve("bin/ideasy");
4✔
367
    Path logoPath = installationPath.resolve("gui/logo.png");
4✔
368
    String content = Files.readString(templateFile)
4✔
369
        .replace("@IDEASY_BIN@", ideasyBin.toString())
4✔
370
        .replace("@IDEASY_ICON@", logoPath.toString());
3✔
371

372
    Path applicationsDir = this.context.getUserHome().resolve(".local/share/applications");
6✔
373
    this.context.getFileAccess().mkdirs(applicationsDir);
5✔
374
    Path desktopFile = applicationsDir.resolve("ideasy-gui.desktop");
4✔
375
    this.context.getFileAccess().writeFileContent(content, desktopFile);
6✔
376
    this.context.getFileAccess().makeExecutable(desktopFile);
5✔
377

378
    // without this, the new entry is only visible in application menus after the next login
379
    try {
380
      this.context.newProcess().executable("update-desktop-database").addArg(applicationsDir.toString())
9✔
381
          .run(ProcessMode.DEFAULT_CAPTURE);
×
382
    } catch (Exception e) {
1✔
383
      LOG.debug("update-desktop-database failed (optional): {}", e.getMessage());
5✔
384
    }
×
385

386
    // Mark as trusted for GNOME 3.28+ so the shortcut is executable without prompting
387
    try {
388
      this.context.newProcess().executable("gio")
14✔
389
          .addArgs("set", desktopFile.toString(), "metadata::trusted", "true")
12✔
390
          .run(ProcessMode.DEFAULT_CAPTURE);
×
391
    } catch (Exception e) {
1✔
392
      LOG.debug("gio set trusted failed (optional): {}", e.getMessage());
5✔
393
    }
×
394

395
    IdeLogLevel.SUCCESS.log(LOG, "Created desktop shortcut at {}", desktopFile);
10✔
396
  }
1✔
397

398
  private void installMacDesktopShortcut(Path installationPath) {
399

400
    Path ideasyBin = installationPath.resolve("bin/ideasy");
4✔
401
    String escapedBin = ideasyBin.toString().replace("\"", "\\\"");
6✔
402
    String content = "#!/bin/bash\n\"" + escapedBin + "\" gui\n";
3✔
403
    Path applicationsDir = this.context.getUserHome().resolve("Applications");
6✔
404
    this.context.getFileAccess().mkdirs(applicationsDir);
5✔
405
    Path commandFile = applicationsDir.resolve("IDEasy.command");
4✔
406
    this.context.getFileAccess().writeFileContent(content, commandFile);
6✔
407
    this.context.getFileAccess().makeExecutable(commandFile);
5✔
408
    IdeLogLevel.SUCCESS.log(LOG, "Created macOS launcher at {}", commandFile);
10✔
409
  }
1✔
410

411
  private void installWindowsDesktopShortcut(Path installationPath) {
412

413
    Path ideasyExe = installationPath.resolve("bin\\ideasy.exe");
4✔
414
    Path icoPath = installationPath.resolve("gui\\logo.ico");
4✔
415
    // Shell Folders contains the already-expanded Desktop path, including OneDrive-redirected locations
416
    WindowsHelper helper = WindowsHelper.get(this.context);
4✔
417
    String desktopStr = helper.getRegistryValue(
5✔
418
        "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\Shell Folders", "Desktop");
419
    Path desktopPath = (desktopStr != null && !desktopStr.isBlank()) ? Path.of(desktopStr) : this.context.getUserHome().resolve("Desktop");
8!
420
    this.context.getFileAccess().mkdirs(desktopPath);
5✔
421
    createWindowsShortcut(desktopPath.resolve("IDEasy.lnk"), ideasyExe, icoPath);
7✔
422
    String startMenuStr = helper.getRegistryValue(
5✔
423
        "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\Shell Folders", "Programs");
424
    Path startMenu = (startMenuStr != null && !startMenuStr.isBlank()) ? Path.of(startMenuStr)
2!
425
        : this.context.getUserHome().resolve("AppData\\Roaming\\Microsoft\\Windows\\Start Menu\\Programs");
6✔
426
    if (Files.isDirectory(startMenu)) {
5!
427
      createWindowsShortcut(startMenu.resolve("IDEasy.lnk"), ideasyExe, icoPath);
×
428
    }
429
  }
1✔
430

431
  private static String psEscapePath(Path path) {
432
    return path.toString().replace("'", "''");
6✔
433
  }
434

435
  private void createWindowsShortcut(Path lnkPath, Path targetExe, Path icoPath) {
436

437
    String ps = "$ws = New-Object -ComObject WScript.Shell; "
1✔
438
        + "$s = $ws.CreateShortcut('" + psEscapePath(lnkPath) + "'); "
2✔
439
        + "$s.TargetPath = '" + psEscapePath(targetExe) + "'; "
2✔
440
        + "$s.Arguments = 'gui'; "
441
        + "$s.IconLocation = '" + psEscapePath(icoPath) + ",0'; "
3✔
442
        + "$s.Save()";
443
    try {
444
      this.context.newProcess().executable("powershell").addArgs("-Command", ps)
17✔
445
          .run(ProcessMode.DEFAULT_CAPTURE);
×
446
      IdeLogLevel.SUCCESS.log(LOG, "Created shortcut at {}", lnkPath);
×
447
    } catch (Exception e) {
1✔
448
      LOG.warn("Failed to create shortcut at {}.", lnkPath, e);
5✔
449
    }
×
450
  }
1✔
451

452
  private void configurePowerShellProfiles(boolean install) {
453

454
    if (!this.context.getSystemInfo().isWindows()) {
5✔
455
      return;
1✔
456
    }
457

458
    // Windows PowerShell 5.x and PowerShell 7+ have different profile locations.
459
    modifyPowerShellProfile("powershell", install);
4✔
460
    modifyPowerShellProfile("pwsh", install);
4✔
461
  }
1✔
462

463
  private void modifyPowerShellProfile(String executable, boolean install) {
464

465
    Path profilePath = getPowerShellProfilePath(executable);
4✔
466
    if (profilePath == null) {
2✔
467
      return;
1✔
468
    }
469

470
    logIdeasyModification(profilePath.toString(), install);
5✔
471

472
    FileAccess fileAccess = this.context.getFileAccess();
4✔
473
    List<String> lines = fileAccess.readFileLines(profilePath);
4✔
474

475
    if (lines == null && !install) {
4!
476
      return;
×
477
    }
478

479
    List<String> modifiedLines = modifyPowerShellProfileLines(lines, install);
5✔
480

481
    Path parent = profilePath.getParent();
3✔
482
    if (parent != null) {
2!
483
      fileAccess.mkdirs(parent);
3✔
484
    }
485

486
    fileAccess.writeFileLines(modifiedLines, profilePath);
4✔
487
    LOG.debug("Successfully updated PowerShell profile {}", profilePath);
4✔
488
  }
1✔
489

490
  List<String> modifyPowerShellProfileLines(List<String> lines, boolean install) {
491

492
    List<String> modifiedLines;
493

494
    if (lines == null) {
2✔
495
      modifiedLines = new ArrayList<>();
5✔
496
    } else {
497
      modifiedLines = new ArrayList<>(lines);
5✔
498
    }
499

500
    boolean configured = modifiedLines.stream()
3✔
501
        .map(String::trim)
3✔
502
        .anyMatch(POWERSHELL_CODE_SOURCE_FUNCTIONS::equals);
2✔
503

504
    if (install) {
2✔
505
      if (!configured) {
2✔
506
        modifiedLines.add(POWERSHELL_CODE_SOURCE_FUNCTIONS);
5✔
507
      }
508
    } else {
509
      modifiedLines.removeIf(
4✔
510
          line -> line.trim().equals(POWERSHELL_CODE_SOURCE_FUNCTIONS));
5✔
511
    }
512

513
    return modifiedLines;
2✔
514
  }
515

516
  private void logIdeasyModification(String target, boolean configure) {
517
    String action = configure ? "Configuring" : "Removing";
6✔
518
    LOG.info("{} IDEasy in {}", action, target);
5✔
519
  }
1✔
520

521
  private Path getPowerShellProfilePath(String executable) {
522

523
    try {
524
      ProcessResult result = this.context.newProcess()
4✔
525
          .executable(executable)
15✔
526
          .addArgs("-NoProfile", "-Command", "$PROFILE.CurrentUserAllHosts")
2✔
527
          .run(ProcessMode.DEFAULT_CAPTURE);
2✔
528

529
      if (!result.isSuccessful()) {
3!
530
        LOG.debug("{} is not available or its profile could not be determined.", executable);
×
531
        return null;
×
532
      }
533

534
      List<String> output = result.getOut();
3✔
535
      if (output == null || output.isEmpty()) {
5!
536
        LOG.debug("{} returned no PowerShell profile path.", executable);
×
537
        return null;
×
538
      }
539

540
      String profilePath = output.stream()
3✔
541
          .map(String::strip)
2✔
542
          .filter(line -> !line.isEmpty())
7!
543
          .findFirst()
2✔
544
          .orElse(null);
3✔
545

546
      if (profilePath == null) {
2!
547
        LOG.debug("{} returned no PowerShell profile path.", executable);
×
548
        return null;
×
549
      }
550

551
      return Path.of(profilePath);
5✔
552

553
    } catch (Exception e) {
1✔
554
      // pwsh is optional. Windows PowerShell normally exists on supported Windows versions.
555
      LOG.debug("Could not determine profile for {}: {}", executable, e.getMessage());
6✔
556
      return null;
2✔
557
    }
558
  }
559

560
  private void setGitLongpaths() {
561
    this.context.getGitContext().findGitRequired();
5✔
562
    Path configPath = this.context.getUserHome().resolve(".gitconfig");
6✔
563
    FileAccess fileAccess = this.context.getFileAccess();
4✔
564
    IniFile iniFile = fileAccess.readIniFile(configPath);
4✔
565
    IniSection coreSection = iniFile.getOrCreateSection("core");
4✔
566
    coreSection.setProperty("longpaths", "true");
4✔
567
    fileAccess.writeIniFile(iniFile, configPath);
4✔
568
  }
1✔
569

570
  /**
571
   * Sets up Windows Terminal with Git Bash integration.
572
   */
573
  public void setupWindowsTerminal() {
574
    if (!this.context.getSystemInfo().isWindows()) {
×
575
      return;
×
576
    }
577
    if (!isWindowsTerminalInstalled()) {
×
578
      try {
579
        installWindowsTerminal();
×
580
      } catch (Exception e) {
×
581
        LOG.error("Failed to install Windows Terminal!", e);
×
582
      }
×
583
    }
584
    configureWindowsTerminalGitBash();
×
585
  }
×
586

587
  /**
588
   * Checks if Windows Terminal is installed.
589
   *
590
   * @return {@code true} if Windows Terminal is installed, {@code false} otherwise.
591
   */
592
  private boolean isWindowsTerminalInstalled() {
593
    try {
594
      ProcessResult result = this.context.newProcess()
×
595
          .executable("powershell")
×
596
          .addArgs("-Command", "Get-AppxPackage -Name Microsoft.WindowsTerminal")
×
597
          .run(ProcessMode.DEFAULT_CAPTURE);
×
598
      return result.isSuccessful() && !result.getOut().isEmpty();
×
599
    } catch (Exception e) {
×
600
      LOG.debug("Failed to check Windows Terminal installation.", e);
×
601
      return false;
×
602
    }
603
  }
604

605
  /**
606
   * Installs Windows Terminal using winget.
607
   */
608
  private void installWindowsTerminal() {
609
    try {
610
      LOG.info("Installing Windows Terminal...");
×
611
      ProcessResult result = this.context.newProcess()
×
612
          .executable("winget")
×
613
          .addArgs("install", "Microsoft.WindowsTerminal")
×
614
          .run(ProcessMode.DEFAULT);
×
615
      if (result.isSuccessful()) {
×
616
        IdeLogLevel.SUCCESS.log(LOG, "Windows Terminal has been installed successfully.");
×
617
      } else {
618
        LOG.warn("Failed to install Windows Terminal. Please install it manually from Microsoft Store.");
×
619
      }
620
    } catch (Exception e) {
×
621
      LOG.warn("Failed to install Windows Terminal: {}. Please install it manually from Microsoft Store.", e.toString());
×
622
    }
×
623
  }
×
624

625
  /**
626
   * Configures Git Bash integration in Windows Terminal.
627
   */
628
  protected void configureWindowsTerminalGitBash() {
629
    Path settingsPath = getWindowsTerminalSettingsPath();
3✔
630
    if (settingsPath == null || !Files.exists(settingsPath)) {
7!
631
      LOG.warn("Windows Terminal settings file not found. Cannot configure Git Bash integration.");
×
632
      return;
×
633
    }
634

635
    try {
636
      Path bashPath = this.context.findBash();
4✔
637
      if (bashPath == null) {
2!
638
        LOG.warn("Git Bash not found. Cannot configure Windows Terminal integration.");
×
639
        return;
×
640
      }
641

642
      configureGitBashProfile(settingsPath, bashPath.toString());
5✔
643
      IdeLogLevel.SUCCESS.log(LOG, "Git Bash has been configured in Windows Terminal.");
4✔
644
    } catch (Exception e) {
×
645
      LOG.warn("Failed to configure Git Bash in Windows Terminal: {}", e.getMessage());
×
646
    }
1✔
647
  }
1✔
648

649
  /**
650
   * Gets the Windows Terminal settings file path.
651
   *
652
   * @return the {@link Path} to the Windows Terminal settings file, or {@code null} if not found.
653
   */
654
  protected Path getWindowsTerminalSettingsPath() {
655
    Path localAppData = this.context.getUserHome().resolve("AppData").resolve("Local");
8✔
656
    Path packagesPath = localAppData.resolve("Packages");
4✔
657

658
    // Try the new Windows Terminal package first
659
    Path newTerminalPath = packagesPath.resolve("Microsoft.WindowsTerminal_8wekyb3d8bbwe")
4✔
660
        .resolve("LocalState").resolve("settings.json");
4✔
661
    if (Files.exists(newTerminalPath)) {
5!
662
      return newTerminalPath;
2✔
663
    }
664

665
    // Try the old Windows Terminal Preview package
666
    Path previewPath = packagesPath.resolve("Microsoft.WindowsTerminalPreview_8wekyb3d8bbwe")
×
667
        .resolve("LocalState").resolve("settings.json");
×
668
    if (Files.exists(previewPath)) {
×
669
      return previewPath;
×
670
    }
671

672
    return null;
×
673
  }
674

675
  /**
676
   * Configures Git Bash profile in Windows Terminal settings.
677
   *
678
   * @param settingsPath the {@link Path} to the Windows Terminal settings file.
679
   * @param bashPath the path to the Git Bash executable.
680
   */
681
  private void configureGitBashProfile(Path settingsPath, String bashPath) throws Exception {
682

683
    ObjectMapper mapper = new ObjectMapper();
4✔
684
    ObjectNode root;
685
    try (Reader reader = Files.newBufferedReader(settingsPath)) {
3✔
686
      JsonNode rootNode = mapper.readTree(reader);
4✔
687
      root = (ObjectNode) rootNode;
3✔
688
    }
689

690
    // Get or create profiles object
691
    ObjectNode profiles = (ObjectNode) root.get("profiles");
5✔
692
    if (profiles == null) {
2!
693
      profiles = mapper.createObjectNode();
×
694
      root.set("profiles", profiles);
×
695
    }
696

697
    // Get or create list array of profiles object
698
    JsonNode profilesList = profiles.get("list");
4✔
699
    if (profilesList == null || !profilesList.isArray()) {
5!
700
      profilesList = mapper.createArrayNode();
×
701
      profiles.set("list", profilesList);
×
702
    }
703

704
    // Check if Git Bash profile already exists
705
    boolean gitBashProfileExists = false;
2✔
706
    for (JsonNode profile : profilesList) {
10✔
707
      if (profile.has("name") && profile.get("name").asText().equals("Git Bash")) {
11!
708
        gitBashProfileExists = true;
×
709
        break;
×
710
      }
711
    }
1✔
712

713
    // Add Git Bash profile if it doesn't exist
714
    if (gitBashProfileExists) {
2!
715
      LOG.info("Git Bash profile already exists in {}.", settingsPath);
×
716
    } else {
717
      ObjectNode gitBashProfile = mapper.createObjectNode();
3✔
718
      String newGuid = "{2ece5bfe-50ed-5f3a-ab87-5cd4baafed2b}";
2✔
719
      String iconPath = getGitBashIconPath(bashPath);
4✔
720
      Path startingDirectory = Objects.requireNonNullElse(this.context.getIdeRoot(), this.context.getUserHome());
9✔
721

722
      gitBashProfile.put("guid", newGuid);
5✔
723
      gitBashProfile.put("name", "Git Bash");
5✔
724
      gitBashProfile.put("commandline", bashPath);
5✔
725
      gitBashProfile.put("icon", iconPath);
5✔
726
      gitBashProfile.put("startingDirectory", startingDirectory.toString());
6✔
727

728
      ((ArrayNode) profilesList).add(gitBashProfile);
5✔
729

730
      // Set Git Bash as default profile
731
      root.put("defaultProfile", newGuid);
5✔
732

733
      // Write back to file
734
      try (Writer writer = Files.newBufferedWriter(settingsPath)) {
5✔
735
        mapper.writerWithDefaultPrettyPrinter().writeValue(writer, root);
5✔
736
      }
737
    }
738
  }
1✔
739

740
  private String getGitBashIconPath(String bashPathString) {
741
    Path bashPath = Path.of(bashPathString);
5✔
742
    // "C:\\Program Files\\Git\\bin\\bash.exe"
743
    // "C:\\Program Files\\Git\\mingw64\\share\\git\\git-for-windows.ico"
744
    Path parent = bashPath.getParent();
3✔
745
    if (parent != null) {
2!
746
      Path iconPath = parent.resolve("mingw64/share/git/git-for-windows.ico");
4✔
747
      if (Files.exists(iconPath)) {
5!
748
        LOG.debug("Found git-bash icon at {}", iconPath);
×
749
        return iconPath.toString();
×
750
      }
751
      LOG.debug("Git Bash icon not found at {}. Using default icon.", iconPath);
4✔
752
    }
753
    return "ms-appx:///ProfileIcons/{0caa0dad-35be-5f56-a8ff-afceeeaa6101}.png";
2✔
754
  }
755

756
  static String removeObsoleteEntryFromWindowsPath(String userPath) {
757
    return removeEntryFromWindowsPath(userPath, IDE_BIN);
4✔
758
  }
759

760
  static String removeEntryFromWindowsPath(String userPath, String suffix) {
761
    int len = userPath.length();
3✔
762
    int start = 0;
2✔
763
    while ((start >= 0) && (start < len)) {
5!
764
      int end = userPath.indexOf(';', start);
5✔
765
      if (end < 0) {
2✔
766
        end = len;
2✔
767
      }
768
      String entry = userPath.substring(start, end);
5✔
769
      if (entry.endsWith(suffix)) {
4✔
770
        String prefix = "";
2✔
771
        int offset = 1;
2✔
772
        if (start > 0) {
2✔
773
          prefix = userPath.substring(0, start - 1);
7✔
774
          offset = 0;
2✔
775
        }
776
        if (end == len) {
3✔
777
          return prefix;
2✔
778
        } else {
779
          return removeEntryFromWindowsPath(prefix + userPath.substring(end + offset), suffix);
10✔
780
        }
781
      }
782
      start = end + 1;
4✔
783
    }
1✔
784
    return userPath;
2✔
785
  }
786

787
  /**
788
   * Adds ourselves to the shell RC (run-commands) configuration file.
789
   *
790
   * @param filename the name of the RC file.
791
   * @param ideRoot the IDE_ROOT {@link Path}.
792
   */
793
  private void addToShellRc(String filename, Path ideRoot, String extraLine) {
794

795
    modifyShellRc(filename, ideRoot, true, extraLine);
6✔
796
  }
1✔
797

798
  private void removeFromShellRc(String filename, Path ideRoot) {
799

800
    modifyShellRc(filename, ideRoot, false, null);
6✔
801
  }
1✔
802

803
  /**
804
   * Adds ourselves to the shell RC (run-commands) configuration file.
805
   *
806
   * @param filename the name of the RC file.
807
   * @param ideRoot the IDE_ROOT {@link Path}.
808
   */
809
  private void modifyShellRc(String filename, Path ideRoot, boolean add, String extraLine) {
810

811
    logIdeasyModification(filename, add);
4✔
812

813
    Path rcFile = this.context.getUserHome().resolve(filename);
6✔
814
    FileAccess fileAccess = this.context.getFileAccess();
4✔
815
    List<String> lines = fileAccess.readFileLines(rcFile);
4✔
816
    if (lines == null) {
2✔
817
      if (!add) {
2!
818
        return;
×
819
      }
820
      lines = new ArrayList<>();
5✔
821
    } else {
822
      // since it is unspecified if the returned List may be immutable we want to get sure
823
      lines = new ArrayList<>(lines);
5✔
824
    }
825
    Iterator<String> iterator = lines.iterator();
3✔
826
    int removeCount = 0;
2✔
827
    while (iterator.hasNext()) {
3✔
828
      String line = iterator.next();
4✔
829
      line = line.trim();
3✔
830
      if (isObsoleteRcLine(line)) {
3✔
831
        LOG.info("Removing obsolete line from {}: {}", filename, line);
5✔
832
        iterator.remove();
2✔
833
        removeCount++;
2✔
834
      } else if (line.equals(extraLine)) {
4✔
835
        extraLine = null;
2✔
836
      }
837
    }
1✔
838
    if (add) {
2✔
839
      if (extraLine != null) {
2!
840
        lines.add(extraLine);
×
841
      }
842
      if (!this.context.getSystemInfo().isWindows()) {
5✔
843
        lines.add("export IDE_ROOT=\"" + WindowsPathSyntax.MSYS.format(ideRoot) + "\"");
7✔
844
      }
845
      lines.add(BASH_CODE_SOURCE_FUNCTIONS);
4✔
846
    }
847
    fileAccess.writeFileLines(lines, rcFile);
4✔
848
    LOG.debug("Successfully updated {}", filename);
4✔
849
  }
1✔
850

851
  private static boolean isObsoleteRcLine(String line) {
852
    if (line.startsWith("alias ide=")) {
4!
853
      return true;
×
854
    } else if (line.startsWith("export IDE_ROOT=")) {
4!
855
      return true;
×
856
    } else if (line.equals("ide")) {
4✔
857
      return true;
2✔
858
    } else if (line.equals("ide init")) {
4✔
859
      return true;
2✔
860
    } else if (line.startsWith("source \"$IDE_ROOT/_ide/")) {
4✔
861
      return true;
2✔
862
    }
863
    return false;
2✔
864
  }
865

866
  private boolean addInstallationArtifact(Path cwd, String artifactName, boolean required, List<Path> installationArtifacts) {
867

868
    Path artifactPath = cwd.resolve(artifactName);
4✔
869
    if (Files.exists(artifactPath)) {
5!
870
      installationArtifacts.add(artifactPath);
5✔
871
    } else if (required) {
×
872
      LOG.error("Missing required file {}", artifactName);
×
873
      return false;
×
874
    }
875
    return true;
2✔
876
  }
877

878
  private Path determineIdeRoot(Path cwd) {
879
    Path ideRoot = this.context.getIdeRoot();
4✔
880
    if (ideRoot == null) {
2✔
881
      Path home = this.context.getUserHome();
4✔
882
      Path installRoot = home;
2✔
883
      if (this.context.getSystemInfo().isWindows()) {
5✔
884
        if (!cwd.startsWith(home)) {
4!
885
          installRoot = cwd.getRoot();
×
886
        }
887
      }
888
      ideRoot = installRoot.resolve(IdeContext.FOLDER_PROJECTS);
4✔
889
    } else {
1✔
890
      assert (Files.isDirectory(ideRoot)) : "IDE_ROOT directory does not exist!";
6!
891
    }
892
    return ideRoot;
2✔
893
  }
894

895
  /**
896
   * Uninstalls IDEasy entirely from the system.
897
   */
898
  public void uninstallIdeasy() {
899

900
    Path ideRoot = this.context.getIdeRoot();
4✔
901
    removeFromShellRc(BASHRC, ideRoot);
4✔
902
    removeFromShellRc(ZSHRC, ideRoot);
4✔
903
    Path idePath = this.context.getIdePath();
4✔
904
    uninstallIdeasyWindowsEnv(ideRoot);
3✔
905
    configurePowerShellProfiles(false);
3✔
906
    uninstallIdeasyIdePath(idePath);
3✔
907
    deleteDownloadCache();
2✔
908
    IdeLogLevel.SUCCESS.log(LOG, "IDEasy has been uninstalled from your system.");
4✔
909
    IdeLogLevel.INTERACTION.log(LOG, "ATTENTION:\n"
10✔
910
        + "In order to prevent data-loss, we do not delete your projects and git repositories!\n"
911
        + "To entirely get rid of IDEasy, also check your IDE_ROOT folder at:\n"
912
        + "{}", ideRoot);
913
  }
1✔
914

915
  private void deleteDownloadCache() {
916
    Path downloadPath = this.context.getDownloadPath();
4✔
917
    LOG.info("Deleting download cache from {}", downloadPath);
4✔
918
    tryDeleteDownloadCache(downloadPath);
3✔
919
    // older versions kept the cache under ~/Downloads/ide - clean that up too if it's still around
920
    Path legacy = this.context.getUserHome().resolve("Downloads/ide");
6✔
921
    if (!legacy.equals(downloadPath) && Files.exists(legacy)) {
4!
922
      LOG.info("Deleting legacy download cache from {}", legacy);
×
923
      tryDeleteDownloadCache(legacy);
×
924
    }
925
  }
1✔
926

927
  private void tryDeleteDownloadCache(Path path) {
928
    try {
929
      this.context.getFileAccess().delete(path);
5✔
930
    } catch (IllegalStateException e) {
×
931
      // best effort - on macOS ~/Downloads can deny access (EPERM), don't fail the whole uninstallation over it
932
      String cause = (e.getCause() != null) ? e.getCause().getMessage() : e.getMessage();
×
933
      LOG.warn("Could not delete download cache at {} ({}). The folder will be left in place; you can remove it manually via Finder.", path, cause);
×
934
    }
1✔
935
  }
1✔
936

937
  private void uninstallIdeasyIdePath(Path idePath) {
938
    if (this.context.getSystemInfo().isWindows()) {
5✔
939
      Path bash = this.context.findBash();
4✔
940
      if (bash == null) {
2!
941
        LOG.warn("Could not find bash for asynchronous deletion of {}. Falling back to direct deletion.", idePath);
×
942
        this.context.getFileAccess().delete(idePath);
×
943
        return;
×
944
      }
945
      this.context.newProcess().executable(bash).addArgs("-c",
17✔
946
          "sleep 10 && rm -rf \"" + WindowsPathSyntax.MSYS.format(idePath) + "\"").run(ProcessMode.BACKGROUND);
5✔
947
      IdeLogLevel.INTERACTION.log(LOG,
10✔
948
          "To prevent windows file locking errors, we perform an asynchronous deletion of {} in background now.\n"
949
              + "Please close all terminals and wait a minute for the deletion to complete before running other commands.", idePath);
950
    } else {
1✔
951
      LOG.info("Finally deleting {}", idePath);
4✔
952
      this.context.getFileAccess().delete(idePath);
5✔
953
    }
954
  }
1✔
955

956
  private void uninstallIdeasyWindowsEnv(Path ideRoot) {
957
    if (!this.context.getSystemInfo().isWindows()) {
5✔
958
      return;
1✔
959
    }
960
    WindowsHelper helper = WindowsHelper.get(this.context);
4✔
961
    helper.removeUserEnvironmentValue(IdeVariables.IDE_ROOT.getName());
4✔
962
    String userPath = helper.getUserEnvironmentValue(IdeVariables.PATH.getName());
5✔
963
    if (userPath == null) {
2!
964
      LOG.error("Could not read user PATH from registry!");
×
965
    } else {
966
      LOG.info("Found user PATH={}", userPath);
4✔
967
      String newUserPath = userPath;
2✔
968
      if (!userPath.isEmpty()) {
3!
969
        SimpleSystemPath path = SimpleSystemPath.of(userPath, ';');
4✔
970
        path.removeEntries(s -> s.endsWith(IDE_BIN) || s.endsWith(IDE_INSTALLATION_BIN));
15!
971
        newUserPath = path.toString();
3✔
972
      }
973
      if (newUserPath.equals(userPath)) {
4!
974
        LOG.error("Could not find IDEasy in PATH:\n{}", userPath);
×
975
      } else {
976
        helper.setUserEnvironmentValue(IdeVariables.PATH.getName(), newUserPath);
5✔
977
      }
978
    }
979
  }
1✔
980
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc