UI/console update (#20590)

This commit is contained in:
Chelsea Shaw 2023-05-17 11:41:02 -05:00 committed by GitHub
parent e58f3816a4
commit 722c578ff4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 301 additions and 134 deletions

3
changelog/20590.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:improvement
ui: Update Web CLI with examples and a new `kv-get` command for reading kv v2 data and metadata
```

View File

@ -1,8 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import Component from '@ember/component';
export default Component.extend({});

View File

@ -8,15 +8,17 @@ import { alias, or } from '@ember/object/computed';
import Component from '@ember/component';
import { getOwner } from '@ember/application';
import { schedule } from '@ember/runloop';
import { camelize } from '@ember/string';
import { task } from 'ember-concurrency';
import ControlGroupError from 'vault/lib/control-group-error';
import {
parseCommand,
extractDataAndFlags,
logFromResponse,
logFromError,
logErrorFromInput,
formattedErrorFromInput,
executeUICommand,
extractFlagsFromStrings,
extractDataFromStrings,
} from 'vault/lib/console-helpers';
export default Component.extend({
@ -64,29 +66,25 @@ export default Component.extend({
// parse to verify it's valid
try {
serviceArgs = parseCommand(command, shouldThrow);
serviceArgs = parseCommand(command);
} catch (e) {
this.logAndOutput(command, { type: 'help' });
return;
}
// we have a invalid command but don't want to throw
if (serviceArgs === false) {
if (shouldThrow) {
this.logAndOutput(command, { type: 'help' });
}
return;
}
const [method, flagArray, path, dataArray] = serviceArgs;
const { method, flagArray, path, dataArray } = serviceArgs;
const flags = extractFlagsFromStrings(flagArray, method);
const data = extractDataFromStrings(dataArray);
if (dataArray || flagArray) {
var { data, flags } = extractDataAndFlags(method, dataArray, flagArray);
}
const inputError = logErrorFromInput(path, method, flags, dataArray);
const inputError = formattedErrorFromInput(path, method, flags, dataArray);
if (inputError) {
this.logAndOutput(command, inputError);
return;
}
try {
const resp = yield service[method].call(service, path, data, flags.wrapTTL);
const resp = yield service[camelize(method)].call(service, path, data, flags);
this.logAndOutput(command, logFromResponse(resp, path, method, flags));
} catch (error) {
if (error instanceof ControlGroupError) {

View File

@ -39,7 +39,7 @@
</:footer>
</Hds::SideNav>
</Frame.Sidebar>
<Frame.Main id="app-main-content">
<Frame.Main id="app-main-content" class={{if this.console.isOpen "main--console-open"}}>
{{! outlet for app content }}
<div id="modal-wormhole"></div>
<LinkStatus @status={{this.currentCluster.cluster.hcpLinkStatus}} />

View File

@ -4,66 +4,107 @@
*/
import keys from 'vault/lib/keycodes';
import argTokenizer from './arg-tokenizer';
import AdapterError from '@ember-data/adapter/error';
import { parse } from 'shell-quote';
const supportedCommands = ['read', 'write', 'list', 'delete'];
import argTokenizer from './arg-tokenizer';
import { StringMap } from 'vault/vault/app-types';
// Add new commands to `log-help` component for visibility
const supportedCommands = ['read', 'write', 'list', 'delete', 'kv-get'];
const uiCommands = ['api', 'clearall', 'clear', 'fullscreen', 'refresh'];
export function extractDataAndFlags(method, data, flags) {
return data.concat(flags).reduce(
(accumulator, val) => {
// will be "key=value" or "-flag=value" or "foo=bar=baz"
// split on the first =
// default to value of empty string
const [item, value = ''] = val.split(/=(.+)?/);
if (item.startsWith('-')) {
let flagName = item.replace(/^-/, '');
if (flagName === 'wrap-ttl') {
flagName = 'wrapTTL';
} else if (method === 'write') {
if (flagName === 'f' || flagName === '-force') {
flagName = 'force';
}
}
accumulator.flags[flagName] = value || true;
return accumulator;
}
// if it exists in data already, then we have multiple
// foo=bar in the list and need to make it an array
if (accumulator.data[item]) {
accumulator.data[item] = [].concat(accumulator.data[item], value);
return accumulator;
}
accumulator.data[item] = value;
return accumulator;
},
{ data: {}, flags: {} }
);
interface DataObj {
[key: string]: string | string[];
}
export function executeUICommand(command, logAndOutput, commandFns) {
export function extractDataFromStrings(dataArray: string[]): DataObj {
if (!dataArray) return {};
return dataArray.reduce((accumulator: DataObj, val: string) => {
// will be "key=value" or "foo=bar=baz"
// split on the first =
// default to value of empty string
const [item = '', value = ''] = val.split(/=(.+)?/);
if (!item) return accumulator;
// if it exists in data already, then we have multiple
// foo=bar in the list and need to make it an array
const existingValue = accumulator[item];
if (existingValue) {
accumulator[item] = Array.isArray(existingValue) ? [...existingValue, value] : [existingValue, value];
return accumulator;
}
accumulator[item] = value;
return accumulator;
}, {});
}
interface Flags {
field?: string;
format?: string;
force?: boolean;
wrapTTL?: boolean;
[key: string]: string | boolean | undefined;
}
export function extractFlagsFromStrings(flagArray: string[], method: string): Flags {
if (!flagArray) return {};
return flagArray.reduce((accumulator: Flags, val: string) => {
// val will be "-flag=value" or "--force"
// split on the first =
// default to value or true
const [item, value] = val.split(/=(.+)?/);
if (!item) return accumulator;
let flagName = item.replace(/^-/, '');
if (flagName === 'wrap-ttl') {
flagName = 'wrapTTL';
} else if (method === 'write') {
if (flagName === 'f' || flagName === '-force') {
flagName = 'force';
}
}
accumulator[flagName] = value || true;
return accumulator;
}, {});
}
interface CommandFns {
[key: string]: CallableFunction;
}
export function executeUICommand(
command: string,
logAndOutput: CallableFunction,
commandFns: CommandFns
): boolean {
const cmd = command.startsWith('api') ? 'api' : command;
const isUICommand = uiCommands.includes(cmd);
if (isUICommand) {
logAndOutput(command);
}
if (typeof commandFns[cmd] === 'function') {
commandFns[cmd]();
const execCommand = commandFns[cmd];
if (execCommand && typeof execCommand === 'function') {
execCommand();
}
return isUICommand;
}
export function parseCommand(command, shouldThrow) {
const args = argTokenizer(parse(command));
interface ParsedCommand {
method: string;
path: string;
flagArray: string[];
dataArray: string[];
}
export function parseCommand(command: string): ParsedCommand {
const args: string[] = argTokenizer(parse(command));
if (args[0] === 'vault') {
args.shift();
}
const [method, ...rest] = args;
let path;
const flags = [];
const data = [];
const [method = '', ...rest] = args;
let path = '';
const flags: string[] = [];
const data: string[] = [];
rest.forEach((arg) => {
if (arg.startsWith('-')) {
@ -86,24 +127,28 @@ export function parseCommand(command, shouldThrow) {
});
if (!supportedCommands.includes(method)) {
if (shouldThrow) {
throw new Error('invalid command');
}
return false;
throw new Error('invalid command');
}
return [method, flags, path, data];
return { method, flagArray: flags, path, dataArray: data };
}
export function logFromResponse(response, path, method, flags) {
interface LogResponse {
auth?: StringMap;
data?: StringMap;
wrap_info?: StringMap;
[key: string]: unknown;
}
export function logFromResponse(response: LogResponse, path: string, method: string, flags: Flags) {
const { format, field } = flags;
let secret = response && (response.auth || response.data || response.wrap_info);
if (!secret) {
const respData: StringMap | undefined = response && (response.auth || response.data || response.wrap_info);
const secret: StringMap | LogResponse = respData || response;
if (!respData) {
if (method === 'write') {
return { type: 'success', content: `Success! Data written to: ${path}` };
} else if (method === 'delete') {
return { type: 'success', content: `Success! Data deleted (if it existed) at: ${path}` };
} else {
secret = response;
}
}
@ -143,11 +188,17 @@ export function logFromResponse(response, path, method, flags) {
return { type: 'object', content: secret };
}
export function logFromError(error, vaultPath, method) {
interface CustomError extends AdapterError {
httpStatus: number;
path: string;
errors: string[];
}
export function logFromError(error: CustomError, vaultPath: string, method: string) {
let content;
const { httpStatus, path } = error;
const verbClause = {
read: 'reading from',
'kv-get': 'reading secret',
write: 'writing to',
list: 'listing',
delete: 'deleting at',
@ -162,7 +213,11 @@ export function logFromError(error, vaultPath, method) {
return { type: 'error', content };
}
export function shiftCommandIndex(keyCode, history, index) {
interface CommandLog {
type: string;
content?: string;
}
export function shiftCommandIndex(keyCode: number, history: CommandLog[], index: number) {
let newInputValue;
const commandHistoryLength = history.length;
@ -186,17 +241,18 @@ export function shiftCommandIndex(keyCode, history, index) {
}
if (newInputValue !== '') {
newInputValue = history.objectAt(index).content;
newInputValue = history.objectAt(index)?.content;
}
return [index, newInputValue];
}
export function logErrorFromInput(path, method, flags, dataArray) {
export function formattedErrorFromInput(path: string, method: string, flags: Flags, dataArray: string[]) {
if (path === undefined) {
return { type: 'error', content: 'A path is required to make a request.' };
}
if (method === 'write' && !flags.force && dataArray.length === 0) {
return { type: 'error', content: 'Must supply data or use -force' };
}
return;
}

View File

@ -84,11 +84,22 @@ export default Service.extend({
});
},
read(path, data, wrapTTL) {
kvGet(path, data, flags = {}) {
const { wrapTTL, metadata } = flags;
// Split on first / to find backend and secret path
const pathSegment = metadata ? 'metadata' : 'data';
const [backend, secretPath] = path.split(/\/(.+)?/);
const kvPath = `${backend}/${pathSegment}/${secretPath}`;
return this.ajax('read', sanitizePath(kvPath), { wrapTTL });
},
read(path, data, flags) {
const wrapTTL = flags?.wrapTTL;
return this.ajax('read', sanitizePath(path), { wrapTTL });
},
write(path, data, wrapTTL) {
write(path, data, flags) {
const wrapTTL = flags?.wrapTTL;
return this.ajax('write', sanitizePath(path), { data, wrapTTL });
},
@ -96,7 +107,8 @@ export default Service.extend({
return this.ajax('delete', sanitizePath(path));
},
list(path, data, wrapTTL) {
list(path, data, flags) {
const wrapTTL = flags?.wrapTTL;
const listPath = ensureTrailingSlash(sanitizePath(path));
return this.ajax('list', listPath, {
data: {

View File

@ -125,6 +125,10 @@ $console-close-height: 35px;
min-height: 400px;
}
.main--console-open {
padding-bottom: 400px;
}
.panel-open .console-ui-panel.fullscreen {
bottom: 0;
right: 0;

View File

@ -6,6 +6,7 @@
Commands:
read Read data and retrieves secrets
kv-get Read data for kv v2 secret engines. Use -metadata flag to read metadata
write Write data, configuration, and secrets
delete Delete secrets and configuration
list List data or secrets

View File

@ -5,10 +5,18 @@
</div>
<div class="console-ui-panel-content">
<div class="content has-bottom-margin-l">
<p class="has-text-grey is-font-mono">
The Vault Browser CLI provides an easy way to execute the most common CLI commands, such as write, read, delete, and
list.
<p class="has-text-grey is-font-mono has-bottom-margin-s">
The Vault Browser CLI provides an easy way to execute common Vault CLI commands, such as write, read, delete, and list.
It does not include kv v2 write or put commands. For guidance, type `help`.
</p>
<p class="has-text-grey is-font-mono has-bottom-margin-s">Examples:</p>
<p class="has-text-grey is-font-mono">→ Write secrets to kv v1: write &lt;mount&gt;/my-secret foo=bar</p>
<p class="has-text-grey is-font-mono">→ List kv v1 secret keys: list &lt;mount&gt;/</p>
<p class="has-text-grey is-font-mono">→ Read a kv v1 secret: read &lt;mount&gt;/my-secret</p>
<p class="has-text-grey is-font-mono">→ Mount a kv v2 secret engine: write sys/mounts/&lt;mount&gt; type=kv
options=version=2</p>
<p class="has-text-grey is-font-mono">→ Read a kv v2 secret: kv-get &lt;mount&gt;/secret-path</p>
<p class="has-text-grey is-font-mono">→ Read a kv v2 secret's metadata: kv-get &lt;mount&gt;/secret-path -metadata</p>
</div>
<Console::OutputLog @outputLog={{this.cliLog}} />
<Console::CommandInput

View File

@ -10,8 +10,11 @@
<ToolbarFilters>
<div class="field is-marginless">
<p class="control has-icons-left">
<label for="swagger-result-filter" class="sr-only">Filter operations by path</label>
<input
oninput={{queue (action "updateFilter") (action "proxyEvent")}}
id="swagger-result-filter"
{{on "input" (action "proxyEvent")}}
{{on "change" (action "updateFilter")}}
value={{@initialFilter}}
disabled={{this.swaggerLoading}}
class="filter input"

View File

@ -92,6 +92,7 @@
"@types/ember__utils": "^4.0.2",
"@types/qunit": "^2.19.3",
"@types/rsvp": "^4.0.4",
"@types/shell-quote": "^1.7.1",
"@typescript-eslint/eslint-plugin": "^5.19.0",
"@typescript-eslint/parser": "^5.19.0",
"asn1js": "^2.2.0",

View File

@ -6,10 +6,11 @@
import { module, test } from 'qunit';
import {
parseCommand,
extractDataAndFlags,
logFromResponse,
logFromError,
logErrorFromInput,
formattedErrorFromInput,
extractFlagsFromStrings,
extractDataFromStrings,
} from 'vault/lib/console-helpers';
module('Unit | Lib | console helpers', function () {
@ -20,16 +21,16 @@ module('Unit | Lib | console helpers', function () {
access_key=AKIAJWVN5Z4FOFT7NLNA \
secret_key=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 \
region=us-east-1`,
expected: [
'write',
[],
'aws/config/root',
[
expected: {
method: 'write',
flagArray: [],
path: 'aws/config/root',
dataArray: [
'access_key=AKIAJWVN5Z4FOFT7NLNA',
'secret_key=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',
'region=us-east-1',
],
],
},
},
{
name: 'write with space in a value',
@ -43,11 +44,11 @@ module('Unit | Lib | console helpers', function () {
insecure_tls=true \
starttls=false
`,
expected: [
'write',
[],
'auth/ldap/config',
[
expected: {
method: 'write',
flagArray: [],
path: 'auth/ldap/config',
dataArray: [
'url=ldap://ldap.example.com:3268',
'binddn=CN=ServiceViewDev,OU=Service Accounts,DC=example,DC=com',
'bindpass=xxxxxxxxxxxxxxxxxxxxxxxxxx',
@ -56,7 +57,7 @@ module('Unit | Lib | console helpers', function () {
'insecure_tls=true',
'starttls=false',
],
],
},
},
{
name: 'write with double quotes',
@ -64,7 +65,7 @@ module('Unit | Lib | console helpers', function () {
auth/token/create \
policies="foo"
`,
expected: ['write', [], 'auth/token/create', ['policies=foo']],
expected: { method: 'write', flagArray: [], path: 'auth/token/create', dataArray: ['policies=foo'] },
},
{
name: 'write with single quotes',
@ -72,7 +73,7 @@ module('Unit | Lib | console helpers', function () {
auth/token/create \
policies='foo'
`,
expected: ['write', [], 'auth/token/create', ['policies=foo']],
expected: { method: 'write', flagArray: [], path: 'auth/token/create', dataArray: ['policies=foo'] },
},
{
name: 'write with unmatched quotes',
@ -80,30 +81,35 @@ module('Unit | Lib | console helpers', function () {
auth/token/create \
policies="'foo"
`,
expected: ['write', [], 'auth/token/create', ["policies='foo"]],
expected: { method: 'write', flagArray: [], path: 'auth/token/create', dataArray: ["policies='foo"] },
},
{
name: 'write with shell characters',
/* eslint-disable no-useless-escape */
command: `vault write database/roles/api-prod db_name=apiprod creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; GRANT SELECT ON ALL TABLES IN SCHEMA public TO \"{{name}}\";" default_ttl=1h max_ttl=24h
`,
expected: [
'write',
[],
'database/roles/api-prod',
[
expected: {
method: 'write',
flagArray: [],
path: 'database/roles/api-prod',
dataArray: [
'db_name=apiprod',
`creation_statements=CREATE ROLE {{name}} WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; GRANT SELECT ON ALL TABLES IN SCHEMA public TO {{name}};`,
'default_ttl=1h',
'max_ttl=24h',
],
],
},
},
{
name: 'read with field',
command: `vault read -field=access_key aws/creds/my-role`,
expected: ['read', ['-field=access_key'], 'aws/creds/my-role', []],
expected: {
method: 'read',
flagArray: ['-field=access_key'],
path: 'aws/creds/my-role',
dataArray: [],
},
},
];
@ -115,16 +121,14 @@ module('Unit | Lib | console helpers', function () {
});
test('#parseCommand: invalid commands', function (assert) {
assert.expect(1);
const command = 'vault kv get foo';
const result = parseCommand(command);
assert.false(result, 'parseCommand returns false by default');
assert.throws(
() => {
parseCommand(command, true);
parseCommand(command);
},
/invalid command/,
'throws on invalid command when `shouldThrow` is true'
'throws on invalid command'
);
});
@ -132,14 +136,12 @@ module('Unit | Lib | console helpers', function () {
{
method: 'read',
name: 'data fields',
input: [
[
'access_key=AKIAJWVN5Z4FOFT7NLNA',
'secret_key=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',
'region=us-east-1',
],
[],
dataInput: [
'access_key=AKIAJWVN5Z4FOFT7NLNA',
'secret_key=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',
'region=us-east-1',
],
flagInput: [],
expected: {
data: {
access_key: 'AKIAJWVN5Z4FOFT7NLNA',
@ -152,7 +154,8 @@ module('Unit | Lib | console helpers', function () {
{
method: 'read',
name: 'repeated data and a flag',
input: [['allowed_domains=example.com', 'allowed_domains=foo.example.com'], ['-wrap-ttl=2h']],
dataInput: ['allowed_domains=example.com', 'allowed_domains=foo.example.com'],
flagInput: ['-wrap-ttl=2h'],
expected: {
data: {
allowed_domains: ['example.com', 'foo.example.com'],
@ -162,10 +165,27 @@ module('Unit | Lib | console helpers', function () {
},
},
},
{
method: 'read',
name: 'triple data',
dataInput: [
'allowed_domains=example.com',
'allowed_domains=foo.example.com',
'allowed_domains=dev.example.com',
],
flagInput: [],
expected: {
data: {
allowed_domains: ['example.com', 'foo.example.com', 'dev.example.com'],
},
flags: {},
},
},
{
method: 'read',
name: 'data with more than one equals sign',
input: [['foo=bar=baz', 'foo=baz=bop', 'some=value=val'], []],
dataInput: ['foo=bar=baz', 'foo=baz=bop', 'some=value=val'],
flagInput: [],
expected: {
data: {
foo: ['bar=baz', 'baz=bop'],
@ -177,7 +197,8 @@ module('Unit | Lib | console helpers', function () {
{
method: 'read',
name: 'data with empty values',
input: [[`foo=`, 'some=thing'], []],
dataInput: [`foo=`, 'some=thing'],
flagInput: [],
expected: {
data: {
foo: '',
@ -189,7 +210,8 @@ module('Unit | Lib | console helpers', function () {
{
method: 'write',
name: 'write with force flag',
input: [[], ['-force']],
dataInput: [],
flagInput: ['-force'],
expected: {
data: {},
flags: {
@ -200,7 +222,8 @@ module('Unit | Lib | console helpers', function () {
{
method: 'write',
name: 'write with force short flag',
input: [[], ['-f']],
dataInput: [],
flagInput: ['-f'],
expected: {
data: {},
flags: {
@ -211,7 +234,8 @@ module('Unit | Lib | console helpers', function () {
{
method: 'write',
name: 'write with GNU style force flag',
input: [[], ['--force']],
dataInput: [],
flagInput: ['--force'],
expected: {
data: {},
flags: {
@ -222,9 +246,12 @@ module('Unit | Lib | console helpers', function () {
];
testExtractCases.forEach(function (testCase) {
test(`#extractDataAndFlags: ${testCase.name}`, function (assert) {
const { data, flags } = extractDataAndFlags(testCase.method, ...testCase.input);
test(`#extractDataFromStrings: ${testCase.name}`, function (assert) {
const data = extractDataFromStrings(testCase.dataInput);
assert.deepEqual(data, testCase.expected.data, 'has expected data');
});
test(`#extractFlagsFromStrings: ${testCase.name}`, function (assert) {
const flags = extractFlagsFromStrings(testCase.flagInput, testCase.method);
assert.deepEqual(flags, testCase.expected.flags, 'has expected flags');
});
});
@ -469,8 +496,8 @@ module('Unit | Lib | console helpers', function () {
];
testCommandCases.forEach(function (testCase) {
test(`#logErrorFromInput: ${testCase.name}`, function (assert) {
const data = logErrorFromInput(...testCase.args);
test(`#formattedErrorFromInput: ${testCase.name}`, function (assert) {
const data = formattedErrorFromInput(...testCase.args);
assert.deepEqual(
data,

View File

@ -38,7 +38,7 @@ module('Unit | Service | console', function (hooks) {
{
method: 'read',
args: ['/secrets/foo/bar', {}, '30m'],
args: ['/secrets/foo/bar', {}, { wrapTTL: '30m' }],
expectedURL: 'secrets/foo/bar',
expectedVerb: 'GET',
expectedOptions: { data: undefined, wrapTTL: '30m' },
@ -65,7 +65,7 @@ module('Unit | Service | console', function (hooks) {
{
method: 'list',
args: ['secret/mounts', {}, '1h'],
args: ['secret/mounts', {}, { wrapTTL: '1h' }],
expectedURL: 'secret/mounts/',
expectedVerb: 'GET',
expectedOptions: { data: { list: true }, wrapTTL: '1h' },
@ -102,4 +102,58 @@ module('Unit | Service | console', function (hooks) {
assert.deepEqual(options, testCase.expectedOptions, `${testCase.method}: uses the correct options`);
});
});
const kvTestCases = [
{
method: 'kvGet',
args: ['kv/foo'],
expectedURL: 'kv/data/foo',
expectedVerb: 'GET',
expectedOptions: { data: undefined, wrapTTL: undefined },
},
{
method: 'kvGet',
args: ['kv/foo', {}, { metadata: true }],
expectedURL: 'kv/metadata/foo',
expectedVerb: 'GET',
expectedOptions: { data: undefined, wrapTTL: undefined },
},
{
method: 'kvGet',
args: ['kv/foo', {}, { wrapTTL: '10m' }],
expectedURL: 'kv/data/foo',
expectedVerb: 'GET',
expectedOptions: { data: undefined, wrapTTL: '10m' },
},
{
method: 'kvGet',
args: ['kv/foo', {}, { metadata: true, wrapTTL: '10m' }],
expectedURL: 'kv/metadata/foo',
expectedVerb: 'GET',
expectedOptions: { data: undefined, wrapTTL: '10m' },
},
];
test('it reads kv secret and metadata', function (assert) {
assert.expect(12);
const ajax = sinon.stub();
const uiConsole = this.owner.factoryFor('service:console').create({
adapter() {
return {
buildURL(url) {
return url;
},
ajax,
};
},
});
kvTestCases.forEach((testCase) => {
uiConsole[testCase.method](...testCase.args);
const [url, verb, options] = ajax.lastCall.args;
assert.strictEqual(url, testCase.expectedURL, `${testCase.method}: uses correct url`);
assert.strictEqual(verb, testCase.expectedVerb, `${testCase.method}: uses the correct verb`);
assert.deepEqual(options, testCase.expectedOptions, `${testCase.method}: uses the correct options`);
});
});
});

View File

@ -5648,6 +5648,13 @@ __metadata:
languageName: node
linkType: hard
"@types/shell-quote@npm:^1.7.1":
version: 1.7.1
resolution: "@types/shell-quote@npm:1.7.1"
checksum: 51e58326b8c6dcb72846b94cebe3dc4c84f3514469a8e52bd29c52c601e784b427b851d7477acbeef47bfcccf25d2a5768684d27e7fc95fdd003393c1bbb7bc3
languageName: node
linkType: hard
"@types/symlink-or-copy@npm:^1.2.0":
version: 1.2.0
resolution: "@types/symlink-or-copy@npm:1.2.0"
@ -24224,6 +24231,7 @@ __metadata:
"@types/ember__utils": ^4.0.2
"@types/qunit": ^2.19.3
"@types/rsvp": ^4.0.4
"@types/shell-quote": ^1.7.1
"@typescript-eslint/eslint-plugin": ^5.19.0
"@typescript-eslint/parser": ^5.19.0
asn1js: ^2.2.0