Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Using .passThroughOptions() without enabling .enablePositionalOptions() #2281

Closed
aryanjassal opened this issue Nov 15, 2024 · 13 comments
Closed

Comments

@aryanjassal
Copy link

Is there any way to allow passing options through without enabling positional options globally? Part of my program expects it to be disabled, and is built around that. However, one subcommand needs this functionality, and I cannot selectively enable positional arguments for one command.

program subcmd --opt arg1 arg2 --opt

For subcmd, I need to get all arguments after the first positional argument, like ['arg1', 'arg2', '--opt'], but I also have a --opt flag for the subcommand, so the variadic arguments treat the second --opt as part of the subcommand command, which is not what should happen.

Trying to enable passThroughOptions does not achieve anything as positional arguments are disabled globally. Why do we need to enable positional arguments globally to use passThroughOptions in a subcommand anyways?

@shadowspawn
Copy link
Collaborator

shadowspawn commented Nov 15, 2024

Why do we need to enable positional arguments globally to use passThroughOptions in a subcommand anyways?

The default parsing is that options for the root command (or any command) can come before or after subcommands. The parsing looks for global options before it looks for subcommands. So in your example, if the root command also had same --opt option it would consume the trailing opt before finding subcmd.

It would take a rewrite of the parsing to allow the leaf command to change the parsing behaviour in isolation, which was more than I wanted to do to add the feature.

@shadowspawn
Copy link
Collaborator

shadowspawn commented Nov 15, 2024

Part of my program expects it to be disabled, and is built around that.

So to confirm, you use global options after subcommands in other parts of your program? Like:

program subcmd2 --programOption

@aryanjassal
Copy link
Author

aryanjassal commented Nov 17, 2024

So to confirm, you use global options after subcommands in other parts of your program? Like:

program subcmd2 --programOption

Well, kinda. I actually have a function which allows all options to be available on all levels of the program. For example, if --option is specified for program root, then it would still be accessible like this: program subcmd --option. When I enable positional options, then this stops working, and modifying the function only gets limited success. (See relevant code)

For a more concrete example, I have defined the --format option in the program root, but I can use and access it in each subcommand. As such, program subcmd --format and program --format subcmd will be processed similarly.

Now, I need to enable pass through options because I am implementing a command like Unix's env command. It can take a bunch of positional arguments and a command to run. However, I don't have key-value pairs like env, so I parse the regular options unless I encounter a --, which is when I switch to parsing commands, like such: program subcmd val1 val2 -- cmd arg1 arg2. Without pass through options, I would have to do something like this to get the -- parsed properly: program subcmd val1 val2 -- -- cmd arg1 arg2.

Relevant code
/**
 * Overrides opts to return all options set in the command hierarchy
 */
public opts<T extends commander.OptionValues>(): T {
  const opts = super.opts<T>();
  if (this.parent != null) {
    // Override the current options with parent options
    // global option values are captured by the root command
    return Object.assign(opts, this.parent.opts<T>());
  } else {
    return opts;
  }
}

@shadowspawn
Copy link
Collaborator

Somewhat related, there are examples adding options to multiple subcommands in: https://github.com/tj/commander.js/blob/master/examples/global-options-added.js

@aryanjassal
Copy link
Author

Somewhat related, there are examples adding options to multiple subcommands in: https://github.com/tj/commander.js/blob/master/examples/global-options-added.js

Yeah, this is what I am doing. For reference, here is the relevant file in my project: https://github.com/MatrixAI/Polykey-CLI/blob/staging/src/CommandPolykey.ts

Reiterating here, but I need to enable pass through options for only one subcommand, so I can capture the raw arguments (including the --). If enabling pass through options isn't possible without enabling positional options, is there another way to get the desired behaviour? Or would I have to refactor my program to work with positional options enabled?

@shadowspawn
Copy link
Collaborator

Reiterating here, but I need to enable pass through options for only one subcommand, so I can capture the raw arguments (including the --). If enabling pass through options isn't possible without enabling positional options, is there another way to get the desired behaviour?

No, not and keep the other behaviours you have described.

Or would I have to refactor my program to work with positional options enabled?

Yes. I had mentioned the example file because if you have the options at the leaf subcommands anyway, you could refactor and remove the redundant global copy.

@aryanjassal
Copy link
Author

Yes. I had mentioned the example file because if you have the options at the leaf subcommands anyway, you could refactor and remove the redundant global copy.

The example I provided was simplified. In actuality, a usage of the program can look like this

polykey secrets env --node-path 'abc/xyz'

Now, this command currently works in all these configurations

polykey --node-path 'abc/xyz' secrets env
polykey secrets --node-path 'abc/xyz/ env
polykey secrets env --format 'json' --node-path 'abc/xyz'

And I have a lot of commands. It would have a lot of repetition, and it would be very easy to forget adding the option to a leaf command, and can break the program. This is why the project implements global opts, allowing access of all options on all levels basically. This is why it is such an issue.

