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
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

17 changes: 17 additions & 0 deletions src/main/java/org/metricshub/winrm/cli/CliArguments.java
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -85,6 +86,7 @@ private CliArguments(final Builder builder) {
kerberosRealm = builder.kerberosRealm;
kerberosRealmInferred = builder.kerberosRealmInferred;
forwardStdin = builder.forwardStdin;
directory = builder.directory;
input = builder.input;
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -480,6 +492,10 @@ boolean forwardStdin() {
return forwardStdin;
}

String directory() {
return directory;
}

String input() {
return input;
}
Expand Down Expand Up @@ -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;
Expand Down
49 changes: 36 additions & 13 deletions src/main/java/org/metricshub/winrm/cli/WinRmCli.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 -> {
Expand Down Expand Up @@ -298,6 +299,7 @@ private static int interactiveShell(
try {
final int exitCode = remote.shell(
arguments.timeout(),
arguments.directory(),
localInput.stream,
standardOutput,
standardError,
Expand Down Expand Up @@ -495,6 +497,7 @@ private static String help() {
" -pf, --password-file <file> Read a UTF-8 password from a file (preferred for automation)\n" +
" -P, --port <port> Target port (default: HTTP 5985, HTTPS 5986)\n" +
" -t, --timeout <ms> Operation timeout in milliseconds (default: 60000)\n" +
" -d, --directory <path> 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" +
Expand Down Expand Up @@ -539,23 +542,32 @@ 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<String> stdoutConsumer,
Consumer<String> stderrConsumer
) 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();
Expand Down Expand Up @@ -605,6 +617,7 @@ public void streamWql(final String query, final long timeout, final Consumer<Map
@Override
public int executeCommand(
final String command,
final String workingDirectory,
final long timeout,
final InputStream stdin,
final Consumer<String> stdoutConsumer,
Expand All @@ -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);
}
Expand All @@ -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,
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/site/markdown/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ takes no argument.
| `-pf, --password-file <file>` | Read the password from a UTF-8 file (preferred for automation, see below). |
| `-P, --port <port>` | Target port. Default: 5985 for HTTP, 5986 for HTTPS. |
| `-t, --timeout <ms>` | Operation timeout in milliseconds. Default: 60000. See [Timeout semantics](#Timeout_semantics). |
| `-d, --directory <path>` | 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. |
Comment thread
bertysentry marked this conversation as resolved.
| `-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`. |
Expand Down
32 changes: 32 additions & 0 deletions src/test/java/org/metricshub/winrm/cli/CliArgumentsTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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")) {
Expand Down Expand Up @@ -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 },
Expand Down
64 changes: 64 additions & 0 deletions src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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("<rsp:WorkingDirectory>C:\\build</rsp:WorkingDirectory>"), create);
}
}

@Test
void shellSubcommandBridgesTheRemoteShellAndPropagatesItsExitCode() throws Exception {
final FakeRemote remote = new FakeRemote();
Expand Down Expand Up @@ -174,6 +230,8 @@ public int read() {
"secret",
"-t",
"10000",
"-d",
"C:\\build",
"shell"
},
WinRmCli::connect,
Expand All @@ -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("<wsman:Option Name=\"WINRS_CODEPAGE\">1252</wsman:Option>"), create);
assertTrue(create.contains("<rsp:WorkingDirectory>C:\\build</rsp:WorkingDirectory>"), create);
final String command = requests.get(2);
assertTrue(command.contains("<rsp:Command>cmd.exe /Q</rsp:Command>"), command);
assertTrue(command.contains("<wsman:Option Name=\"WINRS_CONSOLEMODE_STDIN\">FALSE</wsman:Option>"), command);
Expand Down Expand Up @@ -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;
Expand All @@ -619,12 +679,14 @@ public void streamWql(final String query, final long timeout, final Consumer<Map
@Override
public int executeCommand(
final String command,
final String workingDirectory,
final long timeout,
final java.io.InputStream stdin,
final Consumer<String> stdoutConsumer,
final Consumer<String> stderrConsumer
) throws Exception {
this.command = command;
this.workingDirectory = workingDirectory;
this.forwardedStdin = stdin;
failIfConfigured();
stdoutChunks.forEach(stdoutConsumer);
Expand All @@ -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);
Expand Down
Loading