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

devonfw / IDEasy / 30622595516

31 Jul 2026 10:10AM UTC coverage: 72.527% (-0.1%) from 72.641%
30622595516

Pull #2239

github

web-flow
Merge b76ccb249 into 468118d46
Pull Request #2239: #218: add possibility to start a command in a new window with the process builder

5048 of 7697 branches covered (65.58%)

Branch coverage included in aggregate %.

13123 of 17357 relevant lines covered (75.61%)

3.21 hits per line

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

68.85
cli/src/main/java/com/devonfw/tools/ide/process/ProcessContextImpl.java
1
package com.devonfw.tools.ide.process;
2

3
import java.io.BufferedReader;
4
import java.io.IOException;
5
import java.io.InputStream;
6
import java.io.InputStreamReader;
7
import java.lang.ProcessBuilder.Redirect;
8
import java.nio.charset.StandardCharsets;
9
import java.nio.file.Files;
10
import java.nio.file.Path;
11
import java.util.ArrayList;
12
import java.util.List;
13
import java.util.Map;
14
import java.util.Objects;
15
import java.util.concurrent.CompletableFuture;
16
import java.util.concurrent.ConcurrentLinkedQueue;
17
import java.util.function.Predicate;
18
import java.util.stream.Collectors;
19

20
import org.slf4j.Logger;
21
import org.slf4j.LoggerFactory;
22

23
import com.devonfw.tools.ide.cli.CliProcessException;
24
import com.devonfw.tools.ide.common.SystemPath;
25
import com.devonfw.tools.ide.context.IdeContext;
26
import com.devonfw.tools.ide.environment.VariableLine;
27
import com.devonfw.tools.ide.log.IdeLogLevel;
28
import com.devonfw.tools.ide.util.FilenameUtil;
29
import com.devonfw.tools.ide.variable.IdeVariables;
30

31
/**
32
 * Implementation of {@link ProcessContext}.
33
 */
