1   /*
2    * SPDX-FileCopyrightText: none
3    * SPDX-License-Identifier: CC0-1.0
4    */
5   
6   package gov.nist.secauto.metaschema.cli.processor;
7   
8   import static org.fusesource.jansi.Ansi.ansi;
9   
10  import gov.nist.secauto.metaschema.cli.processor.command.CommandExecutionException;
11  import gov.nist.secauto.metaschema.cli.processor.command.CommandService;
12  import gov.nist.secauto.metaschema.cli.processor.command.ExtraArgument;
13  import gov.nist.secauto.metaschema.cli.processor.command.ICommand;
14  import gov.nist.secauto.metaschema.cli.processor.command.ICommandExecutor;
15  import gov.nist.secauto.metaschema.core.util.AutoCloser;
16  import gov.nist.secauto.metaschema.core.util.CollectionUtil;
17  import gov.nist.secauto.metaschema.core.util.IVersionInfo;
18  import gov.nist.secauto.metaschema.core.util.ObjectUtils;
19  
20  import org.apache.commons.cli.CommandLine;
21  import org.apache.commons.cli.CommandLineParser;
22  import org.apache.commons.cli.DefaultParser;
23  import org.apache.commons.cli.HelpFormatter;
24  import org.apache.commons.cli.Option;
25  import org.apache.commons.cli.Options;
26  import org.apache.commons.cli.ParseException;
27  import org.apache.logging.log4j.Level;
28  import org.apache.logging.log4j.LogManager;
29  import org.apache.logging.log4j.Logger;
30  import org.apache.logging.log4j.core.LoggerContext;
31  import org.apache.logging.log4j.core.config.Configuration;
32  import org.apache.logging.log4j.core.config.LoggerConfig;
33  import org.fusesource.jansi.AnsiConsole;
34  import org.fusesource.jansi.AnsiPrintStream;
35  
36  import java.io.PrintStream;
37  import java.io.PrintWriter;
38  import java.nio.charset.StandardCharsets;
39  import java.util.Arrays;
40  import java.util.Collection;
41  import java.util.LinkedList;
42  import java.util.List;
43  import java.util.Map;
44  import java.util.concurrent.atomic.AtomicBoolean;
45  import java.util.function.Function;
46  import java.util.stream.Collectors;
47  
48  import edu.umd.cs.findbugs.annotations.NonNull;
49  import edu.umd.cs.findbugs.annotations.Nullable;
50  import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
51  
52  /**
53   * Processes command line arguments and dispatches called commands.
54   * <p>
55   * This implementation make significant use of the command pattern to support a
56   * delegation chain of commands based on implementations of {@link ICommand}.
57   */
58  @SuppressWarnings("PMD.CouplingBetweenObjects")
59  public class CLIProcessor {
60    private static final Logger LOGGER = LogManager.getLogger(CLIProcessor.class);
61  
62    /**
63     * This option indicates if the help should be shown.
64     */
65    @NonNull
66    public static final Option HELP_OPTION = ObjectUtils.notNull(Option.builder("h")
67        .longOpt("help")
68        .desc("display this help message")
69        .get());
70    /**
71     * This option indicates if colorized output should be disabled.
72     */
73    @NonNull
74    public static final Option NO_COLOR_OPTION = ObjectUtils.notNull(Option.builder()
75        .longOpt("no-color")
76        .desc("do not colorize output")
77        .get());
78    /**
79     * This option indicates if non-errors should be suppressed.
80     */
81    @NonNull
82    public static final Option QUIET_OPTION = ObjectUtils.notNull(Option.builder("q")
83        .longOpt("quiet")
84        .desc("minimize output to include only errors")
85        .get());
86    /**
87     * This option indicates if a strack trace should be shown for an error
88     * {@link ExitStatus}.
89     */
90    @NonNull
91    public static final Option SHOW_STACK_TRACE_OPTION = ObjectUtils.notNull(Option.builder()
92        .longOpt("show-stack-trace")
93        .desc("display the stack trace associated with an error")
94        .get());
95    /**
96     * This option indicates if the version information should be shown.
97     */
98    @NonNull
99    public static final Option VERSION_OPTION = ObjectUtils.notNull(Option.builder()
100       .longOpt("version")
101       .desc("display the application version")
102       .get());
103 
104   @NonNull
105   private static final List<Option> OPTIONS = ObjectUtils.notNull(List.of(
106       HELP_OPTION,
107       NO_COLOR_OPTION,
108       QUIET_OPTION,
109       SHOW_STACK_TRACE_OPTION,
110       VERSION_OPTION));
111 
112   /**
113    * Used to identify the version info for the command.
114    */
115   public static final String COMMAND_VERSION = "http://csrc.nist.gov/ns/metaschema-java/cli/command-version";
116 
117   @NonNull
118   private final List<ICommand> commands = new LinkedList<>();
119   @NonNull
120   private final String exec;
121   @NonNull
122   private final Map<String, IVersionInfo> versionInfos;
123   @NonNull
124   private final PrintStream outputStream;
125 
126   /**
127    * The main entry point for command execution.
128    *
129    * @param args
130    *          the command line arguments to process
131    */
132   public static void main(String... args) {
133     System.setProperty("java.util.logging.manager", "org.apache.logging.log4j.jul.LogManager");
134     CLIProcessor processor = new CLIProcessor("metaschema-cli");
135 
136     CommandService.getInstance().getCommands().stream().forEach(command -> {
137       assert command != null;
138       processor.addCommandHandler(command);
139     });
140     System.exit(processor.process(args).getExitCode().getStatusCode());
141   }
142 
143   /**
144    * The main entry point for CLI processing.
145    * <p>
146    * This uses the build-in version information.
147    *
148    * @param args
149    *          the command line arguments
150    */
151   public CLIProcessor(@NonNull String args) {
152     this(args, CollectionUtil.singletonMap(COMMAND_VERSION, new ProcessorVersion()));
153   }
154 
155   /**
156    * The main entry point for CLI processing.
157    * <p>
158    * This uses the provided version information.
159    *
160    * @param exec
161    *          the command name
162    * @param versionInfos
163    *          the version info to display when the version option is provided
164    */
165   public CLIProcessor(@NonNull String exec, @NonNull Map<String, IVersionInfo> versionInfos) {
166     this(exec, versionInfos, null);
167   }
168 
169   /**
170    * The main entry point for CLI processing.
171    * <p>
172    * This constructor allows specifying a custom output stream for testing
173    * purposes.
174    *
175    * @param exec
176    *          the command name
177    * @param versionInfos
178    *          the version info to display when the version option is provided
179    * @param outputStream
180    *          the output stream to write to, or {@code null} to use the default
181    *          console
182    */
183   public CLIProcessor(@NonNull String exec, @NonNull Map<String, IVersionInfo> versionInfos,
184       @Nullable PrintStream outputStream) {
185     this.exec = exec;
186     this.versionInfos = versionInfos;
187     if (outputStream == null) {
188       AnsiConsole.systemInstall();
189       this.outputStream = ObjectUtils.notNull(AnsiConsole.out());
190     } else {
191       this.outputStream = outputStream;
192     }
193   }
194 
195   /**
196    * Gets the command used to execute for use in help text.
197    *
198    * @return the command name
199    */
200   @NonNull
201   public String getExec() {
202     return exec;
203   }
204 
205   /**
206    * Retrieve the version information for this application.
207    *
208    * @return the versionInfo
209    */
210   @NonNull
211   public Map<String, IVersionInfo> getVersionInfos() {
212     return versionInfos;
213   }
214 
215   /**
216    * Register a new command handler.
217    *
218    * @param handler
219    *          the command handler to register
220    */
221   public void addCommandHandler(@NonNull ICommand handler) {
222     commands.add(handler);
223   }
224 
225   /**
226    * Process a set of CLIProcessor arguments.
227    * <p>
228    * process().getExitCode().getStatusCode()
229    *
230    * @param args
231    *          the arguments to process
232    * @return the exit status
233    */
234   @NonNull
235   public ExitStatus process(String... args) {
236     return parseCommand(args);
237   }
238 
239   @NonNull
240   private ExitStatus parseCommand(String... args) {
241     List<String> commandArgs = Arrays.asList(args);
242     assert commandArgs != null;
243     CallingContext callingContext = new CallingContext(commandArgs);
244 
245     if (LOGGER.isDebugEnabled()) {
246       String commandChain = callingContext.getCalledCommands().stream()
247           .map(ICommand::getName)
248           .collect(Collectors.joining(" -> "));
249       LOGGER.debug("Processing command chain: {}", commandChain);
250     }
251 
252     ExitStatus status;
253     // the first two arguments should be the <command> and <operation>, where <type>
254     // is the object type
255     // the <operation> is performed against.
256     if (commandArgs.isEmpty()) {
257       status = ExitCode.INVALID_COMMAND.exit();
258       callingContext.showHelp();
259     } else {
260       status = callingContext.processCommand();
261     }
262     return status;
263   }
264 
265   /**
266    * Get the root-level commands.
267    *
268    * @return the list of commands
269    */
270   @NonNull
271   protected final List<ICommand> getTopLevelCommands() {
272     return CollectionUtil.unmodifiableList(commands);
273   }
274 
275   /**
276    * Get the root-level commands, mapped from name to command.
277    *
278    * @return the map of command names to command
279    */
280   @NonNull
281   protected final Map<String, ICommand> getTopLevelCommandsByName() {
282     return ObjectUtils.notNull(getTopLevelCommands()
283         .stream()
284         .collect(Collectors.toUnmodifiableMap(ICommand::getName, Function.identity())));
285   }
286 
287   private static void handleNoColor() {
288     System.setProperty(AnsiConsole.JANSI_MODE, AnsiConsole.JANSI_MODE_STRIP);
289     AnsiConsole.systemUninstall();
290   }
291 
292   /**
293    * Configure the logger to only report errors.
294    */
295   public static void handleQuiet() {
296     LoggerContext ctx = (LoggerContext) LogManager.getContext(false); // NOPMD not closable here
297     Configuration config = ctx.getConfiguration();
298     LoggerConfig loggerConfig = config.getLoggerConfig(LogManager.ROOT_LOGGER_NAME);
299     Level oldLevel = loggerConfig.getLevel();
300     if (oldLevel.isLessSpecificThan(Level.ERROR)) {
301       loggerConfig.setLevel(Level.ERROR);
302       ctx.updateLoggers();
303     }
304   }
305 
306   /**
307    * Output version information.
308    */
309   protected void showVersion() {
310     getVersionInfos().values().stream().forEach(info -> {
311       outputStream.println(ansi()
312           .bold().a(info.getName()).boldOff()
313           .a(" ")
314           .bold().a(info.getVersion()).boldOff()
315           .a(" built at ")
316           .bold().a(info.getBuildTimestamp()).boldOff()
317           .a(" from branch ")
318           .bold().a(info.getGitBranch()).boldOff()
319           .a(" (")
320           .bold().a(info.getGitCommit()).boldOff()
321           .a(") at ")
322           .bold().a(info.getGitOriginUrl()).boldOff()
323           .reset());
324     });
325     outputStream.flush();
326   }
327 
328   /**
329    * Records information about the command line options and called command
330    * hierarchy.
331    */
332   @SuppressWarnings("PMD.GodClass")
333   public final class CallingContext {
334     @NonNull
335     private final List<Option> options;
336     @NonNull
337     private final List<ICommand> calledCommands;
338     @Nullable
339     private final ICommand targetCommand;
340     @NonNull
341     private final List<String> extraArgs;
342 
343     @SuppressFBWarnings(value = "CT_CONSTRUCTOR_THROW", justification = "Use of final fields")
344     private CallingContext(@NonNull List<String> args) {
345       @SuppressWarnings("PMD.LooseCoupling") // needed to support getLast
346       LinkedList<ICommand> calledCommands = new LinkedList<>();
347       List<Option> options = new LinkedList<>(OPTIONS);
348       List<String> extraArgs = new LinkedList<>();
349 
350       AtomicBoolean endArgs = new AtomicBoolean();
351       args.forEach(arg -> {
352         if (endArgs.get() || arg.startsWith("-")) {
353           extraArgs.add(arg);
354         } else if ("--".equals(arg)) {
355           endArgs.set(true);
356         } else {
357           ICommand command = calledCommands.isEmpty()
358               ? getTopLevelCommandsByName().get(arg)
359               : calledCommands.getLast().getSubCommandByName(arg);
360 
361           if (command == null) {
362             extraArgs.add(arg);
363             endArgs.set(true);
364           } else {
365             calledCommands.add(command);
366             options.addAll(command.gatherOptions());
367           }
368         }
369       });
370 
371       this.calledCommands = CollectionUtil.unmodifiableList(calledCommands);
372       this.targetCommand = calledCommands.peekLast();
373       this.options = CollectionUtil.unmodifiableList(options);
374       this.extraArgs = CollectionUtil.unmodifiableList(extraArgs);
375     }
376 
377     /**
378      * Get the command line processor instance that generated this calling context.
379      *
380      * @return the instance
381      */
382     @NonNull
383     public CLIProcessor getCLIProcessor() {
384       return CLIProcessor.this;
385     }
386 
387     /**
388      * Get the command that was triggered by the CLI arguments.
389      *
390      * @return the command or {@code null} if no command was triggered
391      */
392     @Nullable
393     public ICommand getTargetCommand() {
394       return targetCommand;
395     }
396 
397     /**
398      * Get the options that are in scope for the current command context.
399      *
400      * @return the list of options
401      */
402     @NonNull
403     private List<Option> getOptionsList() {
404       return options;
405     }
406 
407     @NonNull
408     private List<ICommand> getCalledCommands() {
409       return calledCommands;
410     }
411 
412     /**
413      * Get any left over arguments that were not consumed by CLI options.
414      *
415      * @return the list of remaining arguments, which may be empty
416      */
417     @NonNull
418     private List<String> getExtraArgs() {
419       return extraArgs;
420     }
421 
422     /**
423      * Get the collections of in scope options as an options group.
424      *
425      * @return the options group
426      */
427     private Options toOptions() {
428       Options retval = new Options();
429       for (Option option : getOptionsList()) {
430         retval.addOption(option);
431       }
432       return retval;
433     }
434 
435     /**
436      * Process the command identified by the CLI arguments.
437      *
438      * @return the result of processing the command
439      */
440     @SuppressWarnings({
441         "PMD.OnlyOneReturn",
442         "PMD.NPathComplexity",
443         "PMD.CyclomaticComplexity"
444     })
445     @NonNull
446     public ExitStatus processCommand() {
447       // TODO: Consider refactoring as follows to reduce complexity:
448       // - Extract the parsing logic for each phase into separate methods (e.g.,
449       // parsePhaseOneOptions, parsePhaseTwoOptions).
450       // - Encapsulate error handling into dedicated methods.
451       // - Separate command execution logic into its own method if possible.
452       CommandLineParser parser = new DefaultParser();
453 
454       // this uses a three phase approach where:
455       // phase 1: checks if help or version are used
456       // phase 2: parse and validate arguments
457       // phase 3: executes the command
458 
459       // phase 1
460       CommandLine cmdLine;
461       try {
462         Options phase1Options = new Options();
463         phase1Options.addOption(HELP_OPTION);
464         phase1Options.addOption(VERSION_OPTION);
465 
466         cmdLine = ObjectUtils.notNull(parser.parse(phase1Options, getExtraArgs().toArray(new String[0]), true));
467       } catch (ParseException ex) {
468         String msg = ex.getMessage();
469         assert msg != null;
470         return handleInvalidCommand(msg);
471       }
472 
473       if (cmdLine.hasOption(VERSION_OPTION)) {
474         showVersion();
475         return ExitCode.OK.exit();
476       }
477       if (cmdLine.hasOption(HELP_OPTION)) {
478         showHelp();
479         return ExitCode.OK.exit();
480       }
481 
482       // phase 2
483       try {
484         cmdLine = ObjectUtils.notNull(parser.parse(toOptions(), getExtraArgs().toArray(new String[0])));
485       } catch (ParseException ex) {
486         String msg = ex.getMessage();
487         assert msg != null;
488         return handleInvalidCommand(msg);
489       }
490 
491       ICommand targetCommand = getTargetCommand();
492       if (targetCommand != null) {
493         try {
494           targetCommand.validateExtraArguments(this, cmdLine);
495         } catch (InvalidArgumentException ex) {
496           return handleError(
497               ExitCode.INVALID_ARGUMENTS.exitMessage(ex.getLocalizedMessage()),
498               cmdLine,
499               true);
500         }
501       }
502 
503       for (ICommand cmd : getCalledCommands()) {
504         try {
505           cmd.validateOptions(this, cmdLine);
506         } catch (InvalidArgumentException ex) {
507           String msg = ex.getMessage();
508           assert msg != null;
509           return handleInvalidCommand(msg);
510         }
511       }
512 
513       // phase 3
514       if (cmdLine.hasOption(NO_COLOR_OPTION)) {
515         handleNoColor();
516       }
517 
518       if (cmdLine.hasOption(QUIET_OPTION)) {
519         handleQuiet();
520       }
521       return invokeCommand(cmdLine);
522     }
523 
524     /**
525      * Directly execute the logic associated with the command.
526      *
527      * @param cmdLine
528      *          the command line information
529      * @return the result of executing the command
530      */
531     @SuppressWarnings({
532         "PMD.OnlyOneReturn", // readability
533         "PMD.AvoidCatchingGenericException" // needed here
534     })
535     @NonNull
536     private ExitStatus invokeCommand(@NonNull CommandLine cmdLine) {
537       ExitStatus retval;
538       try {
539         ICommand targetCommand = getTargetCommand();
540         if (targetCommand == null) {
541           retval = ExitCode.INVALID_COMMAND.exit();
542         } else {
543           ICommandExecutor executor = targetCommand.newExecutor(this, cmdLine);
544           try {
545             executor.execute();
546             retval = ExitCode.OK.exit();
547           } catch (CommandExecutionException ex) {
548             retval = ex.toExitStatus();
549           } catch (RuntimeException ex) {
550             retval = ExitCode.RUNTIME_ERROR
551                 .exitMessage("Unexpected error occured: " + ex.getLocalizedMessage())
552                 .withThrowable(ex);
553           }
554         }
555       } catch (RuntimeException ex) {
556         retval = ExitCode.RUNTIME_ERROR
557             .exitMessage(String.format("An uncaught runtime error occurred. %s", ex.getLocalizedMessage()))
558             .withThrowable(ex);
559       }
560 
561       if (!ExitCode.OK.equals(retval.getExitCode())) {
562         retval.generateMessage(cmdLine.hasOption(SHOW_STACK_TRACE_OPTION));
563 
564         if (ExitCode.INVALID_COMMAND.equals(retval.getExitCode())) {
565           showHelp();
566         }
567       }
568       return retval;
569     }
570 
571     /**
572      * Handle an error that occurred while executing the command.
573      *
574      * @param exitStatus
575      *          the execution result
576      * @param cmdLine
577      *          the command line information
578      * @param showHelp
579      *          if {@code true} show the help information
580      * @return the resulting exit status
581      */
582     @NonNull
583     public ExitStatus handleError(
584         @NonNull ExitStatus exitStatus,
585         @NonNull CommandLine cmdLine,
586         boolean showHelp) {
587       exitStatus.generateMessage(cmdLine.hasOption(SHOW_STACK_TRACE_OPTION));
588       if (showHelp) {
589         showHelp();
590       }
591       return exitStatus;
592     }
593 
594     /**
595      * Generate the help message and exit status for an invalid command using the
596      * provided message.
597      *
598      * @param message
599      *          the error message
600      * @return the resulting exit status
601      */
602     @NonNull
603     public ExitStatus handleInvalidCommand(
604         @NonNull String message) {
605       showHelp();
606 
607       ExitStatus retval = ExitCode.INVALID_COMMAND.exitMessage(message);
608       retval.generateMessage(false);
609       return retval;
610     }
611 
612     /**
613      * Callback for providing a help header.
614      *
615      * @return the header or {@code null}
616      */
617     @Nullable
618     private String buildHelpHeader() {
619       // TODO: build a suitable header
620       return null;
621     }
622 
623     /**
624      * Callback for providing a help footer.
625      *
626      * @param exec
627      *          the executable name
628      *
629      * @return the footer or {@code null}
630      */
631     @NonNull
632     private String buildHelpFooter() {
633 
634       ICommand targetCommand = getTargetCommand();
635       Collection<ICommand> subCommands;
636       if (targetCommand == null) {
637         subCommands = getTopLevelCommands();
638       } else {
639         subCommands = targetCommand.getSubCommands();
640       }
641 
642       String retval;
643       if (subCommands.isEmpty()) {
644         retval = "";
645       } else {
646         StringBuilder builder = new StringBuilder(128);
647         builder
648             .append(System.lineSeparator())
649             .append("The following are available commands:")
650             .append(System.lineSeparator());
651 
652         int length = subCommands.stream()
653             .mapToInt(command -> command.getName().length())
654             .max().orElse(0);
655 
656         for (ICommand command : subCommands) {
657           builder.append(
658               ansi()
659                   .render(String.format("   @|bold %-" + length + "s|@ %s%n",
660                       command.getName(),
661                       command.getDescription())));
662         }
663         builder
664             .append(System.lineSeparator())
665             .append('\'')
666             .append(getExec())
667             .append(" <command> --help' will show help on that specific command.")
668             .append(System.lineSeparator());
669         retval = builder.toString();
670         assert retval != null;
671       }
672       return retval;
673     }
674 
675     /**
676      * Get the CLI syntax.
677      *
678      * @return the CLI syntax to display in help output
679      */
680     private String buildHelpCliSyntax() {
681 
682       StringBuilder builder = new StringBuilder(64);
683       builder.append(getExec());
684 
685       List<ICommand> calledCommands = getCalledCommands();
686       if (!calledCommands.isEmpty()) {
687         builder.append(calledCommands.stream()
688             .map(ICommand::getName)
689             .collect(Collectors.joining(" ", " ", "")));
690       }
691 
692       // output calling commands
693       ICommand targetCommand = getTargetCommand();
694       if (targetCommand == null) {
695         builder.append(" <command>");
696       } else {
697         builder.append(getSubCommands(targetCommand));
698       }
699 
700       // output required options
701       getOptionsList().stream()
702           .filter(Option::isRequired)
703           .forEach(option -> {
704             builder
705                 .append(' ')
706                 .append(OptionUtils.toArgument(ObjectUtils.notNull(option)));
707             if (option.hasArg()) {
708               builder
709                   .append('=')
710                   .append(option.getArgName());
711             }
712           });
713 
714       // output non-required option placeholder
715       builder.append(" [<options>]");
716 
717       // output extra arguments
718       if (targetCommand != null) {
719         // handle extra arguments
720         builder.append(getExtraArguments(targetCommand));
721       }
722 
723       String retval = builder.toString();
724       assert retval != null;
725       return retval;
726     }
727 
728     @NonNull
729     private CharSequence getSubCommands(ICommand targetCommand) {
730       Collection<ICommand> subCommands = targetCommand.getSubCommands();
731 
732       StringBuilder builder = new StringBuilder();
733       if (!subCommands.isEmpty()) {
734         builder.append(' ');
735         if (!targetCommand.isSubCommandRequired()) {
736           builder.append('[');
737         }
738 
739         builder.append("<command>");
740 
741         if (!targetCommand.isSubCommandRequired()) {
742           builder.append(']');
743         }
744       }
745       return builder;
746     }
747 
748     @NonNull
749     private CharSequence getExtraArguments(@NonNull ICommand targetCommand) {
750       StringBuilder builder = new StringBuilder();
751       for (ExtraArgument argument : targetCommand.getExtraArguments()) {
752         builder.append(' ');
753         if (!argument.isRequired()) {
754           builder.append('[');
755         }
756 
757         builder.append('<')
758             .append(argument.getName())
759             .append('>');
760 
761         if (argument.getNumber() > 1) {
762           builder.append("...");
763         }
764 
765         if (!argument.isRequired()) {
766           builder.append(']');
767         }
768       }
769       return builder;
770     }
771 
772     /**
773      * Output the help text to the console.
774      */
775     public void showHelp() {
776 
777       HelpFormatter formatter = new HelpFormatter();
778       formatter.setLongOptSeparator("=");
779 
780       PrintStream out = outputStream;
781       int terminalWidth = (out instanceof AnsiPrintStream)
782           ? ((AnsiPrintStream) out).getTerminalWidth()
783           : 80;
784 
785       try (PrintWriter writer = new PrintWriter( // NOPMD not owned
786           AutoCloser.preventClose(out),
787           true,
788           StandardCharsets.UTF_8)) {
789         formatter.printHelp(
790             writer,
791             Math.max(terminalWidth, 50),
792             buildHelpCliSyntax(),
793             buildHelpHeader(),
794             toOptions(),
795             HelpFormatter.DEFAULT_LEFT_PAD,
796             HelpFormatter.DEFAULT_DESC_PAD,
797             buildHelpFooter(),
798             false);
799         writer.flush();
800       }
801     }
802   }
803 }