[8.8] [cli] Adjust CLI to treat extra options consistently (#158257) (#158670)

# Backport

This will backport the following commits from `main` to `8.8`:
- [[cli] Adjust CLI to treat extra options consistently
(#158257)](https://github.com/elastic/kibana/pull/158257)

<!--- Backport version: 8.9.7 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Alex
Szabo","email":"alex.szabo@elastic.co"},"sourceCommit":{"committedDate":"2023-05-30T16:09:16Z","message":"[cli]
Adjust CLI to treat extra options consistently (#158257)\n\n##
Summary\r\n\r\nAddresses: #105371 \r\nOne can use
`--elasticsearch.password
---my-password---`,\r\n`--elasticsearch.password=---mypassword---` or
`--elasticsearch.password\r\n\\\"---mypassword\\\"` both in these cases.
Commander is splitting\r\nsingle-dash inputs, it can't be prevented, so
for those cases, use\r\nquotes or compound options.\r\n\r\nThe original
problem can be reproduced like this:\r\n```shell\r\nyarn start
--elasticsearch.password \"-some-password\"\r\n```\r\n\r\nWe were using
some extra argument parsing prototype extension
in\r\n(command.js)[./src/cli/command.js] - this allows for collecting
extra\r\noptions, but the proprietary logic had a few incorrect
assumptions.\r\n(e.g.: extra opts will be already split by `=` by the
time we get them,\r\nshorthands will be split to individual
flags).\r\n\r\n(1) This change makes it explicit in the code, that the
single-letter\r\nflags (`-fcz`) are not supported in the extra
options.\r\n\r\nThe core of the problem is now how we treat an ambiguous
expression like\r\nthis:\r\n```\r\nkibana --password
\"--kebabs--are--my--favourite--\" --somethingElse 283\r\n```\r\nIs the
`\"--kebabs--are--my--favourite--\"` another flag, or is it a
value\r\nfor the previous option?\r\n\r\nUnfortunately, both
interpretations will have shortcomings. \r\n- if we treat it as a
boolean flag, then values starting with \"--\" will\r\nbe incorrectly
treated as flags, and an error will be displayed (in the\r\ncase of a
very unfortunate random generated string)\r\n- if we treat it as a value
always, then incorrectly ordered\r\noptions/flags will be consumed in
place of values\r\n\r\nMaybe the latter one will happen less often. For
that, people would have\r\nto incorrectly assume that `--booleanOn` type
flags exist, especially\r\namong the non-listed options. Or they'd have
to incorrectly parameterize\r\nthe command, for that they can review
their own usage locally, and see\r\ntheir error in the
ordering.\r\n\r\n(2) This change makes it explicit, that extra options
will come in\r\npairs.\r\n\r\nAlso, added tests for the base use case,
and the _dash_ edge case.\r\n\r\n### Checklist\r\n\r\n- [x] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common scenarios\r\n- [x] Checked and
works on 7.17/7.13 (it was originally reported
on\r\n7.13).\r\n\r\n---------\r\n\r\nCo-authored-by: Kibana Machine
<42973632+kibanamachine@users.noreply.github.com>","sha":"5ee40a9094c84f58c25000d70a32126f57b7839a","branchLabelMapping":{"^v8.9.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["Team:Operations","release_note:fix","backport:prev-MAJOR","v8.9.0"],"number":158257,"url":"https://github.com/elastic/kibana/pull/158257","mergeCommit":{"message":"[cli]
Adjust CLI to treat extra options consistently (#158257)\n\n##
Summary\r\n\r\nAddresses: #105371 \r\nOne can use
`--elasticsearch.password
---my-password---`,\r\n`--elasticsearch.password=---mypassword---` or
`--elasticsearch.password\r\n\\\"---mypassword\\\"` both in these cases.
Commander is splitting\r\nsingle-dash inputs, it can't be prevented, so
for those cases, use\r\nquotes or compound options.\r\n\r\nThe original
problem can be reproduced like this:\r\n```shell\r\nyarn start
--elasticsearch.password \"-some-password\"\r\n```\r\n\r\nWe were using
some extra argument parsing prototype extension
in\r\n(command.js)[./src/cli/command.js] - this allows for collecting
extra\r\noptions, but the proprietary logic had a few incorrect
assumptions.\r\n(e.g.: extra opts will be already split by `=` by the
time we get them,\r\nshorthands will be split to individual
flags).\r\n\r\n(1) This change makes it explicit in the code, that the
single-letter\r\nflags (`-fcz`) are not supported in the extra
options.\r\n\r\nThe core of the problem is now how we treat an ambiguous
expression like\r\nthis:\r\n```\r\nkibana --password
\"--kebabs--are--my--favourite--\" --somethingElse 283\r\n```\r\nIs the
`\"--kebabs--are--my--favourite--\"` another flag, or is it a
value\r\nfor the previous option?\r\n\r\nUnfortunately, both
interpretations will have shortcomings. \r\n- if we treat it as a
boolean flag, then values starting with \"--\" will\r\nbe incorrectly
treated as flags, and an error will be displayed (in the\r\ncase of a
very unfortunate random generated string)\r\n- if we treat it as a value
always, then incorrectly ordered\r\noptions/flags will be consumed in
place of values\r\n\r\nMaybe the latter one will happen less often. For
that, people would have\r\nto incorrectly assume that `--booleanOn` type
flags exist, especially\r\namong the non-listed options. Or they'd have
to incorrectly parameterize\r\nthe command, for that they can review
their own usage locally, and see\r\ntheir error in the
ordering.\r\n\r\n(2) This change makes it explicit, that extra options
will come in\r\npairs.\r\n\r\nAlso, added tests for the base use case,
and the _dash_ edge case.\r\n\r\n### Checklist\r\n\r\n- [x] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common scenarios\r\n- [x] Checked and
works on 7.17/7.13 (it was originally reported
on\r\n7.13).\r\n\r\n---------\r\n\r\nCo-authored-by: Kibana Machine
<42973632+kibanamachine@users.noreply.github.com>","sha":"5ee40a9094c84f58c25000d70a32126f57b7839a"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v8.9.0","labelRegex":"^v8.9.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/158257","number":158257,"mergeCommit":{"message":"[cli]
Adjust CLI to treat extra options consistently (#158257)\n\n##
Summary\r\n\r\nAddresses: #105371 \r\nOne can use
`--elasticsearch.password
---my-password---`,\r\n`--elasticsearch.password=---mypassword---` or
`--elasticsearch.password\r\n\\\"---mypassword\\\"` both in these cases.
Commander is splitting\r\nsingle-dash inputs, it can't be prevented, so
for those cases, use\r\nquotes or compound options.\r\n\r\nThe original
problem can be reproduced like this:\r\n```shell\r\nyarn start
--elasticsearch.password \"-some-password\"\r\n```\r\n\r\nWe were using
some extra argument parsing prototype extension
in\r\n(command.js)[./src/cli/command.js] - this allows for collecting
extra\r\noptions, but the proprietary logic had a few incorrect
assumptions.\r\n(e.g.: extra opts will be already split by `=` by the
time we get them,\r\nshorthands will be split to individual
flags).\r\n\r\n(1) This change makes it explicit in the code, that the
single-letter\r\nflags (`-fcz`) are not supported in the extra
options.\r\n\r\nThe core of the problem is now how we treat an ambiguous
expression like\r\nthis:\r\n```\r\nkibana --password
\"--kebabs--are--my--favourite--\" --somethingElse 283\r\n```\r\nIs the
`\"--kebabs--are--my--favourite--\"` another flag, or is it a
value\r\nfor the previous option?\r\n\r\nUnfortunately, both
interpretations will have shortcomings. \r\n- if we treat it as a
boolean flag, then values starting with \"--\" will\r\nbe incorrectly
treated as flags, and an error will be displayed (in the\r\ncase of a
very unfortunate random generated string)\r\n- if we treat it as a value
always, then incorrectly ordered\r\noptions/flags will be consumed in
place of values\r\n\r\nMaybe the latter one will happen less often. For
that, people would have\r\nto incorrectly assume that `--booleanOn` type
flags exist, especially\r\namong the non-listed options. Or they'd have
to incorrectly parameterize\r\nthe command, for that they can review
their own usage locally, and see\r\ntheir error in the
ordering.\r\n\r\n(2) This change makes it explicit, that extra options
will come in\r\npairs.\r\n\r\nAlso, added tests for the base use case,
and the _dash_ edge case.\r\n\r\n### Checklist\r\n\r\n- [x] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common scenarios\r\n- [x] Checked and
works on 7.17/7.13 (it was originally reported
on\r\n7.13).\r\n\r\n---------\r\n\r\nCo-authored-by: Kibana Machine
<42973632+kibanamachine@users.noreply.github.com>","sha":"5ee40a9094c84f58c25000d70a32126f57b7839a"}}]}]
BACKPORT-->

Co-authored-by: Alex Szabo <alex.szabo@elastic.co>
This commit is contained in:
Kibana Machine 2023-05-30 13:18:52 -04:00 committed by GitHub
parent aee0c5dea9
commit 0841742ec8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 158 additions and 16 deletions

View file

@ -53,30 +53,44 @@ Command.prototype.collectUnknownOptions = function () {
this.allowUnknownOption();
this.getUnknownOptions = function () {
const opts = {};
/**
* Commander.js already presents the unknown args split by "=",
* and shorthand switches already split, like -asd => -a, -s, -d
*/
const unknowns = this.unknownArgv();
const singleFlagArgs = unknowns.filter((flag) => {
return flag.match(/^-[a-zA-Z0-9]$/);
});
if (singleFlagArgs.length) {
this.error(
`${title} shouldn't have unknown shorthand flag arguments (${singleFlagArgs}). Possibly an argumentation error?`
);
}
while (unknowns.length) {
const opt = unknowns.shift().split('=');
if (opt[0].slice(0, 2) !== '--') {
this.error(`${title} "${opt[0]}" must start with "--"`);
const optName = unknowns.shift();
if (optName.slice(0, 2) !== '--') {
this.error(`${title} "${optName}" must start with "--"`);
}
if (opt.length === 1) {
if (!unknowns.length || unknowns[0][0] === '-') {
this.error(`${title} "${opt[0]}" must have a value`);
}
opt.push(unknowns.shift());
if (unknowns.length === 0) {
this.error(`${title} "${optName}" must have a value`);
}
let val = opt[1];
const optValue = unknowns.shift();
let val = optValue;
try {
val = JSON.parse(opt[1]);
} catch (e) {
val = opt[1];
val = JSON.parse(optValue);
} catch {
val = optValue;
}
set(opts, opt[0].slice(2), val);
set(opts, optName.slice(2), val);
}
return opts;
@ -96,7 +110,7 @@ Command.prototype.action = _.wrap(Command.prototype.action, function (action, fn
const ret = fn.apply(this, args);
if (ret && typeof ret.then === 'function') {
ret.then(null, function (e) {
console.log('FATAL CLI ERROR', e.stack);
console.log('FATAL CLI ERROR', e.stack);
process.exit(1);
});
}

128
src/cli/command.test.js Normal file
View file

@ -0,0 +1,128 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import commander from 'commander';
import './command';
describe('Commander extensions', () => {
const mockExit = jest.fn((exitCode) => {
// Prevent exiting from shell, let's throw something instead.
throw new Error('Exit ' + exitCode);
});
beforeAll(() => {
process._real_exit = process.exit;
process.exit = mockExit;
});
afterAll(() => {
process.exit = process._real_exit;
});
describe('regular options', () => {
it('collects multiple args', () => {
const args = makeArgs('test1', '--code', 'xoxo', '--param', 123);
const { collectedOptions, unknownOptions } = parseArgs('test1', ['code', 'param'], args);
expect(collectedOptions).toEqual({ code: 'xoxo', param: 123 });
expect(unknownOptions).toEqual({});
});
});
describe('unknown args', () => {
it('will be collected besides defined ones', () => {
const args = makeArgs('test2', '--code', 'xoxo', '--specialConfig', 'foobar');
const { collectedOptions, unknownOptions } = parseArgs('test2', ['code'], args);
expect(collectedOptions).toEqual({ code: 'xoxo' });
expect(unknownOptions).toEqual({ specialConfig: 'foobar' });
});
it('will be collected with dashes, when quoted', () => {
const args = makeArgs('test3', '--code', 'xoxo', '--specialConfig', '"---foobar"');
const { collectedOptions, unknownOptions } = parseArgs('test3', ['code'], args);
expect(collectedOptions).toEqual({ code: 'xoxo' });
expect(unknownOptions).toEqual({ specialConfig: '---foobar' });
});
it('will be collected with dashes as compound option', () => {
const args = makeArgs('test4', '--code', 'xoxo', '--specialConfig=----foobar');
const { collectedOptions, unknownOptions } = parseArgs('test4', ['code'], args);
expect(collectedOptions).toEqual({ code: 'xoxo' });
expect(unknownOptions).toEqual({ specialConfig: '----foobar' });
});
it('will crash if they contain bool flags', () => {
const args = makeArgs('test5', '--code', 'xoxo', '--secretOpt', '1234', '--useSpecial');
expect(() => parseArgs('test5', ['code'], args)).toThrow(/Exit/);
expect(mockExit).toHaveBeenCalledWith(64);
});
it('will crash if they contain shorthand flags', () => {
const args = makeArgs('test6', '--code', 'xoxo', '-xvz');
expect(() => parseArgs('test6', ['code'], args)).toThrow(/Exit/);
expect(mockExit).toHaveBeenCalledWith(64);
});
});
});
function prepareProgram(testName, options) {
const optionsDefinitions = options.map((optionName) => {
const short = optionName[0].toLowerCase();
return `-${short}, --${optionName} <${optionName}>`;
});
let command = commander.command(testName);
for (const option of optionsDefinitions) {
command = command.option(option);
}
return command;
}
function parseArgs(testName, options, args) {
let storedActionArgs = null;
const command = prepareProgram(testName, options)
.collectUnknownOptions()
.action((command) => {
const colllectedOptions = {};
options.forEach((opt) => {
colllectedOptions[opt] = command[opt];
});
storedActionArgs = colllectedOptions;
});
// Pass the args at the root command
commander.parse(args);
return {
collectedOptions: storedActionArgs,
unknownOptions: command.getUnknownOptions(),
command,
};
}
function makeArgs(...args) {
return ['node', 'test.js', ...args];
}

View file

@ -28,7 +28,7 @@ describe('cli serverless project type', () => {
expect(error).toBe(undefined);
expect(stdout.toString('utf8')).toContain(
'FATAL CLI ERROR Error: invalid --serverless value, must be one of es, oblt, security'
'FATAL CLI ERROR Error: invalid --serverless value, must be one of es, oblt, security'
);
expect(status).toBe(1);