spring-projects / spring-shell

Spring based shell
http://projects.spring.io/spring-shell/
Apache License 2.0
725 stars 394 forks source link

Executing a single command and exitting #159

Closed woemler closed 6 years ago

woemler commented 7 years ago

I stumbled upon this question on Stack Overflow, explaining how to get a Spring Shell application to exit after calling it from the command line with a single command. However, testing this in 2.0.0 with Spring Boot, it does not seem to be the case any more that invoking the JAR with command arguments will execute that command and then exit. The shell just starts as normal without executing the supplied command. Is it still possible to do this? If not, would it be possible to pass the arguments from the JAR execution to Spring Shell and then trigger an exit after execution?

ericbottard commented 7 years ago

Have a look at https://docs.spring.io/spring-shell/docs/2.0.0.BUILD-SNAPSHOT/reference/htmlsingle/#_customizing_command_line_options_behavior to find out how to either:

Hope that helps

woemler commented 7 years ago

Thanks for the prompt response. The second option sounds promising, I will pursue that. Is there a reason why the single command execution was taken out of the project in version 2.0?

ericbottard commented 7 years ago

No problem.

The previous approach was removed because accepting any program argument and assuming it is a command is too fragile, especially in the era of Spring Boot which makes using @ConfigurationProperties so easy (so users will typically pass --some.prefix.key=value

Another approach which may work (but I haven't entirely tested yet) is to pipe your command into the shell, ie echo help | java -jar sping-shell.jar

ericbottard commented 6 years ago

Is it ok if I close this issue, or do you think there is more that needs to be done here?

woemler commented 6 years ago

FYI, for anybody else looking to do the same thing, I found a nice little work-around. Rather than creating an ApplicationRunner that mimics the v1 behavior (which is tricky, since JLineInputProvider is a private class), I created one that is optionally loaded, based on active Spring profile. I used JCommander to define the CLI parameters, allowing me to have identical commands for the interactive shell and the one-off executions. Running the Spring Boot JAR with no args triggers the interactive shell. Running it with arguments triggers the one-and-done execution.

public class JCommanderImportCommands {

  public static enum DataType { SAMPLE, GENE, DATA }

  @Parameter(names = { "-f", "--file" }, required = true, description = "Data file")
  private File file;

  @Parameter(names = { "-t", "--type" }, required = true, description = "Data type")
  private DataType dataType;

  @Parameter(names = { "-o", "--overwrite" }, description = "Flag to overwrite file if it exists")
  private Boolean overwrite = false;

  /* getters and setters */
}

@ShellComponent
public class ImportCommands {

  @ShellMethod(key = "import", value = "Imports the a file of a specified type.")
  public String jCommanderFileImport(@ShellOption(optOut = true) JCommanderImportCommands commands){
    System.out.println(String.format("Importing file=%s  dataType=%s  overwrite=%s", 
        commands.getFile(), commands.getDataType(), commands.getOverwrite()));
    return commands.getFile().getAbsolutePath();
  }

}

public class JCommanderShellRunner implements ApplicationRunner {

  @Override
  public void run(ApplicationArguments args) throws Exception {
    System.out.println(args.getNonOptionArgs());
    JCommanderImportCommands importCommands = new JCommanderImportCommands();
    JCommander.newBuilder()
        .addCommand("import", importCommands)
        .acceptUnknownOptions(true)
        .build()
        .parse(args.getSourceArgs());
    System.out.println(String.format("Importing file=%s  dataType=%s  overwrite=%s",
        importCommands.getFile(), importCommands.getDataType(), importCommands.getOverwrite()));
  }
}

@Configuration
@Profile({"jc"})
public class ShellConfig {

  @Bean
  public JCommanderShellRunner shellRunner(){
    return new JCommanderShellRunner();
  }

}

@SpringBootApplication
public class Application {

  public static void main(String[] args) throws IOException {
    String[] profiles = getActiveProfiles(args);
    SpringApplicationBuilder builder = new SpringApplicationBuilder(Application.class);
    builder.bannerMode((Mode.LOG));
    builder.web(false);
    builder.profiles(profiles);
    System.out.println(String.format("Command line arguments: %s  Profiles: %s",
        Arrays.asList(args), Arrays.asList(profiles)));
    builder.run(args);
  }

  private static String[] getActiveProfiles(String[] args){
    return args.length > 0 ? new String[]{"jc"} : new String[]{};
  }

}

Source code here.

Lovett1991 commented 6 years ago

I had issues piping the command the command in (I think possibly because the input reader would get null on expecting the next command). The command would run fine, but the would get a bad exit.

I have the following which works for me (I am using jool and lombok) as above you can easily use profiles to inject this bean or use the interactive shell. pretty much copy paste of DefaultShellApplicationRunner method except it wraps the JLineInput. To run I just use:

java -jar app.jar <<< $(echo command)

@Order(0)
public class SingleCommandShellRunner extends DefaultShellApplicationRunner{

    private final LineReader lineReader;
    private final PromptProvider promptProvider;
    private final Parser parser;
    private final Shell shell;

    public SingleCommandShellRunner(LineReader lineReader, PromptProvider promptProvider, Parser parser, Shell shell) {
        super(lineReader, promptProvider, parser, shell);
        this.lineReader = lineReader;
        this.promptProvider = promptProvider;
        this.parser = parser;
        this.shell = shell;
    }

    @Override
    public void run(ApplicationArguments args) throws Exception {
        List<File> scriptsToRun = args.getNonOptionArgs().stream()
                .filter(s -> s.startsWith("@"))
                .map(s -> new File(s.substring(1)))
                .collect(Collectors.toList());

        if (scriptsToRun.isEmpty()) {
            InputProvider inputProvider = new SingleCommandInputProvider(new JLineInputProvider(lineReader, promptProvider));
            shell.run(inputProvider);
        } else {
            for (File file : scriptsToRun) {
                try (Reader reader = new FileReader(file); FileInputProvider inputProvider = new FileInputProvider(reader, parser)) {
                    shell.run(inputProvider);
                }
            }
        }
    }

    public static class SingleCommandInputProvider implements InputProvider{

        private boolean hasCommandRun = false;
        private final InputProvider inputProvider;

        public SingleCommandInputProvider(InputProvider inputProvider) {
            this.inputProvider = inputProvider;
        }

        @Override
        public Input readInput() {
            if (!hasCommandRun) {
                hasCommandRun = true;
                return inputProvider.readInput();
            }
            return new PredefinedInput(new String[]{"exit"});
        }

        @AllArgsConstructor
        private static class PredefinedInput implements Input{

            private final String[] args;

            @Override
            public String rawText() {
                return Seq.of(args).toString(" ");
            }

            @Override
            public List<String> words(){
                return Arrays.asList(args);
            }
        }

    }

}
bigbasti commented 6 years ago

Hello @Lovett1991, i'm not really sure how to use your solution. could you elaborate a little on how to integrate this class? I implemented this class and added some System.out commands to it. But it seems like it is never being used. At least i see no different behavior when i call java -jar script.jar <<< $(echo command param) so i still get the exception after the command has been executed successful :

java.lang.IllegalStateException: Failed to execute ApplicationRunner
    at org.springframework.boot.SpringApplication.callRunner(SpringApplication.java:726)
    at org.springframework.boot.SpringApplication.callRunners(SpringApplication.java:713)
    at org.springframework.boot.SpringApplication.afterRefresh(SpringApplication.java:703)
    at org.springframework.boot.SpringApplication.run(SpringApplication.java:304)
    at org.springframework.boot.SpringApplication.run(SpringApplication.java:1118)
    at org.springframework.boot.SpringApplication.run(SpringApplication.java:1107)
    at c.t.r.CliApplication.main(CliApplication.java:74)
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.lang.reflect.Method.invoke(Method.java:498)
    at org.springframework.boot.loader.MainMethodRunner.run(MainMethodRunner.java:48)
    at org.springframework.boot.loader.Launcher.launch(Launcher.java:87)
    at org.springframework.boot.loader.Launcher.launch(Launcher.java:50)
    at org.springframework.boot.loader.JarLauncher.main(JarLauncher.java:51)
Caused by: java.lang.NullPointerException: null
    at org.springframework.shell.jline.ParsedLineInput.words(ParsedLineInput.java:44)
    at org.springframework.shell.Shell.noInput(Shell.java:194)
    at org.springframework.shell.Shell.evaluate(Shell.java:150)
    at org.springframework.shell.Shell.run(Shell.java:133)
    at org.springframework.shell.jline.DefaultShellApplicationRunner.run(DefaultShellApplicationRunner.java:80)
    at org.springframework.boot.SpringApplication.callRunner(SpringApplication.java:723)
    ... 14 common frames omitted
Lovett1991 commented 6 years ago

In your config you'll need to define the bean:

@Bean
public ApplicationRunner shellRunner(LineReader lineReader, PromptProvider promptProvider, Parser parser, Shell shell) { 
  return new SingleCommandShellRunner(lineReader, promptProvider, parser, shell);
}
bigbasti commented 6 years ago

That was the missing piece! Thanks!

LuboVarga commented 6 years ago

What about to exit cleanly in echo solution and do not code more? echo "help\nexit" | java -jar sping-shell.jar It works OK for me.

ptitvert commented 5 years ago

@ericbottard I am not sure to understand the reason why "The previous approach was removed because accepting any program argument and assuming it is a command is too fragile". If you can use the "pipe" method, or "using @ConfigurationProperties so easy (so users will typically pass --some.prefix.key=value)" then it would be also "fragile", the same fragility as to use command line arguments. Or am I missing something? I ask that, because there is a obvious need for that, and the choice to remove it doesn't make sense for me, according to the explanation, especially if the other other options seems to be as fragile as the original method.