7

I stumbled upon this question from a while back, 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?

For example, let's say I have a command, import that has a couple options. It could be run in the shell like this:

$ java -jar my-app.jar

> import -f /path/to/file.txt --overwrite
Successfully imported 'file.txt'

> exit

But it would be nice to be able to simply execute and exit, for the sake of building a script that can utilize this function:

$ java -jar my-app.jar import -f /path/to/file.txt --overwrite
Successfully imported 'file.txt'
woemler
  • 7,089
  • 7
  • 48
  • 67

6 Answers6

4

Run it with @my-script, like so:

java -jar my-app.jar @my-script

Where my-script is a file with your commands:

import -f /path/to/file.txt --overwrite
Greg St.Onge
  • 113
  • 8
3

Just to add, I found another way of doing this, doesn't give you the option to run in interactive mode but using your profiles above you can of course swap the configuration. Please note I'm using lombok and jool (just in case anyone copy pastes and gets funny issues!)

Entry

@SpringBootApplication
public class Righter {

    public static void main(String[] args) {
        SpringApplication.run(Righter.class, args);
    }

    @Bean
    public ApplicationRunner shellRunner(Shell shell) {
        return new NonInteractiveShellRunner(shell);
    }

Application runner:

@Order(0)
public class NonInteractiveShellRunner implements ApplicationRunner{

    private final Shell shell;

    public NonInteractiveShellRunner(Shell shell) {
        this.shell = shell;
    }

    @Override
    public void run(ApplicationArguments args) throws Exception {
        shell.run(new CommandInputProvider(args.getSourceArgs()));
    }

    public static class PredefinedInputProvider implements InputProvider{

        private final Input input;
        private boolean commandExecuted = false;

        public PredefinedInputProvider(String[] args) {
            this.input = new PredefinedInput(args);
        }

        @Override
        public Input readInput() {
            if (!commandExecuted){
                commandExecuted=true;
                return input;
            }
            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);
            }
        }

    }

}
Alex
  • 587
  • 11
  • 31
3

A way to add a single command run mode without excluding interactive mode and script mode (Tested on spring-shell-starter::2.0.0.RELEASE).

Create a runner by analogy with ScriptShellApplicationRunner.

// Runs before ScriptShellApplicationRunner and InteractiveShellApplicationRunner
@Order(InteractiveShellApplicationRunner.PRECEDENCE - 200)
public class SingleCommandApplicationRunner implements ApplicationRunner {

    private final Parser parser;
    private final Shell shell;
    private final ConfigurableEnvironment environment;
    private final Set<String> allCommandNames;

    public SingleCommandApplicationRunner(
            Parser parser,
            Shell shell,
            ConfigurableEnvironment environment,
            Set<CustomCommand> customCommands
    ) {
        this.parser = parser;
        this.shell = shell;
        this.environment = environment;
        this.allCommandNames = buildAllCommandNames(customCommands);
    }

    private Set<String> buildAllCommandNames(Collection<CustomCommand> customCommands) {
        final Set<String> result = new HashSet<>();
        customCommands.stream().map(CustomCommand::keys).flatMap(Collection::stream).forEach(result::add);
        // default spring shell commands
        result.addAll(asList("clear", "exit", "quit", "help", "script", "stacktrace"));
        return result;
    }

    @Override
    public void run(ApplicationArguments args) throws Exception {
        final boolean singleCommand = haveCommand(args.getSourceArgs());
        if (singleCommand) {
            InteractiveShellApplicationRunner.disable(environment);
            final String fullArgs = join(" ", args.getSourceArgs());
            try (Reader reader = new StringReader(fullArgs);
                 FileInputProvider inputProvider = new FileInputProvider(reader, parser)) {
                shell.run(inputProvider);
            }
        }
    }

    private boolean haveCommand(String... args) {
        for (String arg : args) {
            if (allCommandNames.contains(arg)) {
                return true;
            }
        }
        return false;
    }

}

Register the runner as bean.

@Configuration
class ContextConfiguration {

    @Autowired
    private Shell shell;

    @Bean
    SingleCommandApplicationRunner singleCommandApplicationRunner(
            Parser parser,
            ConfigurableEnvironment environment,
            Set<CustomCommand> customCommands
    ) {
        return new SingleCommandApplicationRunner(parser, shell, environment, customCommands);
    }

}

So that the runner starts only when the command is sent, we create an interface.

public interface CustomCommand {

    Collection<String> keys();

}

Implement CustomCommand interface in each commands.

@ShellComponent
@RequiredArgsConstructor
class MyCommand implements CustomCommand {

    private static final String KEY = "my-command";

    @Override
    public Collection<String> keys() {
        return singletonList(KEY);
    }

    @ShellMethod(key = KEY, value = "My custom command.")
    public AttributedString version() {
        return "Hello, single command mode!";
    }

}

Done!

Run in interactive mode:

java -jar myApp.jar

// 2021-01-14 19:28:16.911 INFO 67313 --- [main] com.nao4j.example.Application: Starting Application v1.0.0 using Java 1.8.0_275 on Apple-MacBook-Pro-15.local with PID 67313 (/Users/nao4j/example/target/myApp.jar started by nao4j in /Users/nao4j/example/target)
// 2021-01-14 19:28:16.916 INFO 67313 --- [main] com.nao4j.example.Application: No active profile set, falling back to default profiles: default
// 2021-01-14 19:28:18.227 INFO 67313 --- [main] com.nao4j.example.Application: Started Application in 2.179 seconds (JVM running for 2.796)
// shell:>my-command
// Hello, single command mode!

Run script from file script.txt (contains text "my-command"):

java -jar myApp.jar @script.txt

