[kbn/dev-utils] add RunWithCommands utility (#72311)

Co-authored-by: spalger <spalger@users.noreply.github.com>
This commit is contained in:
Spencer 2020-07-17 13:53:54 -07:00 committed by GitHub
parent 5356941f22
commit 466380e3b6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 749 additions and 144 deletions

View file

@ -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';

View 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;
}
}
}

View file

@ -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 {

View file

@ -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'}`;
}

View 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]
"
`);
});
});

View 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`;
}

View file

@ -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';

View file

@ -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();
}
}

View 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,
}
`);
});

View 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();
}
}
}