34
public class ProcessContextImpl implements ProcessContext {
35

36
  private static final Logger LOG = LoggerFactory.getLogger(ProcessContextImpl.class);
3✔
37

38
  private static final String PREFIX_USR_BIN_ENV = "/usr/bin/env ";
39
  private static final Predicate<Integer> EXIT_CODE_ACCEPTOR = rc -> rc == ProcessResult.SUCCESS;
10✔
40

41
  /** The owning {@link IdeContext}. */
42
  protected final IdeContext context;
43

44
  private final ProcessBuilder processBuilder;
45

46
  protected final List<String> arguments;
47

48
  protected Path executable;
49

50
  private String overriddenPath;
51

52
  private final List<Path> extraPathEntries;
53

54
  private ProcessErrorHandling errorHandling;
55

56
  private OutputListener outputListener;
57

58
  private Predicate<Integer> exitCodeAcceptor;
59

60
  /**
61
   * The constructor.
62
   *
63
   * @param context the owning {@link IdeContext}.
64
   */
65
  public ProcessContextImpl(IdeContext context) {
66

67
    super();
2✔
68
    this.context = context;
3✔
69
    this.processBuilder = new ProcessBuilder();
7✔
70
    this.errorHandling = ProcessErrorHandling.THROW_ERR;
3✔
71
    Map<String, String> environment = this.processBuilder.environment();
4✔
72
    for (VariableLine var : this.context.getVariables().collectExportedVariables()) {
13✔
73
      if (var.isExport()) {
3!
74
        environment.put(var.getName(), var.getValue());
7✔
75
      }
76
    }
1✔
77
    this.arguments = new ArrayList<>();
5✔
78
    this.extraPathEntries = new ArrayList<>();
5✔
79
    this.exitCodeAcceptor = EXIT_CODE_ACCEPTOR;
3✔
80
  }
1✔
81

82
  private ProcessContextImpl(ProcessContextImpl parent) {
83

84
    super();
×
85
    this.context = parent.context;
×
86
    this.processBuilder = parent.processBuilder;
×
87
    this.errorHandling = ProcessErrorHandling.THROW_ERR;
×
88
    this.arguments = new ArrayList<>();
×
89
    this.extraPathEntries = parent.extraPathEntries;
×
90
    this.exitCodeAcceptor = EXIT_CODE_ACCEPTOR;
×
91
  }
×
92

93
  @Override
94
  public ProcessContext errorHandling(ProcessErrorHandling handling) {
95

96
    Objects.requireNonNull(handling);
3✔
97
    this.errorHandling = handling;
3✔
98
    return this;
2✔
99
  }
100

101
  @Override
102
  public ProcessContext directory(Path directory) {
103

104
    if (directory != null) {
2!
105
      this.processBuilder.directory(directory.toFile());
7✔
106
    } else {
107
      LOG.debug(
×
108
          "Could not set the process builder's working directory! Directory of the current java process is used.");
109
    }
110

111
    return this;
2✔
112
  }
113

114
  @Override
115
  public ProcessContext executable(Path command) {
116

117
    if (!this.arguments.isEmpty()) {
4!
118
      throw new IllegalStateException("Arguments already present - did you forget to call run for previous call?");
×
119
    }
120

121
    this.executable = command;
3✔
122
    return this;
2✔
123
  }
124

125
  @Override
126
  public ProcessContext addArg(String arg) {
127

128
    this.arguments.add(arg);
5✔
129
    return this;
2✔
130
  }
131

132
  @Override
133
  public ProcessContext withEnvVar(String key, String value) {
134

135
    if (IdeVariables.PATH.getName().equals(key)) {
5!
136
      this.overriddenPath = value;
×
137
    } else {
138
      LOG.trace("Setting process environment variable {}={}", key, value);
5✔
139
      this.processBuilder.environment().put(key, value);
7✔
140
    }
141
    return this;
2✔
142
  }
143

144
  @Override
145
  public EnvironmentContext removeEnvVar(String key) {
146

147
    LOG.trace("Removing process environment variable {}", key);
4✔
148
    this.processBuilder.environment().remove(key);
6✔
149
    return this;
2✔
150
  }
151

152
  @Override
153
  public ProcessContext withPathEntry(Path path) {
154

155
    this.extraPathEntries.add(path);
5✔
156
    return this;
2✔
157
  }
158

159
  @Override
160
  public ProcessContext withExitCodeAcceptor(Predicate<Integer> exitCodeAcceptor) {
161

162
    this.exitCodeAcceptor = exitCodeAcceptor;
3✔
163
    return this;
2✔
164
  }
165

166
  @Override
167
  public ProcessContext createChild() {
168

169
    return new ProcessContextImpl(this);
×
170
  }
171

172
  @Override
173
  public void setOutputListener(OutputListener listener) {
174
    this.outputListener = listener;
×
175
  }
×
176

177
  @Override
178
  public ProcessResult run(ProcessMode processMode) {
179

180
    if (this.executable == null) {
3✔
181
      throw new IllegalStateException("Missing executable to run process!");
5✔
182
    }
183

184
    SystemPath systemPath = this.context.getPath();
4✔
185
    if ((this.overriddenPath != null) || !this.extraPathEntries.isEmpty()) {
7!
186
      systemPath = systemPath.withPath(this.overriddenPath, this.extraPathEntries);
7✔
187
    }
188
    String path = systemPath.toString();
3✔
189
    LOG.trace("Setting PATH for process execution of {} to {}", this.executable.getFileName(), path);
7✔
190
    this.executable = systemPath.findBinary(this.executable);
6✔
191
    this.processBuilder.environment().put(IdeVariables.PATH.getName(), path);
8✔
192
    List<String> args = new ArrayList<>(this.arguments.size() + 4);
9✔
193
    String interpreter = addExecutable(args);
4✔
194
    args.addAll(this.arguments);
5✔
195
    String command = createCommand();
3✔
196
    if (LOG.isDebugEnabled()) {
3!
197
      String message = createCommandMessage(interpreter, " ...");
5✔
198
      LOG.debug(message);
3✔
199
    }
200

201
    try {
202
      applyRedirects(processMode);
3✔
203
      if (processMode.isBackground()) {
3✔
204
        modifyArgumentsOnBackgroundProcess(processMode, args);
4✔
205
      }
206

207
      this.processBuilder.command(args);
5✔
208

209
      ConcurrentLinkedQueue<OutputMessage> output = new ConcurrentLinkedQueue<>();
4✔
210

211
      Process process = this.processBuilder.start();
4✔
212

213
      try {
214
        if (Redirect.PIPE == processMode.getRedirectOutput() || Redirect.PIPE == processMode.getRedirectError()) {
8!
215
          CompletableFuture<Void> outFut = readInputStream(process.getInputStream(), false, output);
6✔
216
          CompletableFuture<Void> errFut = readInputStream(process.getErrorStream(), true, output);
6✔
217
          if (Redirect.PIPE == processMode.getRedirectOutput()) {
4!
218
            outFut.get();
3✔
219
          }
220
          if (Redirect.PIPE == processMode.getRedirectError()) {
4!
221
            errFut.get();
3✔
222
          }
223
          if (this.outputListener != null) {
3!
224
            for (OutputMessage msg : output) {
×
225
              this.outputListener.onOutput(msg.message(), msg.error());
×
226
            }
×
227
          }
228
        }
229

230
        int exitCode;
231

232
        if (processMode.isBackground()) {
3✔
233
          exitCode = ProcessResult.SUCCESS;
3✔
234
        } else {
235
          exitCode = process.waitFor();
3✔
236
        }
237

238
        List<OutputMessage> finalOutput = new ArrayList<>(output);
5✔
239
        boolean success = this.exitCodeAcceptor.test(exitCode);
6✔
240
        ProcessResult result =
4✔
241
            new ProcessResultImpl(this.executable.getFileName().toString(), command, exitCode, success, finalOutput);
8✔
242

243
        performLogging(result, exitCode, interpreter);
5✔
244

245
        return result;
4✔
246
      } finally {
247
        if (!processMode.isBackground()) {
3✔
248
          process.destroy();
2✔
249
        }
250
      }
251
    } catch (CliProcessException | IllegalStateException e) {
1✔
252
      // these exceptions are thrown from performLogOnError and we do not want to wrap them (see #593)
253
      throw e;
2✔
254
    } catch (Exception e) {
1✔
255
      String msg = e.getMessage();
3✔
256
      if ((msg == null) || msg.isEmpty()) {
5!
257
        msg = e.getClass().getSimpleName();
×
258
      }
259
      throw new IllegalStateException(createCommandMessage(interpreter, " failed: " + msg), e);
10✔
260
    } finally {
261
      this.arguments.clear();
3✔
262
    }
263
  }
264

265
  /**
266
   * Asynchronously and parallel reads {@link InputStream input stream} and stores it in {@link CompletableFuture}. Inspired by: <a href=
267
   * "https://stackoverflow.com/questions/14165517/processbuilder-forwarding-stdout-and-stderr-of-started-processes-without-blocki/57483714#57483714">StackOverflow</a>
268
   *
269
   * @param is {@link InputStream}.
270
   * @param errorStream to identify if the output came from stdout or stderr
271
   * @param outputMessages the queue storing output messages
272
   * @return {@link CompletableFuture}.
273
   */
274
  private static CompletableFuture<Void> readInputStream(InputStream is, boolean errorStream,
275
      ConcurrentLinkedQueue<OutputMessage> outputMessages) {
276

277
    return CompletableFuture.supplyAsync(() -> {
6✔
278

279
      try (InputStreamReader isr = new InputStreamReader(is); BufferedReader br = new BufferedReader(isr)) {
10✔
280

281
        String line;
282
        while ((line = br.readLine()) != null) {
5✔
283
          OutputMessage outputMessage = new OutputMessage(errorStream, line);
6✔
284
          outputMessages.add(outputMessage);
4✔
285
        }
1✔
286

287
        return null;
4✔
288
      } catch (Throwable e) {
1✔
289
        throw new RuntimeException("There was a problem while executing the program", e);
6✔
290
      }
291
    });
292
  }
293

294
  private String createCommand() {
295
    String cmd = this.executable.toString();
4✔
296
    StringBuilder sb = new StringBuilder(cmd.length() + this.arguments.size() * 4);
12✔
297
    sb.append(cmd);
4✔
298
    for (String arg : this.arguments) {
11✔
299
      sb.append(' ');
4✔
300
      sb.append(arg);
4✔
301
    }
1✔
302
    return sb.toString();
3✔
303
  }
304

305
  private String createCommandMessage(String interpreter, String suffix) {
306

307
    StringBuilder sb = new StringBuilder();
4✔
308
    sb.append("Running command '");
4✔
309
    sb.append(this.executable);
5✔
310
    sb.append("'");
4✔
311
    if (interpreter != null) {
2✔
312
      sb.append(" using ");
4✔
313
      sb.append(interpreter);
4✔
314
    }
315
    int size = this.arguments.size();
4✔
316
    if (size > 0) {
2✔
317
      sb.append(" with arguments");
4✔
318
      for (int i = 0; i < size; i++) {
7✔
319
        String arg = this.arguments.get(i);
6✔
320
        sb.append(" '");
4✔
321
        sb.append(arg);
4✔
322
        sb.append("'");
4✔
323
      }
324
    }
325
    sb.append(suffix);
4✔
326
    return sb.toString();
3✔
327
  }
328

329
  private String getSheBang(Path file) {
330

331
    try (InputStream in = Files.newInputStream(file)) {
5✔
332
      // "#!/usr/bin/env bash".length() = 19
333
      byte[] buffer = new byte[32];
3✔
334
      int read = in.read(buffer);
4✔
335
      if ((read > 2) && (buffer[0] == '#') && (buffer[1] == '!')) {
13!
336
        int start = 2;
2✔
337
        int end = 2;
2✔
338
        while (end < read) {
3!
339
          byte c = buffer[end];
4✔
340
          if ((c == '\n') || (c == '\r') || (c > 127)) {
9!
341
            break;
×
342
          } else if ((end == start) && (c == ' ')) {
6!
343
            start++;
×
344
          }
345
          end++;
1✔
346
        }
1✔
347
        String sheBang = new String(buffer, start, end - start, StandardCharsets.US_ASCII).trim();
11✔
348
        if (sheBang.startsWith(PREFIX_USR_BIN_ENV)) {
4✔
349
          sheBang = sheBang.substring(PREFIX_USR_BIN_ENV.length());
5✔
350
        }
351
        return sheBang;
4✔
352
      }
353
    } catch (IOException e) {
5!
354
      // ignore...
355
    }
1✔
356
    return null;
2✔
357
  }
358

359
  private String addExecutable(List<String> args) {
360

361
    String interpreter = null;
2✔
362
    String fileExtension = FilenameUtil.getExtension(this.executable.getFileName().toString());
6✔
363
    boolean isBashScript = "sh".equals(fileExtension);
4✔
364
    this.context.getFileAccess().makeExecutable(this.executable, true);
7✔
365
    if (!isBashScript) {
2✔
366
      String sheBang = getSheBang(this.executable);
5✔
367
      if (sheBang != null) {
2✔
368
        String cmd = sheBang;
2✔
369
        int lastSlash = cmd.lastIndexOf('/');
4✔
370
        if (lastSlash >= 0) {
2✔
371
          cmd = cmd.substring(lastSlash + 1);
6✔
372
        }
373
        if (cmd.equals("bash")) {
4!
374
          isBashScript = true;
2✔
375
        } else {
376
          // currently we do not support other interpreters...
377
        }
378
      }
379
    }
380
    if (isBashScript) {
2✔
381
      interpreter = "bash";
2✔
382
      args.add(this.context.findBashRequired().toString());
7✔
383
    }
384
    if ("msi".equalsIgnoreCase(fileExtension)) {
4!
385
      args.addFirst("/i");
×
386
      args.addFirst("msiexec");
×
387
    }
388
    args.add(this.executable.toString());
6✔
389
    return interpreter;
2✔
390
  }
391

392
  private void performLogging(ProcessResult result, int exitCode, String interpreter) {
393

394
    if (!result.isSuccessful() && (this.errorHandling != ProcessErrorHandling.NONE)) {
7!
395
      IdeLogLevel ideLogLevel = this.errorHandling.getLogLevel();
4✔
396
      String message = createCommandMessage(interpreter, "\nfailed with exit code " + exitCode + "!");
6✔
397

398
      LOG.atLevel(ideLogLevel.getSlf4jLevel()).log(message);
6✔
399
      result.log(ideLogLevel);
3✔
400

401
      if (this.errorHandling == ProcessErrorHandling.THROW_CLI) {
4!
402
        throw new CliProcessException(message, result);
×
403
      } else if (this.errorHandling == ProcessErrorHandling.THROW_ERR) {
4✔
404
        throw new IllegalStateException(message);
5✔
405
      }
406
    }
407
  }
1✔
408

409
  /**
410
   * Modifies the argument list to run the command as a background process. On Linux/macOS, uses {@code bash -c} with {@code & disown} for detachment. On
411
   * Windows, uses {@code cmd.exe /c} with {@code start /b} for detachment or {@code start} for a new window.
412
   *
413
   * @param processMode the {@link ProcessMode} determining the background behavior
414
   * @param args the argument list to modify in place
415
   */
416
  private void modifyArgumentsOnBackgroundProcess(ProcessMode processMode, List<String> args) {
417

418
    if (!processMode.isBackground()) {
3!
419
      throw new IllegalStateException(
×
420
          "modifyArgumentsOnBackgroundProcess called for a non-background process.");
421
    }
422

423
    String commandToRunInBackground = buildCommand(args);
4✔
424

425
    if (this.context.getSystemInfo().isWindows()) {
5!
426
      modifyArgumentsOnBackgroundProcessWindows(processMode, args, commandToRunInBackground);
×
427
    } else {
428
      modifyArgumentsOnBackgroundProcessUnix(processMode, args, commandToRunInBackground);
5✔
429
    }
430
  }
1✔
431

432
  /**
433
   * Modifies arguments for a background process on Linux/macOS using {@code bash -c}.
434
   *
435
   * @param processMode the {@link ProcessMode} determining the background behavior
436
   * @param args the argument list to modify in place
437
   * @param command the command string to run in the background
438
   */
439
  private void modifyArgumentsOnBackgroundProcessUnix(ProcessMode processMode, List<String> args, String command) {
440

441
    Path bash = this.context.findBash();
4✔
442
    if (bash == null) {
2!
443
      LOG.warn("Cannot start background process via bash because no bash installation was found. Hence, output will be discarded.");
×
444
      this.processBuilder.redirectOutput(Redirect.DISCARD).redirectError(Redirect.DISCARD);
×
445
      return;
×
446
    }
447

448
    args.clear();
2✔
449
    args.add(bash.toString());
5✔
450
    args.add("-c");
4✔
451

452
    if (processMode.launchesNewWindow()) {
3✔
453
      String newWindowCommand = buildNewWindowCommand(command);
4✔
454
      args.add(newWindowCommand + " ; disown");
5✔
455
    } else {
1✔
456
      args.add(command + " & disown");
5✔
457
    }
458
  }
1✔
459

460
  /**
461
   * Modifies arguments for a background process on Windows using {@code cmd.exe /c}.
462
   *
463
   * @param processMode the {@link ProcessMode} determining the background behavior
464
   * @param args the argument list to modify in place
465
   * @param command the command string to run in the background
466
   */
467
  private void modifyArgumentsOnBackgroundProcessWindows(ProcessMode processMode, List<String> args, String command) {
468

469
    args.clear();
×
470
    args.add("cmd.exe");
×
471
    args.add("/c");
×
472

473
    if (processMode.launchesNewWindow()) {
×
474
      args.add("start \"\" cmd.exe /k " + command);
×
475
    } else {
476
      // start /b detaches the process without opening a new window
477
      args.add("start \"\" /b " + command);
×
478
    }
479
  }
×
480

481
  private String buildCommand(List<String> args) {
482

483
    if (this.context.getSystemInfo().isWindows()) {
5!
484
      return args.stream()
×
485
          .map(this::windowsQuote)
×
486
          .collect(Collectors.joining(" "));
×
487
    } else {
488
      return args.stream()
5✔
489
          .map(this::shellQuote)
2✔
490
          .collect(Collectors.joining(" "));
3✔
491
    }
492
  }
493

494
  /**
495
   * Build the command for opening a new terminal window on Linux/macOS.
496
   *
497
   * @param command the command to run in the new terminal
498
   * @return the shell command string that opens a new terminal window, or a fallback background command
499
   */
500
  private String buildNewWindowCommand(String command) {
501

502
    if (this.context.getSystemInfo().isLinux()) {
5!
503
      return buildLinuxNewWindowCommand(command);
4✔
504
    }
505

506
    if (this.context.getSystemInfo().isMac()) {
×
507
      return buildMacOsNewWindowCommand(command);
×
508
    }
509

510
    // Fallback for unsupported platforms
511
    LOG.warn(
×
512
        "No terminal emulator detected for BACKGROUND_NEW_WINDOW on {} - falling back to background execution without new window.",
513
        this.context.getSystemInfo().getOsName());
×
514
    return command + " & disown";
×
515
  }
516

517
  /**
518
   * Build the command for opening a new terminal window on Linux. Detects available terminal emulators and uses the correct flag syntax for each.
519
   *
520
   * @param command the command to run in the new terminal
521
   * @return the shell command string that opens a new terminal window, or a fallback background command
522
   */
523
  private String buildLinuxNewWindowCommand(String command) {
524

525
    String bashCommand = "bash -c " + shellQuote(command + "; exec bash");
6✔
526

527
    // Prefer explicit terminal emulators over x-terminal-emulator because
528
    // x-terminal-emulator is only an alternatives symlink and may point to
529
    // different terminals with different command-line syntax.
530
    if (isExecutable("gnome-terminal")) {
4!
531
      return "gnome-terminal -- " + bashCommand + " &";
×
532
    }
533

534
    if (isExecutable("konsole")) {
4!
535
      return "konsole -e " + bashCommand + " &";
×
536
    }
537

538
    if (isExecutable("xfce4-terminal")) {
4!
539
      return "xfce4-terminal --command=" + shellQuote(bashCommand) + " &";
×
540
    }
541

542
    if (isExecutable("tilix")) {
4!
543
      return "tilix -e " + bashCommand + " &";
×
544
    }
545

546
    if (isExecutable("alacritty")) {
4!
547
      return "alacritty -e " + bashCommand + " &";
×
548
    }
549

550
    if (isExecutable("xterm")) {
4!
551
      return "xterm -e " + bashCommand + " &";
×
552
    }
553

554
    // Last fallback only. This may still fail depending on what the alternatives
555
    // symlink points to, but it is better than not trying at all.
556
    if (isExecutable("x-terminal-emulator")) {
4!
557
      return "x-terminal-emulator -e " + bashCommand + " &";
×
558
    }
559

560
    LOG.warn("No terminal emulator found on Linux - falling back to background execution without new window.");
3✔
561

562
    return "bash -c " + shellQuote(command) + " > /dev/null 2>&1 &";
5✔
563
  }
564

565
  private String shellQuote(String value) {
566

567
    if (value == null || value.isEmpty()) {
5!
568
      return "''";
×
569
    }
570
    return "'" + escapeForShellSingleQuote(value) + "'";
4✔
571
  }
572

573
  private String windowsQuote(String value) {
574

575
    if (value == null || value.isEmpty()) {
×
576
      return "\"\"";
×
577
    }
578

579
    // Quote unconditionally and escape cmd.exe metacharacters
580
    String escaped = value.replace("^", "^^").replace("%", "%%").replace("\"", "\\\"");
×
581
    return "\"" + escaped + "\"";
×
582
  }
583

584
  /**
585
   * Build the command for opening a new terminal window on macOS. Prefers iTerm2 via AppleScript, falls back to Terminal.app, then to plain background.
586
   *
587
   * @param command the command to run in the new terminal
588
   * @return the shell command string that opens a new terminal window, or a fallback background command
589
   */
590
  private String buildMacOsNewWindowCommand(String command) {
591

592
    // Escape for AppleScript string literal: backslashes first, then double quotes
593
    String escapedForAppleScript = command.replace("\\", "\\\\")
×
594
        .replace("\"", "\\\"")
×
595
        .replace("\n", "\\n")
×
596
        .replace("\r", "\\r")
×
597
        .replace("\t", "\\t");
×
598

599
    // Check for iTerm2, modern versions ship as /Applications/iTerm.app
600
    if (isItermInstalled()) {
×
601
      String appleScript = "tell application \"iTerm2\"\n"
×
602
          + "  activate\n"
603
          + "  set newWindow to (create window with default profile)\n"
604
          + "  tell current session of newWindow\n"
605
          + "    write text \"" + escapedForAppleScript + "; exec bash\"\n"
606
          + "  end tell\n"
607
          + "end tell";
608
      return "osascript -e '" + escapeForShellSingleQuote(appleScript) + "' &";
×
609
    }
610

611
    // Fallback to Terminal.app
612
    String terminalScript = "tell application \"Terminal\"\n"
×
613
        + "  do script \"" + escapedForAppleScript + "\"\n"
614
        + "end tell";
615
    return "osascript -e '" + escapeForShellSingleQuote(terminalScript) + "' &";
×
616
  }
617

618
  /**
619
   * Check if iTerm2 is installed on macOS.
620
   *
621
   * @return {@code true} if iTerm2 is found
622
   */
623
  private boolean isItermInstalled() {
624

625
    // Modern iTerm2 3.x+ is installed as /Applications/iTerm.app
626
    if (Files.exists(Path.of("/Applications/iTerm.app"))) {
×
627
      return true;
×
628
    }
629
    // Older iTerm2 versions used /Applications/iTerm2.app
630
    if (Files.exists(Path.of("/Applications/iTerm2.app"))) {
×
631
      return true;
×
632
    }
633
    // Check ~/Applications for per-user installs
634
    Path homeApps = Path.of(System.getProperty("user.home"), "Applications");
×
635
    if (Files.exists(homeApps.resolve("iTerm.app"))) {
×
636
      return true;
×
637
    }
638
    if (Files.exists(homeApps.resolve("iTerm2.app"))) {
×
639
      return true;
×
640
    }
641
    // Also check if iTerm binary is in PATH, for portable installs, Homebrew, etc.
642
    return isExecutable("iterm");
×
643
  }
644

645
  /**
646
   * Escape a string for embedding inside a shell single-quoted string. Single quotes are replaced with the standard shell sequence: {@code '"'"'}
647
   *
648
   * @param s the string to escape
649
   * @return the escaped string safe for single-quote embedding
650
   */
651
  private static String escapeForShellSingleQuote(String s) {
652

653
    return s.replace("'", "'\"'\"'");
5✔
654
  }
655

656
  /**
657
   * Check if a command is available in PATH.
658
   *
659
   * @param command the command name
660
   * @return {@code true} if the command is found in PATH
661
   */
662
  private boolean isExecutable(String command) {
663

664
    SystemPath systemPath = this.context.getPath();
4✔
665
    Path binary = systemPath.findBinary(Path.of(command));
7✔
666

667
    try {
668
      return (binary != null) && Files.isExecutable(binary);
7!
669
    } catch (Exception e) {
×
670
      return false;
×
671
    }
672
  }
673

674
  private void applyRedirects(ProcessMode processMode) {
675

676
    Redirect output = processMode.getRedirectOutput();
3✔
677
    Redirect error = processMode.getRedirectError();
3✔
678
    Redirect input = processMode.getRedirectInput();
3✔
679

680
    if (output != null) {
2!
681
      this.processBuilder.redirectOutput(output);
5✔
682
    }
683
    if (error != null) {
2!
684
      this.processBuilder.redirectError(error);
5✔
685
    }
686
    if (input != null) {
2✔
687
      this.processBuilder.redirectInput(input);
5✔
688
    }
689
  }
1✔
690
}
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