From 3c6bf6a6f47359ff414ceb3212939a22247d7fb4 Mon Sep 17 00:00:00 2001 From: per Date: Tue, 4 Aug 2026 17:53:39 +0200 Subject: [PATCH 1/2] add shell command support --- README.md | 1 + docs/API-Guide.md | 35 +++++++ .../groovy/se/alipsa/gi/AbstractInOut.groovy | 99 +++++++++++++++++++ .../groovy/se/alipsa/gi/GuiInteraction.groovy | 50 ++++++++++ .../groovy/se/alipsa/gi/ShellResult.groovy | 44 +++++++++ .../se/alipsa/gi/AbstractInOutTest.groovy | 77 +++++++++++++++ gi-console/README.md | 1 + 7 files changed, 307 insertions(+) create mode 100644 gi-common/src/main/groovy/se/alipsa/gi/ShellResult.groovy diff --git a/README.md b/README.md index 6c0ac17..c597ecd 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/API-Guide.md b/docs/API-Guide.md index d2f3386..1a586ef 100644 --- a/docs/API-Guide.md +++ b/docs/API-Guide.md @@ -230,6 +230,41 @@ 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`. + +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 diff --git a/gi-common/src/main/groovy/se/alipsa/gi/AbstractInOut.groovy b/gi-common/src/main/groovy/se/alipsa/gi/AbstractInOut.groovy index 30b7dfe..2c6ab18 100644 --- a/gi-common/src/main/groovy/se/alipsa/gi/AbstractInOut.groovy +++ b/gi-common/src/main/groovy/se/alipsa/gi/AbstractInOut.groovy @@ -13,9 +13,15 @@ import java.awt.datatransfer.ClipboardOwner import java.awt.datatransfer.DataFlavor import java.awt.datatransfer.StringSelection import java.awt.datatransfer.Transferable +import java.io.InputStream +import java.nio.charset.Charset import java.nio.file.Paths import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.Future import java.util.concurrent.TimeUnit +import java.util.Locale @CompileStatic abstract class AbstractInOut implements GuiInteraction { @@ -160,6 +166,99 @@ 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 + ShellResult shell(String command) throws IOException, InterruptedException { + shell(command, true) + } + + @Override + ShellResult shell(String command, boolean quiet) throws IOException, InterruptedException { + if (command == null || command.trim().isEmpty()) { + throw new IllegalArgumentException('command cannot be null or empty') + } + + Process process = new ProcessBuilder(shellCommand(command)).start() + process.outputStream.close() + + StringBuilder stdout = new StringBuilder() + StringBuilder stderr = new StringBuilder() + ExecutorService readers = Executors.newFixedThreadPool(2) + Future stdoutReader = readers.submit({ + readProcessOutput(process.inputStream, stdout, System.out, quiet) + } as Runnable) + Future stderrReader = readers.submit({ + readProcessOutput(process.errorStream, stderr, System.err, quiet) + } as Runnable) + + try { + int exitCode = process.waitFor() + awaitOutput(stdoutReader) + awaitOutput(stderrReader) + return new ShellResult(stdout.toString(), stderr.toString(), exitCode) + } catch (InterruptedException e) { + process.destroyForcibly() + Thread.currentThread().interrupt() + throw e + } finally { + readers.shutdownNow() + } + } + + private static List 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] + } + + private static boolean isWindows() { + System.getProperty('os.name', '').toLowerCase(Locale.ROOT).contains('win') + } + + private static void readProcessOutput( + InputStream stream, StringBuilder capture, PrintStream destination, boolean quiet) { + Charset charset = Charset.defaultCharset() + stream.withReader(charset.name()) { reader -> + 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() + } + } + } + } + + private static void awaitOutput(Future reader) throws IOException, InterruptedException { + try { + reader.get() + } catch (java.util.concurrent.ExecutionException e) { + Throwable cause = e.cause + if (cause instanceof IOException) { + throw (IOException) cause + } + throw new IOException('Failed to read command output', cause) + } + } + @Override String prompt(Map namedParams) { if (namedParams == null) { diff --git a/gi-common/src/main/groovy/se/alipsa/gi/GuiInteraction.groovy b/gi-common/src/main/groovy/se/alipsa/gi/GuiInteraction.groovy index cad60a4..3113169 100644 --- a/gi-common/src/main/groovy/se/alipsa/gi/GuiInteraction.groovy +++ b/gi-common/src/main/groovy/se/alipsa/gi/GuiInteraction.groovy @@ -144,6 +144,56 @@ interface GuiInteraction { */ URL getResourceUrl(String resource) + /** + * Executes a command through the platform shell and returns standard output. + * + *

When {@code quiet} is false, standard output and standard error are also + * streamed to the current process while the command runs. The complete + * standard output is returned after the command exits.

+ * + * @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) + + /** + * 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) + + /** + * Executes a command through the platform shell and returns its complete result. + * + *

The default is quiet so scripts can inspect the result without producing + * unsolicited console output.

+ * + * @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) + + /** + * 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) + /** * A prompt method with support for named parameters in Groovy. * Example usage: diff --git a/gi-common/src/main/groovy/se/alipsa/gi/ShellResult.groovy b/gi-common/src/main/groovy/se/alipsa/gi/ShellResult.groovy new file mode 100644 index 0000000..553c643 --- /dev/null +++ b/gi-common/src/main/groovy/se/alipsa/gi/ShellResult.groovy @@ -0,0 +1,44 @@ +package se.alipsa.gi + +import groovy.transform.CompileStatic + +/** + * Result of executing a shell command. + */ +@CompileStatic +final class ShellResult { + + private final String stdout + private final String stderr + private final int exitCode + + ShellResult(String stdout, String stderr, int exitCode) { + this.stdout = stdout ?: '' + this.stderr = stderr ?: '' + this.exitCode = exitCode + } + + String getStdout() { + stdout + } + + String getStderr() { + stderr + } + + int getExitCode() { + exitCode + } + + boolean isSuccess() { + exitCode == 0 + } + + /** + * Makes the result convenient to use in Groovy string interpolation. + */ + @Override + String toString() { + stdout + } +} diff --git a/gi-common/src/test/groovy/se/alipsa/gi/AbstractInOutTest.groovy b/gi-common/src/test/groovy/se/alipsa/gi/AbstractInOutTest.groovy index 56a78b1..23a70cd 100644 --- a/gi-common/src/test/groovy/se/alipsa/gi/AbstractInOutTest.groovy +++ b/gi-common/src/test/groovy/se/alipsa/gi/AbstractInOutTest.groovy @@ -11,8 +11,12 @@ import se.alipsa.groovy.svg.Svg import se.alipsa.matrix.core.Matrix import javax.swing.JComponent +import java.io.ByteArrayOutputStream +import java.io.PrintStream import java.time.LocalDate import java.time.YearMonth +import java.nio.charset.StandardCharsets +import java.util.Locale import com.sun.net.httpserver.HttpServer import java.net.InetSocketAddress import java.util.concurrent.atomic.AtomicInteger @@ -48,6 +52,79 @@ class AbstractInOutTest { assertEquals("test.txt", result.name) } + @Test + void shReturnsStandardOutput() { + assertEquals('shell-output', inOut.sh('echo shell-output').trim()) + } + + @Test + void shellReturnsOutputAndExitCode() { + ShellResult result = inOut.shell(failingCommand()) + + assertFalse(result.success) + assertEquals(7, result.exitCode) + assertTrue(result.stderr.contains('shell-failure')) + } + + @Test + void shellSupportsGlobsPipesAndRedirects(@TempDir File commandDir) { + org.junit.jupiter.api.Assumptions.assumeFalse(isWindows()) + new File(commandDir, 'matrix-one.txt').text = 'one' + new File(commandDir, 'other.txt').text = 'two' + File outputFile = new File(commandDir, 'out.txt') + String directory = shellQuote(commandDir.absolutePath) + String output = shellQuote(outputFile.absolutePath) + + ShellResult result = inOut.shell( + "printf '%s\\n' ${directory}/*.txt | grep matrix > ${output}") + + assertTrue(result.success, result.stderr) + assertTrue(outputFile.text.contains('matrix-one.txt')) + } + + @Test + void shStreamsOutputBeforeTheCommandCompletes() { + org.junit.jupiter.api.Assumptions.assumeFalse(isWindows()) + PrintStream originalOut = System.out + ByteArrayOutputStream bytes = new ByteArrayOutputStream() + PrintStream capturedOut = new PrintStream(bytes, true, StandardCharsets.UTF_8) + Thread worker + try { + System.setOut(capturedOut) + worker = new Thread({ inOut.sh('printf first; sleep 1; printf second') } as Runnable) + worker.start() + long deadline = System.nanoTime() + 2_000_000_000L + while (worker.isAlive() && !bytes.toString(StandardCharsets.UTF_8).contains('first') && + System.nanoTime() < deadline) { + Thread.sleep(10) + } + assertTrue(bytes.toString(StandardCharsets.UTF_8).contains('first')) + assertTrue(worker.isAlive()) + worker.join(3000) + assertFalse(worker.isAlive()) + assertTrue(bytes.toString(StandardCharsets.UTF_8).contains('second')) + } finally { + if (worker != null && worker.isAlive()) { + worker.interrupt() + worker.join(3000) + } + System.setOut(originalOut) + capturedOut.close() + } + } + + private static String failingCommand() { + isWindows() ? 'echo shell-failure 1>&2 & exit /b 7' : 'echo shell-failure 1>&2; exit 7' + } + + private static boolean isWindows() { + System.getProperty('os.name', '').toLowerCase(Locale.ROOT).contains('win') + } + + private static String shellQuote(String value) { + "'${value.replace("'", "'\\''")}'" + } + @Test void testGetContentTypeForPngFile() { // Using test resource diff --git a/gi-console/README.md b/gi-console/README.md index 2732620..7142c98 100644 --- a/gi-console/README.md +++ b/gi-console/README.md @@ -17,6 +17,7 @@ This module provides console-based (text mode) interaction capabilities. It's de - File path input via console - Table output using Matrix text formatting - HTML content displayed as plain text (via Jsoup) +- Shell command execution with streamed or captured output - System clipboard access (when not running headless) ## Installation From 2c970d56b3ceee6ba540089d60093b7509f8af99 Mon Sep 17 00:00:00 2001 From: per Date: Tue, 4 Aug 2026 21:08:28 +0200 Subject: [PATCH 2/2] fix shell command execution API --- docs/API-Guide.md | 14 +++ .../groovy/se/alipsa/gi/AbstractInOut.groovy | 119 +++++++++++++++--- .../groovy/se/alipsa/gi/GuiInteraction.groovy | 54 ++++++-- .../groovy/se/alipsa/gi/ShellResult.groovy | 6 +- .../se/alipsa/gi/AbstractInOutTest.groovy | 76 ++++++++--- gi-fx/README.md | 1 + gi-swing/README.md | 1 + release.md | 3 + 8 files changed, 232 insertions(+), 42 deletions(-) diff --git a/docs/API-Guide.md b/docs/API-Guide.md index 1a586ef..3f63f3d 100644 --- a/docs/API-Guide.md +++ b/docs/API-Guide.md @@ -260,6 +260,20 @@ if (!result.success) { 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 diff --git a/gi-common/src/main/groovy/se/alipsa/gi/AbstractInOut.groovy b/gi-common/src/main/groovy/se/alipsa/gi/AbstractInOut.groovy index 2c6ab18..3947e84 100644 --- a/gi-common/src/main/groovy/se/alipsa/gi/AbstractInOut.groovy +++ b/gi-common/src/main/groovy/se/alipsa/gi/AbstractInOut.groovy @@ -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; @@ -13,15 +14,15 @@ import java.awt.datatransfer.ClipboardOwner import java.awt.datatransfer.DataFlavor import java.awt.datatransfer.StringSelection import java.awt.datatransfer.Transferable -import java.io.InputStream -import java.nio.charset.Charset 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.Locale +import java.util.concurrent.TimeoutException +import java.util.concurrent.atomic.AtomicInteger @CompileStatic abstract class AbstractInOut implements GuiInteraction { @@ -176,6 +177,12 @@ abstract class AbstractInOut implements GuiInteraction { 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) @@ -183,37 +190,96 @@ abstract class AbstractInOut implements GuiInteraction { @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) + ExecutorService readers = Executors.newFixedThreadPool(2, daemonThreadFactory()) + PrintStream outputStream = getShellOutputStream() + PrintStream errorStream = getShellErrorStream() Future stdoutReader = readers.submit({ - readProcessOutput(process.inputStream, stdout, System.out, quiet) + readProcessOutput(process.inputReader(), stdout, outputStream, quiet) } as Runnable) Future stderrReader = readers.submit({ - readProcessOutput(process.errorStream, stderr, System.err, quiet) + readProcessOutput(process.errorReader(), stderr, errorStream, quiet) } as Runnable) + long deadline = timeoutMillis > 0 ? + System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis) : Long.MAX_VALUE try { - int exitCode = process.waitFor() - awaitOutput(stdoutReader) - awaitOutput(stderrReader) + 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) { - process.destroyForcibly() + 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 shellCommand(String command) { if (isWindows()) { String commandInterpreter = System.getenv('ComSpec') @@ -223,14 +289,14 @@ abstract class AbstractInOut implements GuiInteraction { return ['/bin/sh', '-c', command] } - private static boolean isWindows() { + @PackageScope + static boolean isWindows() { System.getProperty('os.name', '').toLowerCase(Locale.ROOT).contains('win') } private static void readProcessOutput( - InputStream stream, StringBuilder capture, PrintStream destination, boolean quiet) { - Charset charset = Charset.defaultCharset() - stream.withReader(charset.name()) { reader -> + Reader reader, StringBuilder capture, PrintStream destination, boolean quiet) { + try { char[] buffer = new char[1024] int count while ((count = reader.read(buffer)) >= 0) { @@ -244,13 +310,24 @@ abstract class AbstractInOut implements GuiInteraction { 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 { - reader.get() - } catch (java.util.concurrent.ExecutionException e) { + 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 @@ -259,6 +336,14 @@ abstract class AbstractInOut implements GuiInteraction { } } + 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 namedParams) { if (namedParams == null) { diff --git a/gi-common/src/main/groovy/se/alipsa/gi/GuiInteraction.groovy b/gi-common/src/main/groovy/se/alipsa/gi/GuiInteraction.groovy index 3113169..72057c7 100644 --- a/gi-common/src/main/groovy/se/alipsa/gi/GuiInteraction.groovy +++ b/gi-common/src/main/groovy/se/alipsa/gi/GuiInteraction.groovy @@ -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. @@ -147,17 +148,16 @@ interface GuiInteraction { /** * Executes a command through the platform shell and returns standard output. * - *

When {@code quiet} is false, standard output and standard error are also - * streamed to the current process while the command runs. The complete - * standard output is returned after the command exits.

+ *

Standard output and standard error are streamed to the current process + * while the command runs. Use {@link #sh(String, boolean)} to suppress live + * 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) + String sh(String command) throws IOException, InterruptedException /** * Executes a command through the platform shell and returns standard output. @@ -168,7 +168,25 @@ interface GuiInteraction { * @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) + 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. + * + *

A timeout of {@code 0} disables the timeout. The timeout covers both + * the shell process and draining its output streams.

+ * + * @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. @@ -181,7 +199,7 @@ interface GuiInteraction { * @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) + ShellResult shell(String command) throws IOException, InterruptedException /** * Executes a command through the platform shell and returns its complete result. @@ -192,7 +210,27 @@ interface GuiInteraction { * @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) + 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. + * + *

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.

+ * + * @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. diff --git a/gi-common/src/main/groovy/se/alipsa/gi/ShellResult.groovy b/gi-common/src/main/groovy/se/alipsa/gi/ShellResult.groovy index 553c643..918f5f5 100644 --- a/gi-common/src/main/groovy/se/alipsa/gi/ShellResult.groovy +++ b/gi-common/src/main/groovy/se/alipsa/gi/ShellResult.groovy @@ -35,10 +35,12 @@ final class ShellResult { } /** - * Makes the result convenient to use in Groovy string interpolation. + * Returns a diagnostic representation including the exit status and both + * output streams. Use {@code stdout} or {@code sh(...)} when only text is + * wanted in an interpolated string. */ @Override String toString() { - stdout + "ShellResult(exitCode=${exitCode}, stdout='${stdout}', stderr='${stderr}')" } } diff --git a/gi-common/src/test/groovy/se/alipsa/gi/AbstractInOutTest.groovy b/gi-common/src/test/groovy/se/alipsa/gi/AbstractInOutTest.groovy index 23a70cd..ddbf6a9 100644 --- a/gi-common/src/test/groovy/se/alipsa/gi/AbstractInOutTest.groovy +++ b/gi-common/src/test/groovy/se/alipsa/gi/AbstractInOutTest.groovy @@ -16,13 +16,14 @@ import java.io.PrintStream import java.time.LocalDate import java.time.YearMonth import java.nio.charset.StandardCharsets -import java.util.Locale import com.sun.net.httpserver.HttpServer import java.net.InetSocketAddress import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicReference +import java.util.concurrent.TimeoutException import java.util.stream.Stream +import static org.junit.jupiter.api.Assumptions.assumeFalse import static org.junit.jupiter.api.Assertions.* class AbstractInOutTest { @@ -57,6 +58,46 @@ class AbstractInOutTest { assertEquals('shell-output', inOut.sh('echo shell-output').trim()) } + @Test + void shQuietOverloadSuppressesLiveOutput() { + PrintStream originalOut = System.out + PrintStream originalErr = System.err + ByteArrayOutputStream outBytes = new ByteArrayOutputStream() + ByteArrayOutputStream errBytes = new ByteArrayOutputStream() + PrintStream capturedOut = new PrintStream(outBytes, true, StandardCharsets.UTF_8) + PrintStream capturedErr = new PrintStream(errBytes, true, StandardCharsets.UTF_8) + try { + System.setOut(capturedOut) + System.setErr(capturedErr) + assertEquals('quiet-output', + inOut.sh('printf quiet-output; printf quiet-error 1>&2', true)) + assertEquals('', outBytes.toString(StandardCharsets.UTF_8)) + assertEquals('', errBytes.toString(StandardCharsets.UTF_8)) + } finally { + System.setOut(originalOut) + System.setErr(originalErr) + capturedOut.close() + capturedErr.close() + } + } + + @Test + void shellRejectsNullAndEmptyCommands() { + assertThrows(IllegalArgumentException.class) { inOut.shell(null) } + assertThrows(IllegalArgumentException.class) { inOut.shell(' ') } + } + + @Test + void shellSuccessReportsExitCodeAndResultStringIsDiagnostic() { + ShellResult result = inOut.shell('printf hello') + + assertTrue(result.success) + assertEquals(0, result.exitCode) + assertEquals('hello', result.stdout) + assertTrue(result.toString().contains('exitCode=0')) + assertTrue(result.toString().contains("stdout='hello'")) + } + @Test void shellReturnsOutputAndExitCode() { ShellResult result = inOut.shell(failingCommand()) @@ -66,9 +107,17 @@ class AbstractInOutTest { assertTrue(result.stderr.contains('shell-failure')) } + @Test + void shellTimeoutStopsACommand() { + assumeFalse(AbstractInOut.isWindows()) + assertThrows(TimeoutException.class) { + inOut.shell('sleep 3', true, 100) + } + } + @Test void shellSupportsGlobsPipesAndRedirects(@TempDir File commandDir) { - org.junit.jupiter.api.Assumptions.assumeFalse(isWindows()) + assumeFalse(AbstractInOut.isWindows()) new File(commandDir, 'matrix-one.txt').text = 'one' new File(commandDir, 'other.txt').text = 'two' File outputFile = new File(commandDir, 'out.txt') @@ -84,29 +133,30 @@ class AbstractInOutTest { @Test void shStreamsOutputBeforeTheCommandCompletes() { - org.junit.jupiter.api.Assumptions.assumeFalse(isWindows()) + assumeFalse(AbstractInOut.isWindows()) PrintStream originalOut = System.out ByteArrayOutputStream bytes = new ByteArrayOutputStream() PrintStream capturedOut = new PrintStream(bytes, true, StandardCharsets.UTF_8) Thread worker try { System.setOut(capturedOut) - worker = new Thread({ inOut.sh('printf first; sleep 1; printf second') } as Runnable) + worker = new Thread({ inOut.sh('printf first; sleep 3; printf second') } as Runnable) worker.start() long deadline = System.nanoTime() + 2_000_000_000L - while (worker.isAlive() && !bytes.toString(StandardCharsets.UTF_8).contains('first') && - System.nanoTime() < deadline) { + boolean sawFirstWhileRunning = false + while (worker.isAlive() && !sawFirstWhileRunning && System.nanoTime() < deadline) { + sawFirstWhileRunning = worker.isAlive() && + bytes.toString(StandardCharsets.UTF_8).contains('first') Thread.sleep(10) } - assertTrue(bytes.toString(StandardCharsets.UTF_8).contains('first')) - assertTrue(worker.isAlive()) - worker.join(3000) + assertTrue(sawFirstWhileRunning) + worker.join(5000) assertFalse(worker.isAlive()) assertTrue(bytes.toString(StandardCharsets.UTF_8).contains('second')) } finally { if (worker != null && worker.isAlive()) { worker.interrupt() - worker.join(3000) + worker.join(5000) } System.setOut(originalOut) capturedOut.close() @@ -114,11 +164,7 @@ class AbstractInOutTest { } private static String failingCommand() { - isWindows() ? 'echo shell-failure 1>&2 & exit /b 7' : 'echo shell-failure 1>&2; exit 7' - } - - private static boolean isWindows() { - System.getProperty('os.name', '').toLowerCase(Locale.ROOT).contains('win') + AbstractInOut.isWindows() ? 'echo shell-failure 1>&2 & exit /b 7' : 'echo shell-failure 1>&2; exit 7' } private static String shellQuote(String value) { diff --git a/gi-fx/README.md b/gi-fx/README.md index 06e852d..cd7fc40 100644 --- a/gi-fx/README.md +++ b/gi-fx/README.md @@ -18,6 +18,7 @@ This module provides JavaFX-based dialogs and viewers for user interaction. It o - WebView-based HTML rendering - Markdown viewing with full formatting support - Chart display via Matrix Charts integration +- Shell command execution with captured output and exit status ## Installation diff --git a/gi-swing/README.md b/gi-swing/README.md index 8d5d63c..44edd58 100644 --- a/gi-swing/README.md +++ b/gi-swing/README.md @@ -18,6 +18,7 @@ This module provides Swing-based dialogs and viewers for user interaction. It wo - Table display with JTable - Clipboard operations - Chart display via Matrix Charts integration +- Shell command execution with captured output and exit status ## Installation diff --git a/release.md b/release.md index a41d01a..bd2dade 100644 --- a/release.md +++ b/release.md @@ -1,5 +1,8 @@ # Gui Interaction Release Notes +## Unreleased +- Common: added shell command execution with captured/streamed output, exit status, and optional timeouts. These methods execute arbitrary platform shell commands and should not receive untrusted input. + ## 0.4.0 - 2026-08-03 - Breaking: `FileUtils.getResourceUrl` returns `null` for paths that do not exist; use `File` for output targets that will be created later. - Breaking: Swing and JavaFX display resolution now prefers classpath resources when a name exists both on the classpath and in the working directory.