diff --git a/scripts/jest-scripts.config.ts b/scripts/jest-scripts.config.ts index 8909304f2e5..447132937f3 100644 --- a/scripts/jest-scripts.config.ts +++ b/scripts/jest-scripts.config.ts @@ -39,6 +39,7 @@ const config: Config = { '!reports/**', '!run-once-scripts/**', '!set-maintenance-mode-locally.ts', + '!template.ts', '!upload-practitioner-application-packages.ts', '!user/**', '!postgres/**', diff --git a/scripts/reports/reportUtils.md b/scripts/reports/reportUtils.md new file mode 100644 index 00000000000..deee426de8f --- /dev/null +++ b/scripts/reports/reportUtils.md @@ -0,0 +1,73 @@ +## Argument Parser for Shell Scripts Written in TypeScript + +The argument parser is a wrapper of the node:utils `parseArgs` method that aims to standardize how we write shell scripts in TypeScript and centralize text transformation of parsed arguments. + +### How to Use the Argument Parser in a Shell Script + +Let's say you want the `$1` (first positional) argument to be required and you want to optionally support two additional arguments. Now you can define a `ScriptConfig` object at the top of your script: + +```typescript +const scriptConfig: ScriptConfig = { + description: 'some-script.ts - works wonders', + parameters: { + eventCode: { + position: 0, + required: true, + type: 'string', + }, + fiscal: { + default: false, + short: 'f', + type: 'boolean', + }, + year: { + default: '2024', + short: 'y', + transform: 'number', + type: 'string', + }, + }, +}; +``` + +And then elsewhere in the script you can get the values from the argument parser thusly: + +```typescript +const { eventCode, fiscal, verbose, year } = parseArguments(scriptConfig); +``` + +### In Action + +Given the configuration above, say you call your script like so: + +```bash +npx ts-node --transpile-only scripts/some-script.ts NOA -f +``` + +The argument parser will return the following: + +```typescript +{ + eventCode: 'NOA', + fiscal: true, + year: 2024, +}; +``` + +### Self-Documenting + +All scripts that utilize the argument parser will get a `--help` flag that, when provided, will output usage information automatically generated from the `ScriptConfig` configuration object. + +Again, given the configuration above, say you call your script like so: + +```bash +npx ts-node --transpile-only scripts/some-script.ts --help +``` + +The argument parser will print the following: + +``` +some-script.ts - works wonders + +Usage: some-script.ts [ -f -y ] +``` diff --git a/scripts/reports/reportUtils.test.ts b/scripts/reports/reportUtils.test.ts index 3ae6473f068..2b6c2a199c4 100644 --- a/scripts/reports/reportUtils.test.ts +++ b/scripts/reports/reportUtils.test.ts @@ -1,4 +1,40 @@ -import { parseIntRange, parseInts, parseIntsArg } from './reportUtils'; +import { + type ScriptConfig, + parseArguments, + parseIntRange, + parseInts, + parseIntsArg, +} from './reportUtils'; +import { cloneDeep } from 'lodash'; + +const mockScriptConfig: ScriptConfig = { + description: 'some script', + parameters: { + eventCode: { + position: 0, + required: true, + type: 'string', + }, + fiscal: { + default: false, + short: 'f', + type: 'boolean', + }, + year: { + default: ['2024'], + multiple: true, + short: 'y', + transform: 'number', + type: 'string', + }, + }, +}; + +const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => { + // prevent upstream from continuing by throwing an error + throw new Error('caught process.exit'); +}); +const mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(jest.fn()); describe('parseIntsRange', () => { it('returns array when given valid ranges', () => { @@ -52,4 +88,295 @@ describe('parseIntsArg', () => { it('handles int lists', () => { expect(parseIntsArg('1,2,3')).toEqual([1, 2, 3]); }); + + it('handles a mix of int lists and ranges', () => { + expect(parseIntsArg('1,3-5,7-9')).toEqual([1, 3, 4, 5, 7, 8, 9]); + }); +}); + +describe('parseArguments', () => { + const originalArgv = cloneDeep(process.argv); + const mockEventCode = 'noa'; + beforeEach(() => { + process.argv = ['ts-node', 'some-script.ts', mockEventCode]; + }); + afterAll(() => { + process.argv = originalArgv; + }); + describe('--help flag', () => { + it('prints help output and exits before validating parameters', () => { + process.argv = ['ts-node', 'some-script.ts', '-h']; + try { + parseArguments(mockScriptConfig); + } catch (err: any) { + expect(err.toString()).toEqual('Error: caught process.exit'); + } + expect(mockConsoleLog).toHaveBeenCalledTimes(3); + expect(mockExit).toHaveBeenCalledWith(0); + }); + it('generates a usage example from provided configuration', () => { + process.argv = ['ts-node', 'some-script.ts', '-h']; + try { + parseArguments(mockScriptConfig); + } catch (err: any) { + expect(err.toString()).toEqual('Error: caught process.exit'); + } + expect(mockConsoleLog).toHaveBeenNthCalledWith( + 2, + 'Usage: some-script.ts [ -f -y -v ]\n', + ); + }); + }); + describe('--verbose flag', () => { + it('prints verbose output after validating parameters and does not exit', () => { + process.argv.push('-v'); + const { eventCode, verbose } = parseArguments(mockScriptConfig); + expect(eventCode).toEqual(mockEventCode); // parameters are parsed + expect(verbose).toBeTruthy(); + expect(mockConsoleLog).toHaveBeenNthCalledWith( + 1, + 'Verbose output enabled\n', + ); + expect(mockConsoleLog).toHaveBeenCalledTimes(7); + expect(mockExit).not.toHaveBeenCalled(); + }); + }); + describe('parameter validation', () => { + it('does not allow a boolean parameter to be defaulted to true', () => { + const itsScriptConfig = cloneDeep(mockScriptConfig); + itsScriptConfig.parameters.fiscal.required = true; + const { fiscal } = parseArguments(itsScriptConfig); + expect(fiscal).toBeFalsy(); + expect(mockExit).not.toHaveBeenCalled(); + }); + it('positionals that precede a required positional will also be required', () => { + const itsScriptConfig = cloneDeep(mockScriptConfig); + itsScriptConfig.parameters.eventCode.required = false; + itsScriptConfig.parameters.judge = { + position: 1, + required: false, + type: 'string', + }; + itsScriptConfig.parameters.status = { + position: 2, + required: true, + type: 'string', + }; + try { + parseArguments(itsScriptConfig); + } catch (err: any) { + expect(err.toString()).toEqual('Error: caught process.exit'); + } + expect(mockConsoleLog).toHaveBeenNthCalledWith( + 1, + 'invalid input: expected judge\n', + ); + expect(mockExit).toHaveBeenCalledWith(1); + }); + it('does not allow positionals that are not sequential', () => { + const itsScriptConfig = cloneDeep(mockScriptConfig); + itsScriptConfig.parameters.fiscal = { + position: 1, + required: false, + type: 'string', + }; + itsScriptConfig.parameters.year = { + position: 2, + required: false, + type: 'string', + }; + itsScriptConfig.parameters.judge = { + position: 5, + required: false, + type: 'string', + }; + try { + parseArguments(itsScriptConfig); + } catch (err: any) { + expect(err.toString()).toEqual('Error: caught process.exit'); + } + expect(mockConsoleLog).toHaveBeenNthCalledWith( + 1, + 'invalid positionals: positions must be sequential starting at 0\n', + ); + expect(mockExit).toHaveBeenCalledWith(1); + }); + it('does not allow positionals that do not start at 0', () => { + const itsScriptConfig = cloneDeep(mockScriptConfig); + itsScriptConfig.parameters.eventCode.position = 20; + itsScriptConfig.parameters.fiscal = { + position: 21, + required: false, + type: 'string', + }; + itsScriptConfig.parameters.year = { + position: 22, + required: false, + type: 'string', + }; + itsScriptConfig.parameters.judge = { + position: 23, + required: false, + type: 'string', + }; + try { + parseArguments(itsScriptConfig); + } catch (err: any) { + expect(err.toString()).toEqual('Error: caught process.exit'); + } + expect(mockConsoleLog).toHaveBeenNthCalledWith( + 1, + 'invalid positionals: positions must be sequential starting at 0\n', + ); + expect(mockExit).toHaveBeenCalledWith(1); + }); + it('exits if required positionals were not provided', () => { + const itsScriptConfig = cloneDeep(mockScriptConfig); + itsScriptConfig.parameters.judge = { + position: 1, + required: true, + type: 'string', + }; + try { + parseArguments(itsScriptConfig); + } catch (err: any) { + expect(err.toString()).toEqual('Error: caught process.exit'); + } + expect(mockConsoleLog).toHaveBeenNthCalledWith( + 1, + 'invalid input: expected judge\n', + ); + expect(mockExit).toHaveBeenCalledWith(1); + }); + it('exits if required parameters were not provided', () => { + const itsScriptConfig = cloneDeep(mockScriptConfig); + itsScriptConfig.parameters.judge = { + required: true, + short: 'j', + type: 'string', + }; + try { + parseArguments(itsScriptConfig); + } catch (err: any) { + expect(err.toString()).toEqual('Error: caught process.exit'); + } + expect(mockConsoleLog).toHaveBeenNthCalledWith( + 1, + 'invalid input: expected judge\n', + ); + expect(mockExit).toHaveBeenCalledWith(1); + }); + }); + describe('value transformation', () => { + describe('number', () => { + it('transforms a string into a number', () => { + const itsScriptConfig = cloneDeep(mockScriptConfig); + itsScriptConfig.parameters.year.default = '2024'; + itsScriptConfig.parameters.year.multiple = false; + process.argv.push(...['-y', '2018']); + const { year } = parseArguments(itsScriptConfig); + expect(year).toEqual(2018); + }); + it('transforms an array of strings into an array of numbers', () => { + process.argv.push(...['-y', '2020', '-y', '2024']); + const { year } = parseArguments(mockScriptConfig); + expect(year).toEqual([2020, 2024]); + }); + it( + 'transforms a string containing comma-delimited integers and integer ' + + 'ranges into a sorted array of unique integers', + () => { + process.argv.push(...['-y', '8,12,3-5,1,7-9']); + const { year } = parseArguments(mockScriptConfig); + expect(year).toEqual([1, 3, 4, 5, 7, 8, 9, 12]); + }, + ); + }); + describe('toLowerCase', () => { + it('transforms a string to lower case', () => { + const itsScriptConfig = cloneDeep(mockScriptConfig); + itsScriptConfig.parameters.eventCode.transform = 'toLowerCase'; + process.argv = ['ts-node', 'some-script.ts', 'FEEW']; + const { eventCode } = parseArguments(itsScriptConfig); + expect(eventCode).toEqual('feew'); + }); + }); + describe('toUpperCase', () => { + it('transforms a string to upper case', () => { + const itsScriptConfig = cloneDeep(mockScriptConfig); + itsScriptConfig.parameters.eventCode.transform = 'toUpperCase'; + const { eventCode } = parseArguments(itsScriptConfig); + expect(eventCode).toEqual('NOA'); + }); + it('transforms a comma-delimited string into an array of upper case strings', () => { + const itsScriptConfig = cloneDeep(mockScriptConfig); + itsScriptConfig.parameters.eventCode.commaDelimited = true; + itsScriptConfig.parameters.eventCode.transform = 'toUpperCase'; + process.argv = ['ts-node', 'some-script.ts', 'm071,m074']; + const { eventCode } = parseArguments(itsScriptConfig); + expect(eventCode).toEqual(['M071', 'M074']); + }); + }); + }); + describe('ScriptParameter properties', () => { + describe('commaDelimited', () => { + it('splits a comma-delimited string into an array of strings', () => { + const itsScriptConfig = cloneDeep(mockScriptConfig); + itsScriptConfig.parameters.eventCode.commaDelimited = true; + process.argv = ['ts-node', 'some-script.ts', 'M071,M074,FEEW']; + const { eventCode } = parseArguments(itsScriptConfig); + expect(eventCode).toEqual(['M071', 'M074', 'FEEW']); + }); + }); + describe('long', () => { + it("allows a parameter's long form to differ from its resulting parsed key", () => { + const itsScriptConfig = cloneDeep(mockScriptConfig); + itsScriptConfig.parameters.eventCode = { + long: 'event-code', + short: 'c', + type: 'string', + }; + process.argv = ['ts-node', 'some-script.ts', '--event-code', 'NOA']; + const { eventCode } = parseArguments(itsScriptConfig); + expect(eventCode).toEqual('NOA'); + }); + }); + describe('multiple', () => { + it( + 'compiles a single flat array containing all members when multiple ' + + 'sets of comma-delimited values are provided', + () => { + const itsScriptConfig = cloneDeep(mockScriptConfig); + itsScriptConfig.parameters.eventCode = { + commaDelimited: true, + long: 'event-code', + multiple: true, + short: 'c', + type: 'string', + }; + process.argv = [ + 'ts-node', + 'some-script.ts', + '--event-code', + 'M01,M02', + '--event-code', + 'M042', + '-c', + 'M071,M074', + ]; + const { eventCode } = parseArguments(itsScriptConfig); + expect(eventCode).toEqual(['M01', 'M02', 'M042', 'M071', 'M074']); + }, + ); + it( + 'compiles a single flat array containing all members when multiple ' + + 'sets of integer ranges are provided', + () => { + process.argv.push(...['-y', '2018,2020', '-y', '2022-2024']); + const { year } = parseArguments(mockScriptConfig); + expect(year).toEqual([2018, 2020, 2022, 2023, 2024]); + }, + ); + }); + }); }); diff --git a/scripts/reports/reportUtils.ts b/scripts/reports/reportUtils.ts index 0642abe4d11..bb49bff92c2 100644 --- a/scripts/reports/reportUtils.ts +++ b/scripts/reports/reportUtils.ts @@ -1,12 +1,85 @@ -export function parseInts(ints: string, delimiter = ','): number[] { - let nums = ints +import { type ParseArgsConfig, parseArgs } from 'node:util'; + +export type ScriptConfig = { + /** + * The `description` will be printed to the console when errors are + * encountered or when the `--help` or `--verbose` parameters are provided. + */ + description?: string; + parameters: { + /** + * If the `long` and `position` properties are not defined in the + * `ScriptParameter` object, the parameter is called by its key + * prefixed with two dashes (e.g. `--year`). This key will also be used + * when retrieving this parameter's parsed value with `parseArguments`. + */ + [key: string]: ScriptParameter; + }; +}; + +export type ScriptParameter = { + /** + * Only compatible with 'string' types, the `commaDelimited` property + * indicates that the provided value might be several comma-delimited + * values. When `true`, the value returned by `parseArguments` will + * always be an array, even if only one value was provided. + */ + commaDelimited?: boolean; + description?: string; + /** + * If no value is provided for this parameter, `parseArguments` will + * return this default value. + */ + default?: string | boolean | string[]; + /** + * The `long` property is only necessary if you want to call the parameter + * (e.g. `--event-code`) differently from how want you get it back + * (e.g. `eventCode`) from `parseArguments`. + */ + long?: string; + /** + * The `multiple` property indicates that this parameter may be provided + * more than once. When `true`, the value returned by `parseArguments` + * will always be an array, even if only one value was provided. + */ + multiple?: boolean; + /** + * Only compatible with 'string' types, the `position` property indicates + * the order in which this parameter appears. + */ + position?: number; + /** + * Only compatible with 'string' types, the `required` property indicates + * that a value is required to be provided for this parameter. + */ + required?: boolean; + /** + * The `short` property allows this parameter to optionally be called + * prefixed with a single dash (e.g. `-t`). + */ + short?: string; + /** + * Only compatible with 'string' types, the `transform` property indicates + * how the value(s) should be transformed by `parseArguments` before they + * are returned. + */ + transform?: 'number' | 'toLowerCase' | 'toUpperCase'; + /** + * The `type` property indicates to `parseArguments` how to expect to + * find the provided value. For 'boolean' types, the presence or absence + * of the parameter indicates 'true' or 'false', respectively. + */ + type: 'string' | 'boolean'; +}; + +export const parseInts = (ints: string, delimiter = ','): number[] => { + return ints .split(delimiter) .filter(s => s.length) .map(s => parseInt(s)); - return nums; -} +}; -export function parseIntRange(intRange: string): number[] { +export const parseIntRange = (intRange: string): number[] => { const ints = intRange .split('-') .filter(s => s.length) @@ -18,18 +91,381 @@ export function parseIntRange(intRange: string): number[] { rangeNums.push(i); } return rangeNums; -} - -// eslint-disable-next-line spellcheck/spell-checker -/** - * - * @param intstr a string containing an int (e.g. '1'), list of ints(e.g. '1,2,3'), or inclusive int range (e.g. '1-3') - * @returns an array of one or more integers - */ -export function parseIntsArg(intstr: string): number[] { - if (intstr.indexOf('-') > 0) { - return parseIntRange(intstr); +}; + +// supports: +// a string containing an integer (e.g. '1') +// comma-delimited list of integers (e.g. '1,2,3') +// inclusive range of integers (e.g. '1-3') +// mix of comma-delimited list and range of integers (e.g. '1,3-5,7-9') +export const parseIntsArg = (intstr: string): number[] => { + const ints: number[] = []; + const commaDelimitedSegments = intstr.split(',').filter(s => s.length); + for (const segment of commaDelimitedSegments) { + if (segment.indexOf('-') > 0) { + ints.push(...parseIntRange(segment)); + } else { + ints.push(parseInt(segment)); + } + } + return [...new Set(ints.sort((a, b) => a - b))]; +}; + +const collateArguments = (parameters: { + [key: string]: ScriptParameter; +}): { + requiredPositionals: ScriptParameter[]; + requiredParameters: ScriptParameter[]; + optionalPositionals: ScriptParameter[]; + optionalParameters: ScriptParameter[]; +} => { + const allPositionals: ScriptParameter[] = []; + const requiredParameters: ScriptParameter[] = []; + const optionalParameters: ScriptParameter[] = []; + for (const varName in parameters) { + const paramConfig = parameters[varName]; + if ( + 'position' in paramConfig && + typeof paramConfig.position !== 'undefined' + ) { + allPositionals.push({ ...paramConfig, long: varName }); + } else { + if (paramConfig.required && paramConfig.type === 'string') { + requiredParameters.push({ ...paramConfig, long: varName }); + } else { + optionalParameters.push({ ...paramConfig, long: varName }); + } + } + } + const reverseSortedRequiredPositionals: ScriptParameter[] = []; + const reverseSortedOptionalPositionals: ScriptParameter[] = []; + const reverseSortedPositionals = allPositionals.sort( + (a, b) => (b.position || 0) - (a.position || 0), + ); + let requiredOverride = false; + for (const positional of reverseSortedPositionals) { + if (positional.required || requiredOverride) { + requiredOverride = true; + reverseSortedRequiredPositionals.push(positional); + } else { + reverseSortedOptionalPositionals.push(positional); + } + } + const requiredPositionals = reverseSortedRequiredPositionals + .sort((a, b) => (a.position || 0) - (b.position || 0)) + .map(p => ({ ...p, required: true })); + const optionalPositionals = reverseSortedOptionalPositionals.sort( + (a, b) => (a.position || 0) - (b.position || 0), + ); + return { + optionalParameters, + optionalPositionals, + requiredParameters, + requiredPositionals, + }; +}; + +const buildExample = (parameters: { + [key: string]: ScriptParameter; +}): string => { + const { + optionalParameters, + optionalPositionals, + requiredParameters, + requiredPositionals, + } = collateArguments(parameters); + let example = `${process.argv[1]}`; + if (requiredPositionals.length) { + example += requiredPositionals.map(p => ` <${p.long!}>`).join(''); + } + if (requiredParameters.length) { + example += requiredParameters + .map( + p => + ` ${p.short ? '-' + p.short : '--' + p.long!}${p.type !== 'boolean' ? ' <' + p.long! + '>' : ''}`, + ) + .join(''); + } + if (optionalPositionals.length || optionalParameters.length) { + example += ' ['; + if (optionalPositionals.length) { + example += optionalPositionals.map(p => ` <${p.long}>`).join(''); + } + if (optionalParameters.length) { + example += optionalParameters + .map( + p => + ` ${p.short ? '-' + p.short : '--' + p.long!}${p.type !== 'boolean' ? ' <' + p.long! + '>' : ''}`, + ) + .join(''); + } + example += ' ]'; + } + return example; +}; + +const usage = (sc: ScriptConfig, warning?: string): void => { + const example = buildExample(sc.parameters); + if (warning) { + console.log(`${warning}\n`); + } + if (sc.description?.length) { + console.log(`${sc.description}\n`); + } + console.log(`Usage: ${example}\n`); + console.log('Options:', sc.parameters); +}; + +const buildParseArgsConfigObject = (parameters: { + [key: string]: ScriptParameter; +}): ParseArgsConfig => { + const options = { + help: { + default: false, + short: 'h', + type: 'boolean', + }, + verbose: { + default: false, + short: 'v', + type: 'boolean', + }, + } as const; + const argConfig = { + allowPositionals: false, + options, + strict: true, + }; + for (const varName in parameters) { + const paramConfig = parameters[varName]; + const { multiple, short, type } = paramConfig; + const defaultValue = type === 'boolean' ? false : paramConfig.default; + // parseArgs doesn't distinguish longName from varName like we do + const param = paramConfig.long?.length ? paramConfig.long : varName; + if ( + 'position' in paramConfig && + typeof paramConfig.position !== 'undefined' + ) { + argConfig.allowPositionals = true; + } + const paramOptions = { type }; + if (defaultValue) { + paramOptions['default'] = defaultValue; + } + if (multiple) { + paramOptions['multiple'] = multiple; + } + if (short) { + paramOptions['short'] = short; + } + options[param] = { ...paramOptions } as const; + } + return { ...argConfig } as const as ParseArgsConfig; +}; + +const showHelpAndVerbose = ( + sc: ScriptConfig, + positionals: string[], + values: { + [k: string]: string | boolean | (string | boolean)[] | undefined; + }, +): void => { + if (values.verbose) { + usage(sc, 'Verbose output enabled'); + console.log('positionals:', positionals); + console.log('values:', values); + } + if (values.help) { + if (!values.verbose) { + usage(sc); + } + process.exit(0); + } +}; + +const rawParseArgs = ( + config: ParseArgsConfig, + sc: ScriptConfig, +): { + positionals: string[]; + values: { + [k: string]: string | boolean | (string | boolean)[] | undefined; + }; +} => { + let positionals: string[]; + let values: { + [k: string]: string | boolean | (string | boolean)[] | undefined; + }; + + // the arguments were cached at the time we imported node:util + // to facilitate testing we'll set them now in case they've changed + config.args = process.argv.slice(2); + + try { + ({ positionals, values } = parseArgs(config)); + } catch (ex) { + usage(sc, `Error: ${ex}`); + process.exit(1); + } + return { positionals, values }; +}; + +const splitValueIntoArrayOfStrings = ( + value: string | number | (string | boolean | number)[], + commaDelimited: boolean | undefined, +): string[] => { + const strings: string[] = []; + if (typeof value === 'string') { + value = [value]; + } + if (typeof value === 'number') { + value = [`${value}`]; + } + for (const aVal of value) { + if (typeof aVal === 'string') { + if (commaDelimited) { + strings.push(...aVal.split(',')); + } else { + strings.push(aVal); + } + } else if (typeof aVal === 'number') { + strings.push(`${aVal}`); + } + } + return strings; +}; + +const transformStrings = ( + strings: string[], + transform?: string, +): (string | number)[] => { + const transformed: (number | string)[] = []; + if (transform) { + for (const aVal of strings) { + switch (transform) { + case 'number': + transformed.push(...parseIntsArg(aVal)); + break; + case 'toLowerCase': + transformed.push(aVal.toLowerCase()); + break; + case 'toUpperCase': + transformed.push(aVal.toUpperCase()); + break; + } + } } else { - return parseInts(intstr); + transformed.push(...strings); + } + return transformed; +}; + +const buildStringValue = ( + value: string | number | (string | number | boolean)[], + paramConfig: ScriptParameter, +) => { + const strings = splitValueIntoArrayOfStrings( + value, + paramConfig.commaDelimited, + ); + let returnVal: string | number | (string | number)[] = transformStrings( + strings, + paramConfig.transform, + ); + if (!paramConfig.commaDelimited && !paramConfig.multiple) { + returnVal = returnVal[0]; + } + return returnVal; +}; + +const parseAndTransformValues = ( + sc: ScriptConfig, + positionals: string[], + values: { + [k: string]: string | boolean | (string | boolean)[] | undefined; + }, +): { [k: string]: string | string[] | boolean | number | number[] } => { + const parsedParameters = {}; + for (const varName in sc.parameters) { + const paramConfig = sc.parameters[varName]; + // parseArgs doesn't distinguish longName from varName like we do + const longName = paramConfig.long?.length ? paramConfig.long : varName; + let value: + | string + | boolean + | number + | (string | boolean | number)[] + | undefined = paramConfig.default; + if ( + 'position' in paramConfig && + typeof paramConfig.position !== 'undefined' + ) { + value = positionals[paramConfig.position]; + } else { + if (longName in values) { + value = values[longName]; + } + } + if (paramConfig.type === 'string' && value && typeof value !== 'boolean') { + value = buildStringValue(value, paramConfig); + } + parsedParameters[varName] = value; + } + return parsedParameters; +}; + +const validateParsedValues = ( + sc: ScriptConfig, + parsedValues: { + [k: string]: string | string[] | boolean | number | number[]; + }, + verbose: boolean, +): void => { + const showErrorAndExit = (errorMessage: string): void => { + if (verbose) { + console.log(errorMessage); + } else { + usage(sc, errorMessage); + } + process.exit(1); + }; + const { optionalPositionals, requiredParameters, requiredPositionals } = + collateArguments(sc.parameters); + const allPositionals = [...requiredPositionals, ...optionalPositionals]; + if (allPositionals.length) { + const positionsReversed = [...requiredPositionals, ...optionalPositionals] + .map(p => p.position) + .filter(p => p || p === 0) + .sort((a, b) => b! - a!); + const uniquePositions = [...new Set(positionsReversed)]; + if ( + uniquePositions.length !== positionsReversed.length || + positionsReversed[0] !== uniquePositions.length - 1 + ) { + showErrorAndExit( + 'invalid positionals: positions must be sequential starting at 0', + ); + } + } + for (const requiredParam of [...requiredPositionals, ...requiredParameters]) { + const longName = requiredParam.long!; + if (!(longName in parsedValues) || !parsedValues[longName]) { + showErrorAndExit(`invalid input: expected ${longName}`); + } + } +}; + +export const parseArguments = ( + sc: ScriptConfig, +): { [k: string]: string | string[] | boolean | number | number[] } => { + sc.parameters.verbose = { default: false, short: 'v', type: 'boolean' }; + const config = buildParseArgsConfigObject(sc.parameters); + const { positionals, values } = rawParseArgs(config, sc); + showHelpAndVerbose(sc, positionals, values); + const parsedParameters = parseAndTransformValues(sc, positionals, values); + if (parsedParameters.verbose) { + console.log('parsed arguments:', parsedParameters); } -} + validateParsedValues(sc, parsedParameters, !!parsedParameters.verbose); + return parsedParameters; +}; diff --git a/scripts/template.ts b/scripts/template.ts new file mode 100755 index 00000000000..336dcec46c0 --- /dev/null +++ b/scripts/template.ts @@ -0,0 +1,59 @@ +#!/usr/bin/env npx ts-node --transpile-only + +import { type ScriptConfig, parseArguments } from './reports/reportUtils'; +import { + type ServerApplicationContext, + createApplicationContext, +} from '@web-api/applicationContext'; +import { requireEnvVars } from '../shared/admin-tools/util'; + +requireEnvVars(['ENV', 'REGION']); + +// Example: +// scripts/template.ts m073,m074 --fiscal -y 2018 --year 2020,2022-2024 +const scriptConfig: ScriptConfig = { + description: 'TypeScript Shell Script Template', + parameters: { + eventCodes: { + commaDelimited: true, + position: 0, + required: true, + transform: 'toUpperCase', + type: 'string', + }, + fiscal: { + default: false, + short: 'f', + type: 'boolean', + }, + years: { + default: ['2024'], + multiple: true, + short: 'y', + transform: 'number', + type: 'string', + }, + }, +}; +// Example: +// { +// eventCode: [ 'M073', 'M074' ], +// fiscal: true, +// verbose: false, +// year: [ 2018, 2020, 2022, 2023, 2024 ] +// } +const { eventCodes, fiscal, verbose, years } = parseArguments(scriptConfig) as { + eventCodes: string[]; + fiscal: boolean; + verbose: boolean; + years: number[]; +}; + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +(async () => { + const applicationContext: ServerApplicationContext = createApplicationContext( + {}, + ); + console.log({ eventCodes, fiscal, verbose, years }); + console.log(applicationContext.environment.stage); +})();