diff --git a/AGENTS.md b/AGENTS.md index 6b97797..8ec556d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,5 +24,7 @@ Code quality checks are performed during the build with `mvn verify` (checkstyle ## Documentation -Any change that affects the end user of this library must be properly documented in README.md. +Any change that affects the end user of this library must be properly documented in the site documentation under `src/site/markdown/`. In particular, `src/site/markdown/cli.md` is the single source of truth for the command-line client: new CLI options go in its options table, plus one short line in the `--help` output. + +README.md is deliberately terse — a quick start, a few representative examples, and links to the full documentation. Do NOT catalog every option or minor feature there; update README.md only when a change invalidates or alters what it already shows (quick-start snippets, examples, upgrade notes). diff --git a/src/main/java/org/metricshub/winrm/cli/CliArguments.java b/src/main/java/org/metricshub/winrm/cli/CliArguments.java index 1de7806..0258907 100644 --- a/src/main/java/org/metricshub/winrm/cli/CliArguments.java +++ b/src/main/java/org/metricshub/winrm/cli/CliArguments.java @@ -69,6 +69,7 @@ enum Operation { private final String kerberosRealm; private final boolean kerberosRealmInferred; private final boolean forwardStdin; + private final String directory; private final String input; private CliArguments(final Builder builder) { @@ -85,6 +86,7 @@ private CliArguments(final Builder builder) { kerberosRealm = builder.kerberosRealm; kerberosRealmInferred = builder.kerberosRealmInferred; forwardStdin = builder.forwardStdin; + directory = builder.directory; input = builder.input; } @@ -154,6 +156,10 @@ private static int parseOption(final Builder builder, final String[] arguments, case "-t": builder.timeout = parseTimeout(optionValue(arguments, index, option), option); return nextIndex(argument, index); + case "--directory": + case "-d": + builder.directory = optionValue(arguments, index, option); + return nextIndex(argument, index); case "--ntlm": builder.ntlm = true; return index + 1; @@ -244,6 +250,12 @@ private static void validate(final Builder builder) throws CliUsageException { if (builder.forwardStdin && builder.operation != Operation.COMMAND) { throw new CliUsageException("--stdin requires the command subcommand"); } + if (builder.directory != null && builder.directory.trim().isEmpty()) { + throw new CliUsageException("--directory requires a value"); + } + if (builder.directory != null && builder.operation == Operation.WQL) { + throw new CliUsageException("--directory requires the command or shell subcommand"); + } if (builder.operation == Operation.SHELL && builder.timeout < MIN_SHELL_TIMEOUT) { throw new CliUsageException("shell requires --timeout of at least " + MIN_SHELL_TIMEOUT + " milliseconds"); } @@ -480,6 +492,10 @@ boolean forwardStdin() { return forwardStdin; } + String directory() { + return directory; + } + String input() { return input; } @@ -507,6 +523,7 @@ private static final class Builder { private String kerberosRealm; private boolean kerberosRealmInferred; private boolean forwardStdin; + private String directory; private Integer port; private long timeout = DEFAULT_TIMEOUT; private String input; diff --git a/src/main/java/org/metricshub/winrm/cli/WinRmCli.java b/src/main/java/org/metricshub/winrm/cli/WinRmCli.java index 2883c51..ff90a8c 100644 --- a/src/main/java/org/metricshub/winrm/cli/WinRmCli.java +++ b/src/main/java/org/metricshub/winrm/cli/WinRmCli.java @@ -255,6 +255,7 @@ private static int execute( // automatically when detected, unconditionally with --stdin. final int exitCode = remote.executeCommand( arguments.input(), + arguments.directory(), arguments.timeout(), arguments.forwardStdin() || !localInput.terminal ? localInput.stream : null, chunk -> { @@ -298,6 +299,7 @@ private static int interactiveShell( try { final int exitCode = remote.shell( arguments.timeout(), + arguments.directory(), localInput.stream, standardOutput, standardError, @@ -495,6 +497,7 @@ private static String help() { " -pf, --password-file Read a UTF-8 password from a file (preferred for automation)\n" + " -P, --port Target port (default: HTTP 5985, HTTPS 5986)\n" + " -t, --timeout Operation timeout in milliseconds (default: 60000)\n" + + " -d, --directory Working directory of the remote command or shell\n" + " -i, --stdin Forward the local standard input to the remote command\n" + " --https Use HTTPS\n" + " --https-permissive Trust any HTTPS certificate and hostname (insecure)\n" + @@ -539,11 +542,13 @@ interface RemoteOperations extends AutoCloseable { /** * Run the command, forwarding each decoded output chunk to the matching consumer as it - * arrives, and return the remote exit code. A non-null {@code stdin} is consumed to its end - * and forwarded as the command's standard input. + * arrives, and return the remote exit code. A non-null {@code workingDirectory} is the + * directory the command starts in. A non-null {@code stdin} is consumed to its end and + * forwarded as the command's standard input. */ int executeCommand( String command, + String workingDirectory, long timeout, InputStream stdin, Consumer stdoutConsumer, @@ -551,11 +556,18 @@ int executeCommand( ) throws Exception; /** - * Start {@code cmd.exe} on the remote host and bridge it to the given local streams until - * it exits; return its exit code. See {@link InteractiveShell}. + * Start {@code cmd.exe} on the remote host — in the given working directory when non-null — + * and bridge it to the given local streams until it exits; return its exit code. See + * {@link InteractiveShell}. */ - int shell(long timeout, InputStream localInput, PrintStream out, PrintStream err, AtomicBoolean interruptRequested) - throws Exception; + int shell( + long timeout, + String workingDirectory, + InputStream localInput, + PrintStream out, + PrintStream err, + AtomicBoolean interruptRequested + ) throws Exception; @Override void close(); @@ -605,6 +617,7 @@ public void streamWql(final String query, final long timeout, final Consumer stdoutConsumer, @@ -615,6 +628,11 @@ public int executeCommand( .timeout(Duration.ofMillis(timeout)) .onStdout(stdoutConsumer) .onStderr(stderrConsumer); + // A CLI invocation is one client running one command, so the API's "first command + // only" pinning of the working directory always applies here. + if (workingDirectory != null) { + request.workingDirectory(workingDirectory); + } if (stdin != null) { request.stdin(stdin); } @@ -624,6 +642,7 @@ public int executeCommand( @Override public int shell( final long timeout, + final String workingDirectory, final InputStream localInput, final PrintStream out, final PrintStream err, @@ -644,13 +663,17 @@ public int shell( // cmd.exe /Q: no command echo — the local terminal already shows what the user // types. Pipe-mode stdin (stdin()) makes the local end-of-input a real EOF, which // cmd.exe exits on. - try ( - RemoteProcess process = shellClient - .command(SHELL_COMMAND) - .timeout(Duration.ofMillis(timeout)) - .charset(encoding.charset()) - .stdin() - .start()) { + final CommandRequest shellRequest = shellClient + .command(SHELL_COMMAND) + .timeout(Duration.ofMillis(timeout)) + .charset(encoding.charset()) + .stdin(); + // The session client's first (and only) command is the shell itself, so the + // working directory always takes effect. + if (workingDirectory != null) { + shellRequest.workingDirectory(workingDirectory); + } + try (RemoteProcess process = shellRequest.start()) { return InteractiveShell.run( process, localInput, diff --git a/src/site/markdown/cli.md b/src/site/markdown/cli.md index ad3b3f0..9f5d136 100644 --- a/src/site/markdown/cli.md +++ b/src/site/markdown/cli.md @@ -41,6 +41,7 @@ takes no argument. | `-pf, --password-file ` | Read the password from a UTF-8 file (preferred for automation, see below). | | `-P, --port ` | Target port. Default: 5985 for HTTP, 5986 for HTTPS. | | `-t, --timeout ` | Operation timeout in milliseconds. Default: 60000. See [Timeout semantics](#Timeout_semantics). | +| `-d, --directory ` | Working directory the remote command or interactive shell starts in, like `winrs -d` (only with `command` and `shell`). Default: the remote user's profile directory. | | `-i, --stdin` | Forward the local standard input to the remote command (only with `command`); see below. | | `--https` | Connect over HTTPS. | | `--https-permissive` | Trust any HTTPS certificate and hostname. Intentionally insecure: testing and isolated hosts only. Requires `--https`. | diff --git a/src/test/java/org/metricshub/winrm/cli/CliArgumentsTest.java b/src/test/java/org/metricshub/winrm/cli/CliArgumentsTest.java index 0dc72d6..68a620a 100644 --- a/src/test/java/org/metricshub/winrm/cli/CliArgumentsTest.java +++ b/src/test/java/org/metricshub/winrm/cli/CliArgumentsTest.java @@ -133,6 +133,31 @@ void acceptsOmittedPasswordForInteractivePrompting() throws Exception { } } + @Test + void parsesTheRemoteWorkingDirectory() throws Exception { + try ( + CliArguments parsed = CliArguments.parse( + new String[] + { "-h", "host", "-u", "user", "-p", "secret", "-d", "C:\\build", "command", "build.cmd" } + )) { + assertEquals("C:\\build", parsed.directory()); + } + try ( + CliArguments parsed = CliArguments.parse( + new String[] + { "-h", "host", "-u", "user", "-p", "secret", "--directory=C:\\build", "shell" } + )) { + assertEquals("C:\\build", parsed.directory()); + } + try ( + CliArguments parsed = CliArguments.parse( + new String[] + { "-h", "host", "-u", "user", "-p", "secret", "command", "whoami" } + )) { + assertNull(parsed.directory()); + } + } + @Test void acceptsEveryCommandAlias() throws Exception { for (final String alias : List.of("command", "cmd", "exec", "run")) { @@ -215,6 +240,13 @@ void rejectsInvalidArguments() { "--https-permissive requires --https", concat(base, "--https-permissive", "command", "whoami") }, + { "-d requires a value", concat(base, "-d") }, + { "--directory requires a value", concat(base, "--directory=", "command", "whoami") }, + { "--directory requires a value", concat(base, "--directory", " ", "command", "whoami") }, + { + "--directory requires the command or shell subcommand", + concat(base, "-d", "C:\\build", "wql", "SELECT Name FROM Win32_Service") + }, { "-P must be between 1 and 65535", concat(base, "-P", "65536", "command", "whoami") }, { "-t must be greater than zero", concat(base, "-t", "0", "command", "whoami") }, { "missing subcommand (wql, command, or shell)", base }, diff --git a/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java index 5960811..8745d8b 100644 --- a/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java +++ b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java @@ -55,6 +55,7 @@ void helpAndVersionDoNotConnect() throws Exception { assertTrue(help.stdout.contains("command|cmd|exec|run")); assertTrue(help.stdout.contains("[options] shell")); assertTrue(help.stdout.contains("-P, --port")); + assertTrue(help.stdout.contains("-d, --directory")); assertTrue(help.stdout.contains("--kerberos-kdc")); assertTrue(help.stdout.contains("--kerberos-realm")); // The details (streaming behavior, password files, exit codes) live in the online manual. @@ -109,6 +110,61 @@ void forwardsCommandStreamsAndExitCode() throws Exception { assertEquals("echo \"hello world\"", remote.command); } + @Test + void passesTheDirectoryOptionThroughAsTheWorkingDirectory() throws Exception { + final FakeRemote remote = new FakeRemote(); + final Invocation invocation = invoke(concat(REQUIRED, "-d", "C:\\build", "exec", "build.cmd"), args -> remote); + assertEquals(0, invocation.exitCode); + assertEquals("C:\\build", remote.workingDirectory); + + // The shell subcommand starts in the requested directory too. + final FakeRemote shellRemote = new FakeRemote(); + assertEquals(0, invoke(concat(REQUIRED, "--directory=C:\\build", "shell"), args -> shellRemote).exitCode); + assertEquals("C:\\build", shellRemote.workingDirectory); + + // Without the option, no working directory is sent: the remote default applies. + final FakeRemote defaulted = new FakeRemote(); + assertEquals(0, invoke(concat(REQUIRED, "exec", "build.cmd"), args -> defaulted).exitCode); + org.junit.jupiter.api.Assertions.assertNull(defaulted.workingDirectory); + } + + @Test + void sendsTheWorkingDirectoryInTheCreateShellRequest() throws Exception { + // Full stack against the in-process WSMan server, through the CLI's real connect factory: + // --directory must reach the wire as rsp:WorkingDirectory in the Create shell request. + try (FakeWsmanServer server = new FakeWsmanServer("FAKE", "user", "secret")) { + enqueueShellCreation(server); + enqueueCommandExchange(server, "ok".getBytes(StandardCharsets.UTF_8), new byte[0], 0); + enqueueShellDeletion(server); + + final Invocation invocation = invoke( + new String[] + { + "-h", + "127.0.0.1", + "-P", + String.valueOf(server.port()), + "-u", + "FAKE\\user", + "-p", + "secret", + "-t", + "30000", + "-d", + "C:\\build", + "exec", + "build.cmd" + }, + WinRmCli::connect + ); + + assertEquals(0, invocation.exitCode); + assertEquals("ok", invocation.stdout); + final String create = server.decryptedRequests().get(0); + assertTrue(create.contains("C:\\build"), create); + } + } + @Test void shellSubcommandBridgesTheRemoteShellAndPropagatesItsExitCode() throws Exception { final FakeRemote remote = new FakeRemote(); @@ -174,6 +230,8 @@ public int read() { "secret", "-t", "10000", + "-d", + "C:\\build", "shell" }, WinRmCli::connect, @@ -185,6 +243,7 @@ public int read() { assertTrue(requests.get(0).contains("Win32_OperatingSystem"), requests.get(0)); final String create = requests.get(1); assertTrue(create.contains("1252"), create); + assertTrue(create.contains("C:\\build"), create); final String command = requests.get(2); assertTrue(command.contains("cmd.exe /Q"), command); assertTrue(command.contains("FALSE"), command); @@ -605,6 +664,7 @@ private static final class FakeRemote implements WinRmCli.RemoteOperations { private int commandExitCode; private Exception failure; private String command; + private String workingDirectory; private java.io.InputStream forwardedStdin; private boolean shellStarted; private boolean closed; @@ -619,12 +679,14 @@ public void streamWql(final String query, final long timeout, final Consumer stdoutConsumer, final Consumer stderrConsumer ) throws Exception { this.command = command; + this.workingDirectory = workingDirectory; this.forwardedStdin = stdin; failIfConfigured(); stdoutChunks.forEach(stdoutConsumer); @@ -635,12 +697,14 @@ public int executeCommand( @Override public int shell( final long timeout, + final String workingDirectory, final java.io.InputStream localInput, final java.io.PrintStream out, final java.io.PrintStream err, final java.util.concurrent.atomic.AtomicBoolean interruptRequested ) throws Exception { shellStarted = true; + this.workingDirectory = workingDirectory; failIfConfigured(); stdoutChunks.forEach(chunk -> { out.print(chunk);