Skip to content
Merged
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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ GuiInteraction enables standalone Groovy applications to have the same user inte
- HTML and Markdown content viewing
- Table/Matrix display
- Clipboard operations
- Shell command execution with captured output and exit status
- Content type detection (via Apache Tika)
- Resource loading utilities

Expand Down
49 changes: 49 additions & 0 deletions docs/API-Guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,55 @@ if (clipboardFile != null) {

## Utility Methods

### Shell Commands

`sh` is the concise form for interactive or ad-hoc shell commands. It returns
standard output as a `String` and streams standard output and standard error
while the command runs unless `quiet` is `true`:

```groovy
def listing = io.sh('ls -a')
def filtered = io.sh('ls re* | grep matrix', true)
```

`sh` intentionally does not expose the exit status; use `shell` when the
success or failure of the command matters.

Use `shell` when a script needs the exit status and both output streams:

```groovy
def result = io.shell('ls re* | grep matrix > out.txt')

if (!result.success) {
println("Command failed with exit code ${result.exitCode}: ${result.stderr}")
} else {
println(new File('out.txt').text)
}
```

`ShellResult` provides `stdout`, `stderr`, `exitCode`, and `success`. Shell
redirection is performed by the operating system shell, so redirected output
is written to the target file and is not present in `result.stdout`.

Use the timeout overload when a command must have a bounded execution time:

```groovy
def result = io.shell('long-running-command', true, 10_000)
```

The timeout is in milliseconds and applies to both the shell and draining its
output streams; `0` means no timeout. Without a timeout, a command that starts
a background child inheriting standard output or error can remain blocked after
the shell exits until that child closes the pipe.

The same timeout form is available on `sh` when only standard output is needed:
`io.sh('long-running-command', true, 10_000)`.

Commands are executed through `/bin/sh` on Unix-like systems and `cmd.exe` on
Windows. Shell syntax is therefore platform-dependent. These methods execute
arbitrary commands; never concatenate untrusted user input into a shell
command.

### URL Existence Check

```groovy
Expand Down
186 changes: 185 additions & 1 deletion gi-common/src/main/groovy/se/alipsa/gi/AbstractInOut.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package se.alipsa.gi

import groovy.transform.CompileDynamic
import groovy.transform.CompileStatic
import groovy.transform.PackageScope
import org.commonmark.ext.gfm.tables.TablesExtension;
import org.commonmark.parser.Parser;
import org.commonmark.renderer.html.HtmlRenderer;
Expand All @@ -14,8 +15,14 @@ import java.awt.datatransfer.DataFlavor
import java.awt.datatransfer.StringSelection
import java.awt.datatransfer.Transferable
import java.nio.file.Paths
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutionException
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.Future
import java.util.concurrent.ThreadFactory
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException
import java.util.concurrent.atomic.AtomicInteger

@CompileStatic
abstract class AbstractInOut implements GuiInteraction {
Expand Down Expand Up @@ -160,6 +167,183 @@ abstract class AbstractInOut implements GuiInteraction {
return FileUtils.getResourceUrl(resource)
}

@Override
String sh(String command) throws IOException, InterruptedException {
sh(command, false)
}

@Override
String sh(String command, boolean quiet) throws IOException, InterruptedException {
shell(command, quiet).stdout
}

@Override
String sh(String command, boolean quiet, long timeoutMillis)
throws IOException, InterruptedException, TimeoutException {
shell(command, quiet, timeoutMillis).stdout
}

@Override
ShellResult shell(String command) throws IOException, InterruptedException {
shell(command, true)
}

@Override
ShellResult shell(String command, boolean quiet) throws IOException, InterruptedException {
shell(command, quiet, 0)
}

@Override
ShellResult shell(String command, boolean quiet, long timeoutMillis)
throws IOException, InterruptedException, TimeoutException {
if (command == null || command.trim().isEmpty()) {
throw new IllegalArgumentException('command cannot be null or empty')
}
if (timeoutMillis < 0) {
throw new IllegalArgumentException('timeoutMillis cannot be negative')
}

Process process = new ProcessBuilder(shellCommand(command)).start()
process.outputStream.close()

StringBuilder stdout = new StringBuilder()
StringBuilder stderr = new StringBuilder()
ExecutorService readers = Executors.newFixedThreadPool(2, daemonThreadFactory())
PrintStream outputStream = getShellOutputStream()
PrintStream errorStream = getShellErrorStream()
Future<?> stdoutReader = readers.submit({
readProcessOutput(process.inputReader(), stdout, outputStream, quiet)
} as Runnable)
Future<?> stderrReader = readers.submit({
readProcessOutput(process.errorReader(), stderr, errorStream, quiet)
} as Runnable)
long deadline = timeoutMillis > 0 ?
System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis) : Long.MAX_VALUE

try {
if (timeoutMillis > 0) {
if (!process.waitFor(remainingMillis(deadline), TimeUnit.MILLISECONDS)) {
throw new TimeoutException("Command timed out after ${timeoutMillis} ms")
}
awaitOutput(stdoutReader, remainingMillis(deadline))
awaitOutput(stderrReader, remainingMillis(deadline))
} else {
process.waitFor()
awaitOutput(stdoutReader)
awaitOutput(stderrReader)
}
int exitCode = process.exitValue()
return new ShellResult(stdout.toString(), stderr.toString(), exitCode)
} catch (InterruptedException e) {
destroyProcess(process)
Thread.currentThread().interrupt()
throw e
} catch (TimeoutException e) {
destroyProcess(process)
throw e
} finally {
readers.shutdownNow()
if (process.isAlive()) {
destroyProcess(process)
}
}
}

/**
* Returns the destination for streamed shell standard output. GUI implementations
* can override this to route command output into a visible application view.
*/
protected PrintStream getShellOutputStream() {
System.out
}

/**
* Returns the destination for streamed shell standard error. GUI implementations
* can override this to route command errors into a visible application view.
*/
protected PrintStream getShellErrorStream() {
System.err
}

private static ThreadFactory daemonThreadFactory() {
AtomicInteger readerNumber = new AtomicInteger()
return { Runnable task ->
String stream = readerNumber.getAndIncrement() == 0 ? 'stdout' : 'stderr'
Thread thread = new Thread(task, "gi-shell-${stream}-reader")
thread.setDaemon(true)
thread
} as ThreadFactory
}

private static void destroyProcess(Process process) {
process.descendants().forEach { descendant -> descendant.destroyForcibly() }
process.destroyForcibly()
}

private static List<String> shellCommand(String command) {
if (isWindows()) {
String commandInterpreter = System.getenv('ComSpec')
commandInterpreter = commandInterpreter ?: 'cmd.exe'
return [commandInterpreter, '/d', '/s', '/c', command]
}
return ['/bin/sh', '-c', command]
}

@PackageScope
static boolean isWindows() {
System.getProperty('os.name', '').toLowerCase(Locale.ROOT).contains('win')
}

private static void readProcessOutput(
Reader reader, StringBuilder capture, PrintStream destination, boolean quiet) {
try {
char[] buffer = new char[1024]
int count
while ((count = reader.read(buffer)) >= 0) {
if (count == 0) {
continue
}
String chunk = new String(buffer, 0, count)
capture.append(chunk)
if (!quiet) {
destination.print(chunk)
destination.flush()
}
}
} finally {
reader.close()
}
}

private static void awaitOutput(Future<?> reader) throws IOException, InterruptedException {
awaitOutput(reader, 0)
}

private static void awaitOutput(Future<?> reader, long timeoutMillis)
throws IOException, InterruptedException, TimeoutException {
try {
if (timeoutMillis > 0) {
reader.get(timeoutMillis, TimeUnit.MILLISECONDS)
} else {
reader.get()
}
} catch (ExecutionException e) {
Throwable cause = e.cause
if (cause instanceof IOException) {
throw (IOException) cause
}
throw new IOException('Failed to read command output', cause)
}
}

private static long remainingMillis(long deadline) throws TimeoutException {
long remainingNanos = deadline - System.nanoTime()
if (remainingNanos <= 0) {
throw new TimeoutException('Command timed out')
}
Math.max(1, TimeUnit.NANOSECONDS.toMillis(remainingNanos))
}

@Override
String prompt(Map<String, Object> namedParams) {
if (namedParams == null) {
Expand Down
88 changes: 88 additions & 0 deletions gi-common/src/main/groovy/se/alipsa/gi/GuiInteraction.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import javax.swing.JComponent
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeoutException;

/**
* Core interface for GUI interaction capabilities.
Expand Down Expand Up @@ -144,6 +145,93 @@ interface GuiInteraction {
*/
URL getResourceUrl(String resource)

/**
* Executes a command through the platform shell and returns standard output.
*
* <p>Standard output and standard error are streamed to the current process
* while the command runs. Use {@link #sh(String, boolean)} to suppress live
* output.</p>
*
* @param command the shell command to execute
* @return the command's captured standard output
* @throws IOException if the shell cannot be started or output cannot be read
* @throws InterruptedException if the current thread is interrupted
*/
String sh(String command) throws IOException, InterruptedException

/**
* Executes a command through the platform shell and returns standard output.
*
* @param command the shell command to execute
* @param quiet whether to suppress live output
* @return the command's captured standard output
* @throws IOException if the shell cannot be started or output cannot be read
* @throws InterruptedException if the current thread is interrupted
*/
String sh(String command, boolean quiet) throws IOException, InterruptedException

/**
* Executes a command through the platform shell and returns standard output,
* stopping if the supplied timeout is exceeded.
*
* <p>A timeout of {@code 0} disables the timeout. The timeout covers both
* the shell process and draining its output streams.</p>
*
* @param command the shell command to execute
* @param quiet whether to suppress live output
* @param timeoutMillis maximum execution time in milliseconds, or {@code 0} for no timeout
* @return the command's captured standard output
* @throws IOException if the shell cannot be started or output cannot be read
* @throws InterruptedException if the current thread is interrupted
* @throws TimeoutException if the timeout is exceeded
*/
String sh(String command, boolean quiet, long timeoutMillis)
throws IOException, InterruptedException, TimeoutException

/**
* Executes a command through the platform shell and returns its complete result.
*
* <p>The default is quiet so scripts can inspect the result without producing
* unsolicited console output.</p>
*
* @param command the shell command to execute
* @return captured output, error output, and exit code
* @throws IOException if the shell cannot be started or output cannot be read
* @throws InterruptedException if the current thread is interrupted
*/
ShellResult shell(String command) throws IOException, InterruptedException

/**
* Executes a command through the platform shell and returns its complete result.
*
* @param command the shell command to execute
* @param quiet whether to suppress live output
* @return captured output, error output, and exit code
* @throws IOException if the shell cannot be started or output cannot be read
* @throws InterruptedException if the current thread is interrupted
*/
ShellResult shell(String command, boolean quiet) throws IOException, InterruptedException

/**
* Executes a command through the platform shell and returns its complete result,
* stopping if the supplied timeout is exceeded.
*
* <p>A timeout of {@code 0} disables the timeout. The timeout covers both
* the shell process and draining its output streams. Without a timeout, a
* command that leaves a child process holding standard output or standard
* error open can keep this call blocked after the shell itself exits.</p>
*
* @param command the shell command to execute
* @param quiet whether to suppress live output
* @param timeoutMillis maximum execution time in milliseconds, or {@code 0} for no timeout
* @return captured output, error output, and exit code
* @throws IOException if the shell cannot be started or output cannot be read
* @throws InterruptedException if the current thread is interrupted
* @throws TimeoutException if the timeout is exceeded
*/
ShellResult shell(String command, boolean quiet, long timeoutMillis)
throws IOException, InterruptedException, TimeoutException

/**
* A prompt method with support for named parameters in Groovy.
* Example usage:
Expand Down
Loading
Loading