@shadowspawn
Copy link
Collaborator

One approach is making -- part of the expected syntax for (just) the command which passes on options (as here). Like npm run does:

npm run-script <command> [-- <args>]

You did mention the resulting format in one of your comments, so just pointing out you could embrace the approach:

Without pass through options, I would have to do something like this to get the -- parsed properly: program subcmd val1 val2 -- -- cmd arg1 arg2.

@aryanjassal
Copy link
Author

One approach is making -- part of the expected syntax for (just) the command which passes on options (as here). Like npm run does:

https://github.com/MatrixAI/Polykey-CLI/blob/staging/src/secrets/CommandEnv.ts

I already do kind of have this. However, even this approach needs two of the -- to be passed.

this.argument(
  '<args...>',
  'command and arguments formatted as [envPaths...][-- cmd [cmdArgs...]]',
  binParsers.parseEnvArgs,
);

Am I doing something wrong with my implementation for this?

@shadowspawn
Copy link
Collaborator

shadowspawn commented Nov 18, 2024

Without .passThroughOptions() Commander will consume the first -- and stop detecting options. You need -- in your syntax to avoid cmdArgs from getting misinterpreted, and you need something to terminate the possible envPaths.

You could use a different sequence of characters for terminating envPaths:

'command and arguments formatted as [[envPaths...] //] [cmd -- [cmdArgs...]]'
or even a plain magic word:
'command and arguments formatted as [[envPaths...] END] [cmd -- [cmdArgs...]]'

[Edit: think this won't work]
or stick with the -- being used two ways (and you may or may not receive -- in the action handler depending on whether envPath present):

'command and arguments formatted as [[envPaths...] --] [cmd -- [cmdArgs...]]'

@aryanjassal
Copy link
Author

Without .passThroughOptions() Commander will consume the first -- and stop detecting options. You need -- in your syntax to avoid cmdArgs from getting misinterpreted, and you need something to terminate the possible envPaths.

You could use a different sequence of characters for terminating envPaths:

'command and arguments formatted as [[envPaths...] //] [cmd -- [cmdArgs...]]' or even a plain magic word: 'command and arguments formatted as [[envPaths...] END] [cmd -- [cmdArgs...]]'

This is something I don't want to do. I could have easily added an option or a flag to switch contexts as well, but I want the behaviour to remain as close to Unix's env as possible. Because I can't differentiate between env paths and command (as there is no strict syntax, unlike environment variables' KEY=VALUE), I needed another way to switch contexts. Using the -- seemed to be the best compromise, as the syntax is used in regular commands and it will also be behaving kinda similarly in our case, too.

[Edit: think this won't work] or stick with the -- being used two ways (and you may or may not receive -- in the action handler depending on whether envPath present):

'command and arguments formatted as [[envPaths...] --] [cmd -- [cmdArgs...]]'

I tried this and it didn't work. I even tried making envPaths mandatory and tried moving the order around, but nothing worked.

[<envPaths...> --] [cmd [cmdArgs...]]
<envPaths...> -- [cmd [cmdArgs...]]
<envPaths...> [-- cmd [cmdArgs...]]

Is there no workaround to achieve my desired behaviour except for refactoring to enable positional arguments?

@shadowspawn
Copy link
Collaborator

Ok, I think I understand the constraints now. Some other parsing libraries return the arguments after the -- separately, but Commander silently consumes the -- and stops option processing. That usually means the author doesn't need to do anything extra.

I think by looking up the -- in the original arguments you can work out how to break up the parsed arguments as desired. Everything after the -- is passed through as a command-argument (and not parsed as an option) so we can work backwards to break up the parsed arguments using the count of trailing arguments.

program.command('subcommand')
  .argument('[args...]')
  .usage('subcommand [envPaths...] -- cmd [cmdArgs...]]')
  .action((args, options) => {
    // Find how many arguments come after separator by looking at original arguments.
    const fullArgs = process.argv;
    const fullSeparatorIndex = fullArgs.indexOf('--'); 
    if (fullSeparatorIndex === -1) {
      console.error('No -- separator found');
      return;
    }
    const afterSeparatorCount = fullArgs.length - fullSeparatorIndex - 1;
    const envPaths = args.slice(0, -afterSeparatorCount);
    const cmdIndex = args.length - afterSeparatorCount;
    const cmdName = args[cmdIndex];
    const cmdArgs = args.slice(cmdIndex + 1);
    console.log({envPaths, cmdName, cmdArgs});
  });
% node index.mjs subcommand path1 path2 -- cmd cmdArg1 --cmdOption1 -- cmdArg2
{
  envPaths: [ 'path1', 'path2' ],
  cmdName: 'cmd',
  cmdArgs: [ 'cmdArg1', '--cmdOption1', '--', 'cmdArg2' ]
}

@aryanjassal
Copy link
Author

This is perfect for our use case. Thanks! I'll be closing this issue now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants