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..3f63f3d 100644 --- a/docs/API-Guide.md +++ b/docs/API-Guide.md @@ -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 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..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; @@ -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 { @@ -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 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 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..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. @@ -144,6 +145,93 @@ interface GuiInteraction { */ URL getResourceUrl(String resource) + /** + * Executes a command through the platform shell and returns standard output. + * + *

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 + * @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. + * + *

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. + * + *

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) 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. + * + *

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. * 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..918f5f5 --- /dev/null +++ b/gi-common/src/main/groovy/se/alipsa/gi/ShellResult.groovy @@ -0,0 +1,46 @@ +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 + } + + /** + * 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() { + "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 56a78b1..ddbf6a9 100644 --- a/gi-common/src/test/groovy/se/alipsa/gi/AbstractInOutTest.groovy +++ b/gi-common/src/test/groovy/se/alipsa/gi/AbstractInOutTest.groovy @@ -11,14 +11,19 @@ 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 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 { @@ -48,6 +53,124 @@ class AbstractInOutTest { assertEquals("test.txt", result.name) } + @Test + void shReturnsStandardOutput() { + 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()) + + assertFalse(result.success) + assertEquals(7, result.exitCode) + 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) { + 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') + 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() { + 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 3; printf second') } as Runnable) + worker.start() + long deadline = System.nanoTime() + 2_000_000_000L + boolean sawFirstWhileRunning = false + while (worker.isAlive() && !sawFirstWhileRunning && System.nanoTime() < deadline) { + sawFirstWhileRunning = worker.isAlive() && + bytes.toString(StandardCharsets.UTF_8).contains('first') + Thread.sleep(10) + } + 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(5000) + } + System.setOut(originalOut) + capturedOut.close() + } + } + + private static String failingCommand() { + AbstractInOut.isWindows() ? 'echo shell-failure 1>&2 & exit /b 7' : 'echo shell-failure 1>&2; exit 7' + } + + 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 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.