// 2021-01-14 19:28:16.911 INFO 67313 --- [main] com.nao4j.example.Application: Starting Application v1.0.0 using Java 1.8.0_275 on Apple-MacBook-Pro-15.local with PID 67313 (/Users/nao4j/example/target/myApp.jar started by nao4j in /Users/nao4j/example/target)
// 2021-01-14 19:28:16.916 INFO 67313 --- [main] com.nao4j.example.Application: No active profile set, falling back to default profiles: default
// 2021-01-14 19:28:18.227 INFO 67313 --- [main] com.nao4j.example.Application: Started Application in 2.179 seconds (JVM running for 2.796)
// Hello, single command mode!

Run in single command mode:

java -jar myApp.jar my-command

// 2021-01-14 19:28:16.911 INFO 67313 --- [main] com.nao4j.example.Application: Starting Application v1.0.0 using Java 1.8.0_275 on Apple-MacBook-Pro-15.local with PID 67313 (/Users/nao4j/example/target/myApp.jar started by nao4j in /Users/nao4j/example/target)
// 2021-01-14 19:28:16.916 INFO 67313 --- [main] com.nao4j.example.Application: No active profile set, falling back to default profiles: default
// 2021-01-14 19:28:18.227 INFO 67313 --- [main] com.nao4j.example.Application: Started Application in 2.179 seconds (JVM running for 2.796)
// Hello, single command mode!
nao4j
  • 31
  • 2
2

In addition to Alex answers, here is the simpler version of NonInteractiveApplicationRunner I made.

@Component
@Order(InteractiveShellApplicationRunner.PRECEDENCE - 100)
class NonInteractiveApplicationRunner implements ApplicationRunner {

    private final Shell shell;
    private final ConfigurableEnvironment environment;

    public NonInteractiveApplicationRunner(Shell shell, ConfigurableEnvironment environment) {
        this.shell = shell;
        this.environment = environment;
    }

    @Override
    public void run(ApplicationArguments args) {
        if (args.getSourceArgs().length > 0) {
            InteractiveShellApplicationRunner.disable(environment);
            var input = String.join(" ", args.getSourceArgs());
            shell.evaluate(() -> input);
            shell.evaluate(() -> "exit");
        }
    }
}

Using @Component, we don't need to add bean method. In addition, using shell.evaluate() method looks much simpler compare to shell.run(...).

lalalili
  • 21
  • 1
1

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.

@Parameters
public class ImportParameters {

  @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 */
}

public class ImportCommandExecutor {

  public void run(ImportParameters params) throws Exception {
    // import logic goes here
  }

}

/* Handles interactive shell command execution */
@ShellComponent
public class JLineInputExecutor {

  // All command executors are injected here
  @Autowired private ImportCommandExecutor importExecutor;
  ...

  @ShellMethod(key = "import", value = "Imports the a file of a specified type.")
  public String importCommand(@ShellOption(optOut = true) ImportParameters params) throws Exception {
    importCommandExecutor.run(params);
  }

  ...

}

/* Handles one-off command execution */
public class JCommanderInputExecutor implements ApplicationRunner {

  // All command executors are injected here
  @Autowired private ImportCommandExecutor importExecutor;
  ...

  @Override
  public void run(ApplicationArguments args) throws Exception {

    // Create all of the JCommander argument handler objects
    BaseParameters baseParameters = new BaseParameters();
    ImportParameters importParameters = new ImportParameters();
    ...

    JCommander jc = newBuilder().
      .acceptUnknownOptions(true)
      .addObject(baseParameters)
      .addCommand("import", importParameters)
      ...
      .build();

    jc.parse(args);
    String mainCommand = jc.getParsedCommand();

    if ("import".equals(mainCommand)){
      importExecutor.run(importParameters);
    } else if (...) {
      ...
    }  

  }
}

@Configuration
@Profile({"CLI"})
public class CommandLineInterfaceConfiguration {

  // All of my command executors are defined as beans here, as well as other required configurations for both modes of execution 
  @Bean
  public ImportCommandExecutor importExecutor (){
    return new ImportCommandExecutor();
  }
  ...

}

@Configuration
@Profile({"SINGLE_COMMAND"})
public class SingleCommandConfiguration {

  @Bean
  public JCommanderInputExecutor commandLineInputExecutor(){
    return new JCommanderInputExecutor();
  }

}

@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 Arrays.asList(args).contains("-X") ? new String[]{"CLI", "SINGLE_COMMAND"} : new String[]{"CLI"};
  }

}

So now I can trigger the interactive client by simply running my executable JAR:

java -jar app.jar
> import -f /path/to/file.txt -t GENE -o
> quit()

Or, if I pass the '-X' argument on the command line, the application will execute and then exit:

java -jar app.jar -X import -f /path/to/file.txt -t GENE -o
woemler
  • 7,089
  • 7
  • 48
  • 67
  • For me this doesn't work, it doesn't exit after I launch the command with the parameters, with Spring Shell 2.0.0.RELEASE :( – oblio Jan 25 '19 at 14:58
  • It gets stuck in the shell and after I exit it it print the "Importing.." thing, so only then does it launch the JCommander step. – oblio Jan 25 '19 at 15:23
  • 1
    @oblio: The solution I finally implemented was a little different than my initial answer. I'll update it with my working code. – woemler Jan 28 '19 at 14:53
  • Oh, awesome, I'll try it out. I'll probably reverse the behavior, I think it's a bit nicer if you trigger the interactive mode explicitly (i.e. I'd use -i as a parameter that triggers shell mode, otherwise you get the non-interactive execution). – oblio Jan 29 '19 at 14:27
0

In linux works this way too:

echo "import -f /path/to/file.txt --overwrite" | java -jar my-app.jar

despite the fact that it ends with a failure; it happens only after the command is successfully executed.

Adrian
  • 3,321
  • 2
  • 29
  • 46