[kbn/dev-utils] add RunWithCommands utility (#72311)
Co-authored-by: spalger <spalger@users.noreply.github.com>
This commit is contained in:
parent
5356941f22
commit
466380e3b6
|
@ -33,9 +33,9 @@ export {
|
|||
KBN_P12_PATH,
|
||||
KBN_P12_PASSWORD,
|
||||
} from './certs';
|
||||
export { run, createFailError, createFlagError, combineErrors, isFailError, Flags } from './run';
|
||||
export { REPO_ROOT } from './repo_root';
|
||||
export { KbnClient } from './kbn_client';
|
||||
export * from './run';
|
||||
export * from './axios';
|
||||
export * from './stdio';
|
||||
export * from './ci_stats_reporter';
|
||||
|
|
94
packages/kbn-dev-utils/src/run/cleanup.ts
Normal file
94
packages/kbn-dev-utils/src/run/cleanup.ts
Normal file
|
@ -0,0 +1,94 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { inspect } from 'util';
|
||||
|
||||
import exitHook from 'exit-hook';
|
||||
|
||||
import { ToolingLog } from '../tooling_log';
|
||||
import { isFailError } from './fail';
|
||||
|
||||
export type CleanupTask = () => void;
|
||||
|
||||
export class Cleanup {
|
||||
static setup(log: ToolingLog, helpText: string) {
|
||||
const onUnhandledRejection = (error: any) => {
|
||||
log.error('UNHANDLED PROMISE REJECTION');
|
||||
log.error(
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(`non-Error type rejection value: ${inspect(error)}`)
|
||||
);
|
||||
process.exit(1);
|
||||
};
|
||||
|
||||
process.on('unhandledRejection', onUnhandledRejection);
|
||||
|
||||
const cleanup = new Cleanup(log, helpText, [
|
||||
() => process.removeListener('unhandledRejection', onUnhandledRejection),
|
||||
]);
|
||||
|
||||
cleanup.add(exitHook(() => cleanup.execute()));
|
||||
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly log: ToolingLog,
|
||||
public helpText: string,
|
||||
private readonly tasks: CleanupTask[]
|
||||
) {}
|
||||
|
||||
add(task: CleanupTask) {
|
||||
this.tasks.push(task);
|
||||
}
|
||||
|
||||
execute(topLevelError?: any) {
|
||||
const tasks = this.tasks.slice(0);
|
||||
this.tasks.length = 0;
|
||||
|
||||
for (const task of tasks) {
|
||||
try {
|
||||
task();
|
||||
} catch (error) {
|
||||
this.onError(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (topLevelError) {
|
||||
this.onError(topLevelError);
|
||||
}
|
||||
}
|
||||
|
||||
private onError(error: any) {
|
||||
if (isFailError(error)) {
|
||||
this.log.error(error.message);
|
||||
|
||||
if (error.showHelp) {
|
||||
this.log.write(this.helpText);
|
||||
}
|
||||
|
||||
process.exitCode = error.exitCode;
|
||||
} else {
|
||||
this.log.error('UNHANDLED ERROR');
|
||||
this.log.error(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -22,14 +22,12 @@ import { getFlags } from './flags';
|
|||
it('gets flags correctly', () => {
|
||||
expect(
|
||||
getFlags(['-a', '--abc=bcd', '--foo=bar', '--no-bar', '--foo=baz', '--box', 'yes', '-zxy'], {
|
||||
flags: {
|
||||
boolean: ['x'],
|
||||
string: ['abc'],
|
||||
alias: {
|
||||
x: 'extra',
|
||||
},
|
||||
allowUnexpected: true,
|
||||
boolean: ['x'],
|
||||
string: ['abc'],
|
||||
alias: {
|
||||
x: 'extra',
|
||||
},
|
||||
allowUnexpected: true,
|
||||
})
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
|
@ -60,10 +58,8 @@ it('gets flags correctly', () => {
|
|||
it('guesses types for unexpected flags', () => {
|
||||
expect(
|
||||
getFlags(['-abc', '--abc=bcd', '--no-foo', '--bar'], {
|
||||
flags: {
|
||||
allowUnexpected: true,
|
||||
guessTypesForUnexpectedFlags: true,
|
||||
},
|
||||
allowUnexpected: true,
|
||||
guessTypesForUnexpectedFlags: true,
|
||||
})
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
|
|
|
@ -17,12 +17,9 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
import { relative } from 'path';
|
||||
|
||||
import dedent from 'dedent';
|
||||
import getopts from 'getopts';
|
||||
|
||||
import { Options } from './run';
|
||||
import { RunOptions } from './run';
|
||||
|
||||
export interface Flags {
|
||||
verbose: boolean;
|
||||
|
@ -36,23 +33,52 @@ export interface Flags {
|
|||
[key: string]: undefined | boolean | string | string[];
|
||||
}
|
||||
|
||||
export function getFlags(argv: string[], options: Options): Flags {
|
||||
export interface FlagOptions {
|
||||
allowUnexpected?: boolean;
|
||||
guessTypesForUnexpectedFlags?: boolean;
|
||||
help?: string;
|
||||
alias?: { [key: string]: string | string[] };
|
||||
boolean?: string[];
|
||||
string?: string[];
|
||||
default?: { [key: string]: any };
|
||||
}
|
||||
|
||||
export function mergeFlagOptions(global: FlagOptions = {}, local: FlagOptions = {}): FlagOptions {
|
||||
return {
|
||||
alias: {
|
||||
...global.alias,
|
||||
...local.alias,
|
||||
},
|
||||
boolean: [...(global.boolean || []), ...(local.boolean || [])],
|
||||
string: [...(global.string || []), ...(local.string || [])],
|
||||
default: {
|
||||
...global.alias,
|
||||
...local.alias,
|
||||
},
|
||||
|
||||
help: local.help,
|
||||
|
||||
allowUnexpected: !!(global.allowUnexpected || local.allowUnexpected),
|
||||
guessTypesForUnexpectedFlags: !!(global.allowUnexpected || local.allowUnexpected),
|
||||
};
|
||||
}
|
||||
|
||||
export function getFlags(argv: string[], flagOptions: RunOptions['flags'] = {}): Flags {
|
||||
const unexpectedNames = new Set<string>();
|
||||
const flagOpts = options.flags || {};
|
||||
|
||||
const { verbose, quiet, silent, debug, help, _, ...others } = getopts(argv, {
|
||||
string: flagOpts.string,
|
||||
boolean: [...(flagOpts.boolean || []), 'verbose', 'quiet', 'silent', 'debug', 'help'],
|
||||
string: flagOptions.string,
|
||||
boolean: [...(flagOptions.boolean || []), 'verbose', 'quiet', 'silent', 'debug', 'help'],
|
||||
alias: {
|
||||
...(flagOpts.alias || {}),
|
||||
...flagOptions.alias,
|
||||
v: 'verbose',
|
||||
},
|
||||
default: flagOpts.default,
|
||||
default: flagOptions.default,
|
||||
unknown: (name: string) => {
|
||||
unexpectedNames.add(name);
|
||||
return flagOpts.guessTypesForUnexpectedFlags;
|
||||
return !!flagOptions.guessTypesForUnexpectedFlags;
|
||||
},
|
||||
} as any);
|
||||
});
|
||||
|
||||
const unexpected: string[] = [];
|
||||
for (const unexpectedName of unexpectedNames) {
|
||||
|
@ -119,32 +145,3 @@ export function getFlags(argv: string[], options: Options): Flags {
|
|||
...others,
|
||||
};
|
||||
}
|
||||
|
||||
export function getHelp(options: Options) {
|
||||
const usage = options.usage || `node ${relative(process.cwd(), process.argv[1])}`;
|
||||
|
||||
const optionHelp = (
|
||||
dedent(options?.flags?.help || '') +
|
||||
'\n' +
|
||||
dedent`
|
||||
--verbose, -v Log verbosely
|
||||
--debug Log debug messages (less than verbose)
|
||||
--quiet Only log errors
|
||||
--silent Don't log anything
|
||||
--help Show this message
|
||||
`
|
||||
)
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.join('\n ');
|
||||
|
||||
return `
|
||||
${usage}
|
||||
|
||||
${dedent(options.description || 'Runs a dev task')
|
||||
.split('\n')
|
||||
.join('\n ')}
|
||||
|
||||
Options:
|
||||
${optionHelp + '\n\n'}`;
|
||||
}
|
||||
|
|
199
packages/kbn-dev-utils/src/run/help.test.ts
Normal file
199
packages/kbn-dev-utils/src/run/help.test.ts
Normal file
|
@ -0,0 +1,199 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { getCommandLevelHelp, getHelp, getHelpForAllCommands } from './help';
|
||||
import { Command } from './run_with_commands';
|
||||
|
||||
const fooCommand: Command<any> = {
|
||||
description: `
|
||||
Some thing that we wrote to help us execute things.
|
||||
|
||||
Example:
|
||||
|
||||
foo = bar = baz
|
||||
|
||||
Are you getting it?
|
||||
`,
|
||||
name: 'foo',
|
||||
run: () => {},
|
||||
flags: {
|
||||
help: `
|
||||
--foo Some flag
|
||||
--bar Another flag
|
||||
Secondary info
|
||||
--baz, -b Hey hello
|
||||
`,
|
||||
},
|
||||
usage: 'foo [...names]',
|
||||
};
|
||||
|
||||
const barCommand: Command<any> = {
|
||||
description: `
|
||||
Some other thing that we wrote to help us execute things.
|
||||
`,
|
||||
name: 'bar',
|
||||
run: () => {},
|
||||
flags: {
|
||||
help: `
|
||||
--baz, -b Hey hello
|
||||
`,
|
||||
},
|
||||
usage: 'bar [...names]',
|
||||
};
|
||||
|
||||
describe('getHelp()', () => {
|
||||
it('returns the expected output', () => {
|
||||
expect(
|
||||
getHelp({
|
||||
description: fooCommand.description,
|
||||
flagHelp: fooCommand.flags?.help,
|
||||
usage: `
|
||||
node scripts/foo --bar --baz
|
||||
`,
|
||||
})
|
||||
).toMatchInlineSnapshot(`
|
||||
"
|
||||
node scripts/foo --bar --baz
|
||||
|
||||
Some thing that we wrote to help us execute things.
|
||||
|
||||
Example:
|
||||
|
||||
foo = bar = baz
|
||||
|
||||
Are you getting it?
|
||||
|
||||
Options:
|
||||
--foo Some flag
|
||||
--bar Another flag
|
||||
Secondary info
|
||||
--baz, -b Hey hello
|
||||
--verbose, -v Log verbosely
|
||||
--debug Log debug messages (less than verbose)
|
||||
--quiet Only log errors
|
||||
--silent Don't log anything
|
||||
--help Show this message
|
||||
|
||||
"
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCommandLevelHelp()', () => {
|
||||
it('returns the expected output', () => {
|
||||
expect(
|
||||
getCommandLevelHelp({
|
||||
command: fooCommand,
|
||||
globalFlagHelp: `
|
||||
--global-flag some flag that applies to all commands
|
||||
`,
|
||||
})
|
||||
).toMatchInlineSnapshot(`
|
||||
"
|
||||
node node_modules/jest-worker/build/workers/processChild.js foo [...names]
|
||||
|
||||
Some thing that we wrote to help us execute things.
|
||||
|
||||
Example:
|
||||
|
||||
foo = bar = baz
|
||||
|
||||
Are you getting it?
|
||||
|
||||
Command-specific options:
|
||||
--foo Some flag
|
||||
--bar Another flag
|
||||
Secondary info
|
||||
--baz, -b Hey hello
|
||||
|
||||
Global options:
|
||||
--global-flag some flag that applies to all commands
|
||||
--verbose, -v Log verbosely
|
||||
--debug Log debug messages (less than verbose)
|
||||
--quiet Only log errors
|
||||
--silent Don't log anything
|
||||
--help Show this message
|
||||
|
||||
To see the help for other commands run:
|
||||
node node_modules/jest-worker/build/workers/processChild.js help [command]
|
||||
|
||||
To see the list of commands run:
|
||||
node node_modules/jest-worker/build/workers/processChild.js --help
|
||||
|
||||
"
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHelpForAllCommands()', () => {
|
||||
it('returns the expected output', () => {
|
||||
expect(
|
||||
getHelpForAllCommands({
|
||||
commands: [fooCommand, barCommand],
|
||||
globalFlagHelp: `
|
||||
--global-flag some flag that applies to all commands
|
||||
`,
|
||||
usage: `
|
||||
node scripts/my_cli
|
||||
`,
|
||||
})
|
||||
).toMatchInlineSnapshot(`
|
||||
"
|
||||
node scripts/my_cli [command] [...args]
|
||||
|
||||
Runs a dev task
|
||||
|
||||
Commands:
|
||||
foo [...names]
|
||||
Some thing that we wrote to help us execute things.
|
||||
|
||||
Example:
|
||||
|
||||
foo = bar = baz
|
||||
|
||||
Are you getting it?
|
||||
|
||||
Options:
|
||||
--foo Some flag
|
||||
--bar Another flag
|
||||
Secondary info
|
||||
--baz, -b Hey hello
|
||||
|
||||
bar [...names]
|
||||
Some other thing that we wrote to help us execute things.
|
||||
|
||||
Options:
|
||||
--baz, -b Hey hello
|
||||
|
||||
|
||||
Global options:
|
||||
--global-flag some flag that applies to all commands
|
||||
--verbose, -v Log verbosely
|
||||
--debug Log debug messages (less than verbose)
|
||||
--quiet Only log errors
|
||||
--silent Don't log anything
|
||||
--help Show this message
|
||||
|
||||
To show the help information about a specific command run:
|
||||
node scripts/my_cli help [command]
|
||||
|
||||
"
|
||||
`);
|
||||
});
|
||||
});
|
150
packages/kbn-dev-utils/src/run/help.ts
Normal file
150
packages/kbn-dev-utils/src/run/help.ts
Normal file
|
@ -0,0 +1,150 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import Path from 'path';
|
||||
|
||||
import 'core-js/features/string/repeat';
|
||||
import dedent from 'dedent';
|
||||
|
||||
import { Command } from './run_with_commands';
|
||||
|
||||
const DEFAULT_GLOBAL_USAGE = `node ${Path.relative(process.cwd(), process.argv[1])}`;
|
||||
export const GLOBAL_FLAGS = dedent`
|
||||
--verbose, -v Log verbosely
|
||||
--debug Log debug messages (less than verbose)
|
||||
--quiet Only log errors
|
||||
--silent Don't log anything
|
||||
--help Show this message
|
||||
`;
|
||||
|
||||
export function indent(str: string, depth: number) {
|
||||
const prefix = ' '.repeat(depth);
|
||||
return str
|
||||
.split('\n')
|
||||
.map((line, i) => `${i > 0 ? `\n${prefix}` : ''}${line}`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
export function joinAndTrimLines(...strings: Array<string | undefined>) {
|
||||
return strings.filter(Boolean).join('\n').split('\n').filter(Boolean).join(`\n`);
|
||||
}
|
||||
|
||||
export function getHelp({
|
||||
description,
|
||||
usage,
|
||||
flagHelp,
|
||||
}: {
|
||||
description?: string;
|
||||
usage?: string;
|
||||
flagHelp?: string;
|
||||
}) {
|
||||
const optionHelp = joinAndTrimLines(dedent(flagHelp || ''), GLOBAL_FLAGS);
|
||||
|
||||
return `
|
||||
${dedent(usage || '') || DEFAULT_GLOBAL_USAGE}
|
||||
|
||||
${indent(dedent(description || 'Runs a dev task'), 2)}
|
||||
|
||||
Options:
|
||||
${indent(optionHelp, 4)}\n\n`;
|
||||
}
|
||||
|
||||
export function getCommandLevelHelp({
|
||||
usage,
|
||||
globalFlagHelp,
|
||||
command,
|
||||
}: {
|
||||
usage?: string;
|
||||
globalFlagHelp?: string;
|
||||
command: Command<any>;
|
||||
}) {
|
||||
const globalUsage = dedent(usage || '') || DEFAULT_GLOBAL_USAGE;
|
||||
const globalHelp = joinAndTrimLines(dedent(globalFlagHelp || ''), GLOBAL_FLAGS);
|
||||
|
||||
const commandUsage = dedent(command.usage || '') || `${command.name} [...args]`;
|
||||
const commandFlags = joinAndTrimLines(dedent(command.flags?.help || ''));
|
||||
|
||||
return `
|
||||
${globalUsage} ${commandUsage}
|
||||
|
||||
${indent(dedent(command.description || 'Runs a dev task'), 2)}
|
||||
|
||||
Command-specific options:
|
||||
${indent(commandFlags, 4)}
|
||||
|
||||
Global options:
|
||||
${indent(globalHelp, 4)}
|
||||
|
||||
To see the help for other commands run:
|
||||
${globalUsage} help [command]
|
||||
|
||||
To see the list of commands run:
|
||||
${globalUsage} --help\n\n`;
|
||||
}
|
||||
|
||||
export function getHelpForAllCommands({
|
||||
description,
|
||||
usage,
|
||||
globalFlagHelp,
|
||||
commands,
|
||||
}: {
|
||||
description?: string;
|
||||
usage?: string;
|
||||
globalFlagHelp?: string;
|
||||
commands: Array<Command<any>>;
|
||||
}) {
|
||||
const globalUsage = dedent(usage || '') || DEFAULT_GLOBAL_USAGE;
|
||||
const globalHelp = joinAndTrimLines(dedent(globalFlagHelp || ''), GLOBAL_FLAGS);
|
||||
|
||||
const commandsHelp = commands
|
||||
.map((command) => {
|
||||
const options = command.flags?.help
|
||||
? '\n' +
|
||||
dedent`
|
||||
Options:
|
||||
${indent(
|
||||
joinAndTrimLines(dedent(command.flags?.help || '')),
|
||||
' '.length
|
||||
)}
|
||||
` +
|
||||
'\n'
|
||||
: '';
|
||||
|
||||
return [
|
||||
dedent(command.usage || '') || command.name,
|
||||
` ${indent(dedent(command.description || 'Runs a dev task'), 2)}`,
|
||||
...([indent(options, 2)] || []),
|
||||
].join('\n');
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return `
|
||||
${globalUsage} [command] [...args]
|
||||
|
||||
${indent(dedent(description || 'Runs a dev task'), 2)}
|
||||
|
||||
Commands:
|
||||
${indent(commandsHelp, 4)}
|
||||
|
||||
Global options:
|
||||
${indent(globalHelp, 4)}
|
||||
|
||||
To show the help information about a specific command run:
|
||||
${globalUsage} help [command]\n\n`;
|
||||
}
|
|
@ -17,6 +17,7 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
export { run } from './run';
|
||||
export { Flags } from './flags';
|
||||
export { createFailError, createFlagError, combineErrors, isFailError } from './fail';
|
||||
export * from './run';
|
||||
export * from './run_with_commands';
|
||||
export * from './flags';
|
||||
export * from './fail';
|
||||
|
|
|
@ -17,48 +17,37 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
import { inspect } from 'util';
|
||||
|
||||
// @ts-ignore @types are outdated and module is super simple
|
||||
import exitHook from 'exit-hook';
|
||||
|
||||
import { pickLevelFromFlags, ToolingLog, LogLevel } from '../tooling_log';
|
||||
import { createFlagError, isFailError } from './fail';
|
||||
import { Flags, getFlags, getHelp } from './flags';
|
||||
import { createFlagError } from './fail';
|
||||
import { Flags, getFlags, FlagOptions } from './flags';
|
||||
import { ProcRunner, withProcRunner } from '../proc_runner';
|
||||
import { getHelp } from './help';
|
||||
import { CleanupTask, Cleanup } from './cleanup';
|
||||
|
||||
type CleanupTask = () => void;
|
||||
type RunFn = (args: {
|
||||
export interface RunContext {
|
||||
log: ToolingLog;
|
||||
flags: Flags;
|
||||
procRunner: ProcRunner;
|
||||
addCleanupTask: (task: CleanupTask) => void;
|
||||
}) => Promise<void> | void;
|
||||
}
|
||||
export type RunFn = (context: RunContext) => Promise<void> | void;
|
||||
|
||||
export interface Options {
|
||||
export interface RunOptions {
|
||||
usage?: string;
|
||||
description?: string;
|
||||
log?: {
|
||||
defaultLevel?: LogLevel;
|
||||
};
|
||||
flags?: {
|
||||
allowUnexpected?: boolean;
|
||||
guessTypesForUnexpectedFlags?: boolean;
|
||||
help?: string;
|
||||
alias?: { [key: string]: string | string[] };
|
||||
boolean?: string[];
|
||||
string?: string[];
|
||||
default?: { [key: string]: any };
|
||||
};
|
||||
flags?: FlagOptions;
|
||||
}
|
||||
|
||||
export async function run(fn: RunFn, options: Options = {}) {
|
||||
const flags = getFlags(process.argv.slice(2), options);
|
||||
|
||||
if (flags.help) {
|
||||
process.stderr.write(getHelp(options));
|
||||
process.exit(1);
|
||||
}
|
||||
export async function run(fn: RunFn, options: RunOptions = {}) {
|
||||
const flags = getFlags(process.argv.slice(2), options.flags);
|
||||
const helpText = getHelp({
|
||||
description: options.description,
|
||||
usage: options.usage,
|
||||
flagHelp: options.flags?.help,
|
||||
});
|
||||
|
||||
const log = new ToolingLog({
|
||||
level: pickLevelFromFlags(flags, {
|
||||
|
@ -67,67 +56,33 @@ export async function run(fn: RunFn, options: Options = {}) {
|
|||
writeTo: process.stdout,
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (error) => {
|
||||
log.error('UNHANDLED PROMISE REJECTION');
|
||||
log.error(
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(`non-Error type rejection value: ${inspect(error)}`)
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
const handleErrorWithoutExit = (error: any) => {
|
||||
if (isFailError(error)) {
|
||||
log.error(error.message);
|
||||
|
||||
if (error.showHelp) {
|
||||
log.write(getHelp(options));
|
||||
}
|
||||
|
||||
process.exitCode = error.exitCode;
|
||||
} else {
|
||||
log.error('UNHANDLED ERROR');
|
||||
log.error(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
};
|
||||
|
||||
const doCleanup = () => {
|
||||
const tasks = cleanupTasks.slice(0);
|
||||
cleanupTasks.length = 0;
|
||||
|
||||
for (const task of tasks) {
|
||||
try {
|
||||
task();
|
||||
} catch (error) {
|
||||
handleErrorWithoutExit(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const unhookExit: CleanupTask = exitHook(doCleanup);
|
||||
const cleanupTasks: CleanupTask[] = [unhookExit];
|
||||
|
||||
try {
|
||||
if (!options.flags?.allowUnexpected && flags.unexpected.length) {
|
||||
throw createFlagError(`Unknown flag(s) "${flags.unexpected.join('", "')}"`);
|
||||
}
|
||||
|
||||
try {
|
||||
await withProcRunner(log, async (procRunner) => {
|
||||
await fn({
|
||||
log,
|
||||
flags,
|
||||
procRunner,
|
||||
addCleanupTask: (task: CleanupTask) => cleanupTasks.push(task),
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
doCleanup();
|
||||
}
|
||||
} catch (error) {
|
||||
handleErrorWithoutExit(error);
|
||||
if (flags.help) {
|
||||
log.write(helpText);
|
||||
process.exit();
|
||||
}
|
||||
|
||||
const cleanup = Cleanup.setup(log, helpText);
|
||||
|
||||
if (!options.flags?.allowUnexpected && flags.unexpected.length) {
|
||||
const error = createFlagError(`Unknown flag(s) "${flags.unexpected.join('", "')}"`);
|
||||
cleanup.execute(error);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await withProcRunner(log, async (procRunner) => {
|
||||
await fn({
|
||||
log,
|
||||
flags,
|
||||
procRunner,
|
||||
addCleanupTask: cleanup.add.bind(cleanup),
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
cleanup.execute(error);
|
||||
// process.exitCode is set by `cleanup` when necessary
|
||||
process.exit();
|
||||
} finally {
|
||||
cleanup.execute();
|
||||
}
|
||||
}
|
||||
|
|
77
packages/kbn-dev-utils/src/run/run_with_commands.test.ts
Normal file
77
packages/kbn-dev-utils/src/run/run_with_commands.test.ts
Normal file
|
@ -0,0 +1,77 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { RunWithCommands } from './run_with_commands';
|
||||
import { ToolingLog, ToolingLogCollectingWriter } from '../tooling_log';
|
||||
import { ProcRunner } from '../proc_runner';
|
||||
|
||||
const testLog = new ToolingLog();
|
||||
const testLogWriter = new ToolingLogCollectingWriter();
|
||||
testLog.setWriters([testLogWriter]);
|
||||
|
||||
const testCli = new RunWithCommands({
|
||||
usage: 'node scripts/test_cli [...options]',
|
||||
description: 'test cli',
|
||||
extendContext: async () => {
|
||||
return {
|
||||
extraContext: true,
|
||||
};
|
||||
},
|
||||
globalFlags: {
|
||||
boolean: ['some-bool'],
|
||||
help: `
|
||||
--some-bool description
|
||||
`,
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
process.argv = ['node', 'scripts/test_cli', 'foo', '--some-bool'];
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('extends the context using extendContext()', async () => {
|
||||
const context: any = await new Promise((resolve) => {
|
||||
testCli.command({ name: 'foo', description: 'some command', run: resolve }).execute();
|
||||
});
|
||||
|
||||
expect(context).toEqual({
|
||||
log: expect.any(ToolingLog),
|
||||
flags: expect.any(Object),
|
||||
addCleanupTask: expect.any(Function),
|
||||
procRunner: expect.any(ProcRunner),
|
||||
extraContext: true,
|
||||
});
|
||||
|
||||
expect(context.flags).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"_": Array [
|
||||
"foo",
|
||||
],
|
||||
"debug": false,
|
||||
"help": false,
|
||||
"quiet": false,
|
||||
"silent": false,
|
||||
"some-bool": true,
|
||||
"unexpected": Array [],
|
||||
"v": false,
|
||||
"verbose": false,
|
||||
}
|
||||
`);
|
||||
});
|
136
packages/kbn-dev-utils/src/run/run_with_commands.ts
Normal file
136
packages/kbn-dev-utils/src/run/run_with_commands.ts
Normal file
|
@ -0,0 +1,136 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { ToolingLog, pickLevelFromFlags } from '../tooling_log';
|
||||
import { RunContext, RunOptions } from './run';
|
||||
import { getFlags, FlagOptions, mergeFlagOptions } from './flags';
|
||||
import { Cleanup } from './cleanup';
|
||||
import { getHelpForAllCommands, getCommandLevelHelp } from './help';
|
||||
import { createFlagError } from './fail';
|
||||
import { withProcRunner } from '../proc_runner';
|
||||
|
||||
export type CommandRunFn<T> = (context: RunContext & T) => Promise<void> | void;
|
||||
|
||||
export interface Command<T> {
|
||||
name: string;
|
||||
run: CommandRunFn<T>;
|
||||
description: RunOptions['description'];
|
||||
usage?: RunOptions['usage'];
|
||||
flags?: FlagOptions;
|
||||
}
|
||||
|
||||
export interface RunWithCommandsOptions<T> {
|
||||
log?: RunOptions['log'];
|
||||
description?: RunOptions['description'];
|
||||
usage?: RunOptions['usage'];
|
||||
globalFlags?: FlagOptions;
|
||||
extendContext?(context: RunContext): Promise<T> | T;
|
||||
}
|
||||
|
||||
export class RunWithCommands<T> {
|
||||
constructor(
|
||||
private readonly options: RunWithCommandsOptions<T>,
|
||||
private readonly commands: Array<Command<T>> = []
|
||||
) {}
|
||||
|
||||
command(options: Command<T>) {
|
||||
return new RunWithCommands(this.options, this.commands.concat(options));
|
||||
}
|
||||
|
||||
async execute() {
|
||||
const globalFlags = getFlags(process.argv.slice(2), {
|
||||
allowUnexpected: true,
|
||||
});
|
||||
|
||||
const isHelpCommand = globalFlags._[0] === 'help';
|
||||
const commandName = isHelpCommand ? globalFlags._[1] : globalFlags._[0];
|
||||
const command = this.commands.find((c) => c.name === commandName);
|
||||
const log = new ToolingLog({
|
||||
level: pickLevelFromFlags(globalFlags, {
|
||||
default: this.options.log?.defaultLevel,
|
||||
}),
|
||||
writeTo: process.stdout,
|
||||
});
|
||||
|
||||
const globalHelp = getHelpForAllCommands({
|
||||
description: this.options.description,
|
||||
usage: this.options.usage,
|
||||
globalFlagHelp: this.options.globalFlags?.help,
|
||||
commands: this.commands,
|
||||
});
|
||||
const cleanup = Cleanup.setup(log, globalHelp);
|
||||
|
||||
if (!command) {
|
||||
if (globalFlags.help) {
|
||||
log.write(globalHelp);
|
||||
process.exit();
|
||||
}
|
||||
|
||||
const error = createFlagError(
|
||||
commandName ? `unknown command [${commandName}]` : `missing command name`
|
||||
);
|
||||
cleanup.execute(error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const commandFlagOptions = mergeFlagOptions(this.options.globalFlags, command.flags);
|
||||
const commandFlags = getFlags(process.argv.slice(2), commandFlagOptions);
|
||||
const commandHelp = getCommandLevelHelp({
|
||||
usage: this.options.usage,
|
||||
globalFlagHelp: this.options.globalFlags?.help,
|
||||
command,
|
||||
});
|
||||
cleanup.helpText = commandHelp;
|
||||
|
||||
if (commandFlags.help || isHelpCommand) {
|
||||
cleanup.execute();
|
||||
log.write(commandHelp);
|
||||
process.exit();
|
||||
}
|
||||
|
||||
if (!commandFlagOptions.allowUnexpected && commandFlags.unexpected.length) {
|
||||
cleanup.execute(createFlagError(`Unknown flag(s) "${commandFlags.unexpected.join('", "')}"`));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await withProcRunner(log, async (procRunner) => {
|
||||
const context: RunContext = {
|
||||
log,
|
||||
flags: commandFlags,
|
||||
procRunner,
|
||||
addCleanupTask: cleanup.add,
|
||||
};
|
||||
|
||||
const extendedContext = {
|
||||
...context,
|
||||
...(this.options.extendContext ? await this.options.extendContext(context) : ({} as T)),
|
||||
};
|
||||
|
||||
await command.run(extendedContext);
|
||||
});
|
||||
} catch (error) {
|
||||
cleanup.execute(error);
|
||||
// exitCode is set by `cleanup` when necessary
|
||||
process.exit();
|
||||
} finally {
|
||||
cleanup.execute();
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue