Skip to content

Commit

Permalink
[#802] add echo and prompt for interactive option
Browse files Browse the repository at this point in the history
  • Loading branch information
sakata1222 committed Dec 13, 2020
1 parent a610c7c commit d767f5d
Show file tree
Hide file tree
Showing 3 changed files with 417 additions and 40 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package picocli.examples.interactive;

import picocli.CommandLine;
import picocli.CommandLine.Parameters;

public class UserPasswordDemo implements Runnable {

@Parameters(index = "0", description = {"User"}, interactive = true, echo = true, prompt = "Enter your user: ")
String user;

@Parameters(index = "1", description = {"Password"}, interactive = true, prompt = "Enter your password: ")
String password;

@Parameters(index = "2", description = {"Action"})
String action;

public void run() {
// See also PasswordDemo
login(user, password);
doAction(action);
}

private void login(String user, String pwd) {
System.out.printf("User: %s, Password: %s%n", user, pwd);
}

private void doAction(String action) {
System.out.printf("Action: %s%n", action);
}

public static void main(String[] args) {
new CommandLine(new UserPasswordDemo()).execute(args);
}
}
158 changes: 125 additions & 33 deletions src/main/java/picocli/CommandLine.java
Original file line number Diff line number Diff line change
Expand Up @@ -3870,7 +3870,7 @@ public enum ScopeType {
/**
* Set {@code interactive=true} if this option will prompt the end user for a value (like a password).
* Only supported for single-value options and {@code char[]} arrays (no collections, maps or other array types).
* When running on Java 6 or greater, this will use the {@link Console#readPassword()} API to get a value without echoing input to the console.
* When running on Java 6 or greater and echo attribute is false, this will use the {@link Console#readPassword()} API to get a value without echoing input to the console.
* <p>
* Best security practice is to use type {@code char[]} instead of {@code String}, and to to null out the array after use.
* </p><p>
Expand All @@ -3883,6 +3883,22 @@ public enum ScopeType {
*/
boolean interactive() default false;

/**
* Use this attribute to control a user input is echo-ed to the console or not. If {@code echo=true}, a input is echo-ed to the console.
* This attribute is ignored when interactive attribute is false.
* @return whether the user input is echo-ed to the console or not
* @since 4.X
*/
boolean echo() default false;

/**
* Use this attribute to customize the text displayed to the end user for an interactive option.
* This attribute is ignored when interactive attribute is false.
* @return text will be displayed to the end user
* @since 4.X
*/
String prompt() default "";

/** ResourceBundle key for this option. If not specified, (and a ResourceBundle {@linkplain Command#resourceBundle() exists for this command}) an attempt
* is made to find the option description using any of the option names (without leading hyphens) as key.
* @see OptionSpec#description()
Expand Down Expand Up @@ -4143,12 +4159,28 @@ public enum ScopeType {
/**
* Set {@code interactive=true} if this positional parameter will prompt the end user for a value (like a password).
* Only supported for single-value positional parameters (not arrays, collections or maps).
* When running on Java 6 or greater, this will use the {@link Console#readPassword()} API to get a value without echoing input to the console.
* When running on Java 6 or greater and echo attribute is false, this will use the {@link Console#readPassword()} API to get a value without echoing input to the console.
* @return whether this positional parameter prompts the end user for a value to be entered on the command line
* @since 3.5
*/
boolean interactive() default false;

/**
* Use this attribute to control a user input is echo-ed to the console or not. If {@code echo=true}, a input is echo-ed to the console.
* This attribute is ignored when interactive attribute is false.
* @return whether the user input is echo-ed to the console or not
* @since 4.X
*/
boolean echo() default false;

/**
* Use this attribute to customize the text displayed to the end user for an interactive option.
* This attribute is ignored when interactive attribute is false.
* @return text will be displayed to the end user
* @since 4.X
*/
String prompt() default "";

/** ResourceBundle key for this option. If not specified, (and a ResourceBundle {@linkplain Command#resourceBundle() exists for this command}) an attempt
* is made to find the positional parameter description using {@code paramLabel() + "[" + index() + "]"} as key.
*
Expand Down Expand Up @@ -8217,6 +8249,8 @@ public abstract static class ArgSpec {
// parser fields
private boolean required;
private final boolean interactive;
private final boolean echo;
private final String prompt;
private final String splitRegex;
private final String splitRegexSynopsisLabel;
protected final ITypeInfo typeInfo;
Expand Down Expand Up @@ -8256,6 +8290,8 @@ private <T extends Builder<T>> ArgSpec(Builder<T> builder) {
inherited = builder.inherited;
root = builder.root == null && ScopeType.INHERIT.equals(builder.scopeType) ? this : builder.root;
interactive = builder.interactive;
echo = builder.echo;
prompt = builder.prompt;
initialValue = builder.initialValue;
hasInitialValue = builder.hasInitialValue;
initialValueState = builder.initialValueState;
Expand Down Expand Up @@ -8328,6 +8364,12 @@ public boolean required() {
/** Returns whether this option will prompt the user to enter a value on the command line.
* @see Option#interactive() */
public boolean interactive() { return interactive; }
/** Returns whether the user input is echo-ed to the console or not.
* @see Option#echo() */
public boolean echo() { return echo; }
/** Returns the text displayed to the end user for an interactive option.
* @see Option#prompt() */
public String prompt() { return prompt; }

/** Returns the description of this option or positional parameter, after all variables have been rendered,
* including the {@code ${DEFAULT-VALUE}} and {@code ${COMPLETION-CANDIDATES}} variables.
Expand Down Expand Up @@ -8840,6 +8882,8 @@ abstract static class Builder<T extends Builder<T>> {
private String descriptionKey;
private boolean required;
private boolean interactive;
private boolean echo;
private String prompt;
private String paramLabel;
private boolean hideParamSyntax;
private String splitRegex;
Expand Down Expand Up @@ -8874,6 +8918,8 @@ abstract static class Builder<T extends Builder<T>> {
descriptionKey = original.descriptionKey;
required = original.required;
interactive = original.interactive;
echo = original.echo;
prompt = original.prompt;
paramLabel = original.paramLabel;
hideParamSyntax = original.hideParamSyntax;
splitRegex = original.splitRegex;
Expand Down Expand Up @@ -8920,6 +8966,8 @@ abstract static class Builder<T extends Builder<T>> {

hideParamSyntax = option.hideParamSyntax();
interactive = option.interactive();
echo = option.echo();
prompt = option.prompt();
description = option.description();
descriptionKey = option.descriptionKey();
splitRegex = option.split();
Expand Down Expand Up @@ -8953,6 +9001,8 @@ abstract static class Builder<T extends Builder<T>> {

hideParamSyntax = parameters.hideParamSyntax();
interactive = parameters.interactive();
echo = parameters.echo();
prompt = parameters.prompt();
description = parameters.description();
descriptionKey = parameters.descriptionKey();
splitRegex = parameters.split();
Expand Down Expand Up @@ -8994,6 +9044,12 @@ private static String inferLabel(String label, String fieldName, ITypeInfo typeI
/** Returns whether this option prompts the user to enter a value on the command line.
* @see Option#interactive() */
public boolean interactive() { return interactive; }
/** Returns whether the user input is echo-ed to the console or not.
* @see Option#echo() */
public boolean echo() { return echo; }
/** Returns the text displayed to the end user for an interactive option.
* @see Option#prompt() */
public String prompt() { return prompt; }

/** Returns the description of this option, used when generating the usage documentation.
* @see Option#description() */
Expand Down Expand Up @@ -9121,6 +9177,12 @@ private static String inferLabel(String label, String fieldName, ITypeInfo typeI
/** Sets whether this option prompts the user to enter a value on the command line, and returns this builder. */
public T interactive(boolean interactive) { this.interactive = interactive; return self(); }

/** Sets whether the user input is echo-ed to the console or not. */
public T echo(boolean echo) { this.echo = echo; return self(); }

/** Sets the text displayed to the end user for an interactive option. */
public T prompt(String prompt) { this.prompt = prompt; return self(); }

/** Sets the description of this option, used when generating the usage documentation, and returns this builder.
* @see Option#description() */
public T description(String... description) { this.description = Assert.notNull(description, "description").clone(); return self(); }
Expand Down Expand Up @@ -13302,11 +13364,11 @@ private int applyValueToSingleValuedField(ArgSpec argSpec,
consumed = 0;
}
}
// if argSpec is interactive, we may need to read the password from the console:
// if argSpec is interactive and echo is false, we may need to read the password from the console:
// - if arity = 0 : ALWAYS read from console
// - if arity = 0..1: ONLY read from console if user specified a non-option value
if (argSpec.interactive() && (arity.max == 0 || !optionalValueExists)) {
interactiveValue = readPassword(argSpec);
interactiveValue = readUserInput(argSpec);
consumed = 0;
}
}
Expand All @@ -13326,8 +13388,13 @@ private int applyValueToSingleValuedField(ArgSpec argSpec,
} else {
consumed = 1;
if (interactiveValue != null) {
initValueMessage = "Setting %s to *** (masked interactive value) for %4$s on %5$s%n";
overwriteValueMessage = "Overwriting %s value with *** (masked interactive value) for %s on %5$s%n";
if (argSpec.echo()) {
initValueMessage = "Setting %s to %3$s (interactive value) for %4$s on %5$s%n";
overwriteValueMessage = "Overwriting %s value with %3$s (interactive value) for %s on %5$s%n";
} else {
initValueMessage = "Setting %s to *** (masked interactive value) for %4$s on %5$s%n";
overwriteValueMessage = "Overwriting %s value with *** (masked interactive value) for %s on %5$s%n";
}
}
if (!char[].class.equals(cls) && !char[].class.equals(argSpec.type())) {
if (interactiveValue != null) {
Expand All @@ -13339,7 +13406,7 @@ private int applyValueToSingleValuedField(ArgSpec argSpec,
if (interactiveValue == null) { // setting command line arg to char[] field
newValue = actualValue.toCharArray();
} else {
actualValue = "***"; // mask interactive value
actualValue = getMaskedValue(argSpec, new String(interactiveValue)); // mask interactive value if echo is false
newValue = interactiveValue;
}
}
Expand Down Expand Up @@ -13636,7 +13703,7 @@ private List<Object> consumeArguments(ArgSpec argSpec,
alreadyUnquoted = false;
}
if (argSpec.interactive() && argSpec.arity().max == 0) {
consumed = addPasswordToList(argSpec, result, consumed, argDescription);
consumed = addUserInputToList(argSpec, result, consumed, argDescription);
}
// now process the varargs if any
String fallback = consumed == 0 && argSpec.isOption() && !OptionSpec.DEFAULT_FALLBACK_VALUE.equals(((OptionSpec) argSpec).fallbackValue())
Expand All @@ -13648,7 +13715,7 @@ private List<Object> consumeArguments(ArgSpec argSpec,
for (int i = consumed; consumed < arity.max && !args.isEmpty(); i++) {
if (argSpec.interactive() && argSpec.arity().max == 1 && !varargCanConsumeNextValue(argSpec, args.peek())) {
// if interactive and arity = 0..1, we consume from command line if possible (if next arg not an option or subcommand)
consumed = addPasswordToList(argSpec, result, consumed, argDescription);
consumed = addUserInputToList(argSpec, result, consumed, argDescription);
} else {
if (!varargCanConsumeNextValue(argSpec, args.peek())) { break; }
List<Object> typedValuesAtPosition = new ArrayList<Object>();
Expand Down Expand Up @@ -13693,22 +13760,35 @@ private int consumedCountMap(int i, int initialSize, ArgSpec arg) {
return commandSpec.parser().splitFirst() ? (arg.stringValues().size() - initialSize) / 2 : i;
}

private int addPasswordToList(ArgSpec argSpec, List<Object> result, int consumed, String argDescription) {
char[] password = readPassword(argSpec);
private int addUserInputToList(ArgSpec argSpec, List<Object> result, int consumed, String argDescription) {
char[] input = readUserInput(argSpec);
String inputString = new String(input);
if (tracer.isInfo()) {
tracer.info("Adding *** (masked interactive value) to %s for %s on %s%n", argSpec.toString(), argDescription, argSpec.scopeString());
tracer.info("Adding %s to %s for %s on %s%n",
getLoggableMaskedInteractiveValue(argSpec, inputString), argSpec.toString(), argDescription, argSpec.scopeString());
}
parseResultBuilder.addStringValue(argSpec, "***");
parseResultBuilder.addOriginalStringValue(argSpec, "***");
String maskedValue = getMaskedValue(argSpec, inputString);
parseResultBuilder.addStringValue(argSpec, maskedValue);
parseResultBuilder.addOriginalStringValue(argSpec, maskedValue);
if (!char[].class.equals(argSpec.auxiliaryTypes()[0]) && !char[].class.equals(argSpec.type())) {
Object value = tryConvert(argSpec, consumed, getTypeConverter(argSpec.auxiliaryTypes(), argSpec, 0), new String(password), 0);
Object value = tryConvert(argSpec, consumed, getTypeConverter(argSpec.auxiliaryTypes(), argSpec, 0), new String(input), 0);
result.add(value);
} else {
result.add(password);
result.add(input);
}
consumed++;
return consumed;
}
private String getLoggableMaskedInteractiveValue(ArgSpec argSpec, String input) {
if (argSpec.echo()) {
return input + " (interactive value)";
} else {
return "*** (masked interactive value)";
}
}
private String getMaskedValue(ArgSpec argSpec, String input) {
return argSpec.echo() ? input : "***";
}
private int consumeOneArgument(ArgSpec argSpec,
LookBehind lookBehind,
boolean alreadyUnquoted, Range arity,
Expand Down Expand Up @@ -13941,31 +14021,43 @@ private boolean assertNoMissingParameters(ArgSpec argSpec, Range arity, Stack<St
return true;
}

char[] readPassword(ArgSpec argSpec) {
char[] readUserInput(ArgSpec argSpec) {
String name = argSpec.isOption() ? ((OptionSpec) argSpec).longestName() : "position " + position;
String prompt = String.format("Enter value for %s (%s): ", name, str(argSpec.description(), 0));
if (tracer.isDebug()) {tracer.debug("Reading value for %s from console...%n", name);}
char[] result = readPassword(prompt);
if (tracer.isDebug()) {tracer.debug("User entered %d characters for %s.%n", result.length, name);}
return result;
String prompt = argSpec.prompt() != null && argSpec.prompt().length() != 0 ?
argSpec.prompt() :
String.format("Enter value for %s (%s): ", name, str(argSpec.description(), 0));
try {
if (tracer.isDebug()) {tracer.debug("Reading value for %s from console...%n", name);}
char[] result = argSpec.echo() ? readUserInputWithEchoing(prompt) : readPassword(prompt);
if (tracer.isDebug()) {tracer.debug(createUserInputDebugString(argSpec, result, name));}
return result;
} finally {
interactiveCount++;
}
}
private String createUserInputDebugString(ArgSpec argSpec, char[] result, String name) {
return argSpec.echo() ?
String.format("User entered %s for %s.%n", new String(result), name) :
String.format("User entered %d characters for %s.%n", result.length, name);
}
char[] readPassword(String prompt) {
try {
Object console = System.class.getDeclaredMethod("console").invoke(null);
Method method = Class.forName("java.io.Console").getDeclaredMethod("readPassword", String.class, Object[].class);
return (char[]) method.invoke(console, prompt, new Object[0]);
} catch (Exception e) {
System.out.print(prompt);
InputStreamReader isr = new InputStreamReader(System.in);
BufferedReader in = new BufferedReader(isr);
try {
String password = in.readLine();
return password == null ? new char[0] : password.toCharArray();
} catch (IOException ex2) {
throw new IllegalStateException(ex2);
}
} finally {
interactiveCount++;
return readUserInputWithEchoing(prompt);
}
}
char[] readUserInputWithEchoing(String prompt) {
System.out.print(prompt);
InputStreamReader isr = new InputStreamReader(System.in);
BufferedReader in = new BufferedReader(isr);
try {
String input = in.readLine();
return input == null ? new char[0] : input.toCharArray();
} catch (IOException e) {
throw new IllegalStateException(e);
}
}
int getPosition(ArgSpec arg) {
Expand Down
Loading

0 comments on commit d767f5d

Please sign in to comment.