From 99ecf495db5c35892fa6bb48422d0996e01a74a9 Mon Sep 17 00:00:00 2001 From: Sukairo-02 Date: Tue, 5 Nov 2024 15:04:43 +0200 Subject: [PATCH] Implemented global flags --- README.md | 15 +++- package.json | 2 +- src/command-core.ts | 180 +++++++++++++++++++++++++++++++++++++++++-- src/event-handler.ts | 119 +++++++++++++++++++++++++--- tests/main.test.ts | 166 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 459 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 585b3d6..e609910 100755 --- a/README.md +++ b/README.md @@ -403,9 +403,12 @@ run(commands, { return false; }, - hook: (event, command) => { - if(event === 'before') console.log(`Command '${command.name}' started`) - if(event === 'after') console.log(`Command '${command.name}' succesfully finished it's work`) + globals: { + flag: boolean('gflag').description('Global flag').default(false) + }, + hook: (event, command, globals) => { + if(event === 'before') console.log(`Command '${command.name}' started with flag ${globals.flag}`) + if(event === 'after') console.log(`Command '${command.name}' succesfully finished it's work with flag ${globals.flag}`) } }) ``` @@ -445,7 +448,11 @@ Return: `true` | `Promise` if you consider event processed `false` | `Promise` to redirect event to default theme -- `hook(event: EventType, command: Command)` - function that's used to execute code before and after every command's `transform` and `handler` execution +- `globals` - global options that could be processed in `hook` +:warning: - positionals are not allowed in `globals` +:warning: - names and aliases must not overlap with options of commands + +- `hook(event: EventType, command: Command, options: TypeOf)` - function that's used to execute code before and after every command's `transform` and `handler` execution ### Additional functions diff --git a/package.json b/package.json index 1b7e068..40849a0 100755 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@drizzle-team/brocli", "type": "module", "author": "Drizzle Team", - "version": "0.10.2", + "version": "0.11.0", "description": "Modern type-safe way of building CLIs", "license": "Apache-2.0", "sideEffects": false, diff --git a/src/command-core.ts b/src/command-core.ts index 5736304..bb89ea2 100755 --- a/src/command-core.ts +++ b/src/command-core.ts @@ -4,6 +4,7 @@ import { defaultEventHandler, type EventHandler, eventHandlerWrapper } from './e import { type GenericBuilderInternals, type GenericBuilderInternalsFields, + type GenericBuilderInternalsLimited, type OutputType, type ProcessedBuilderConfig, type ProcessedOptions, @@ -35,14 +36,22 @@ export type CommandsInfo = Record; export type EventType = 'before' | 'after'; -export type BroCliConfig = { +export type BroCliConfig< + TOpts extends Record | undefined = undefined, + TOptsData = TOpts extends Record ? TypeOf : undefined, +> = { name?: string; description?: string; argSource?: string[]; help?: string | Function; version?: string | Function; omitKeysOfUndefinedOptions?: boolean; - hook?: (event: EventType, command: Command) => any; + globals?: TOpts; + hook?: ( + event: EventType, + command: Command, + options: TOptsData, + ) => any; theme?: EventHandler; }; @@ -648,6 +657,7 @@ const parseOptions = ( omitKeysOfUndefinedOptions?: boolean, ): Record | 'help' | 'version' | undefined => { const options = command.options; + let noOpts = !options; const optEntries = Object.entries(options ?? {} as Exclude).map( (opt) => [opt[0], opt[1].config] as [string, ProcessedBuilderConfig], @@ -715,6 +725,72 @@ const parseOptions = ( }); } + return noOpts ? undefined : result; +}; + +const parseGlobals = ( + command: Command, + globals: ProcessedOptions> | undefined, + args: string[], + cliName: string | undefined, + cliDescription: string | undefined, + omitKeysOfUndefinedOptions?: boolean, +): Record | 'help' | 'version' | undefined => { + if (!globals) return undefined; + + const optEntries = Object.entries(globals).map( + (opt) => [opt[0], opt[1].config] as [string, ProcessedBuilderConfig], + ); + + const result: Record = {}; + const missingRequiredArr: string[][] = []; + + for (let i = 0; i < args.length; ++i) { + const arg = args[i]!; + const nextArg = args[i + 1]; + + const { + data, + name, + option, + skipNext, + isHelp, + isVersion, + } = parseArg(command, optEntries, [], arg, nextArg, cliName, cliDescription); + if (skipNext) ++i; + + if (isHelp) return 'help'; + if (isVersion) return 'version'; + if (!option) continue; + delete args[i]; + if (skipNext) delete args[i - 1]; + + result[name!] = data; + } + + for (const [optKey, option] of optEntries) { + const data = result[optKey] ?? option.default; + + if (!omitKeysOfUndefinedOptions) { + result[optKey] = data; + } else { + if (data !== undefined) result[optKey] = data; + } + + if (option.isRequired && result[optKey] === undefined) missingRequiredArr.push([option.name!, ...option.aliases]); + } + + if (missingRequiredArr.length) { + throw new BroCliError(undefined, { + type: 'error', + violation: 'missing_args_error', + name: cliName, + description: cliDescription, + command, + missing: missingRequiredArr as [string[], ...string[][]], + }); + } + return Object.keys(result).length ? result : undefined; }; @@ -765,6 +841,62 @@ const validateCommands = (commands: Command[], parent?: Command) => { return commands; }; +const validateGlobalsInner = ( + commands: Command[], + globals: GenericBuilderInternalsFields[], +) => { + for (const c of commands) { + const { options } = c; + if (!options) continue; + + for (const { config: opt } of Object.values(options)) { + const foundNameOverlap = globals.find(({ config: g }) => g.name === opt.name); + if (foundNameOverlap) { + throw new BroCliError( + `Global options overlap with option '${opt.name}' of command '${getCommandNameWithParents(c)}' on name`, + ); + } + + let foundAliasOverlap = opt.aliases.find((a) => globals.find(({ config: g }) => g.name === a)) + ?? globals.find(({ config: g }) => opt.aliases.find((a) => a === g.name)); + if (!foundAliasOverlap) { + for (const { config: g } of globals) { + foundAliasOverlap = g.aliases.find((gAlias) => opt.name === gAlias); + + if (foundAliasOverlap) break; + } + } + if (!foundAliasOverlap) { + for (const { config: g } of globals) { + foundAliasOverlap = g.aliases.find((gAlias) => opt.aliases.find((a) => a === gAlias)); + + if (foundAliasOverlap) break; + } + } + + if (foundAliasOverlap) { + throw new BroCliError( + `Global options overlap with option '${opt.name}' of command '${ + getCommandNameWithParents(c) + }' on alias '${foundAliasOverlap}'`, + ); + } + } + + if (c.subcommands) validateGlobalsInner(c.subcommands, globals); + } +}; + +const validateGlobals = ( + commands: Command[], + globals: ProcessedOptions> | undefined, +) => { + if (!globals) return; + const globalEntries = Object.values(globals); + + validateGlobalsInner(commands, globalEntries); +}; + const removeByIndex = (arr: T[], idx: number): T[] => [...arr.slice(0, idx), ...arr.slice(idx + 1, arr.length)]; /** @@ -774,7 +906,12 @@ const removeByIndex = (arr: T[], idx: number): T[] => [...arr.slice(0, idx), * * @param config - additional settings */ -export const run = async (commands: Command[], config?: BroCliConfig): Promise => { +export const run = async < + TOpts extends Record | undefined = undefined, +>( + commands: Command[], + config?: BroCliConfig, +): Promise => { const eventHandler = config?.theme ? eventHandlerWrapper(config.theme) : defaultEventHandler; @@ -784,9 +921,12 @@ export const run = async (commands: Command[], config?: BroCliConfig): Promise a !== undefined) : newArgs, + cliName, + cliDescription, + omitKeysOfUndefinedOptions, + ); - if (optionResult === 'help') { + if (optionResult === 'help' || gOptionResult === 'help') { return command.help !== undefined ? await executeOrLog(command.help) : await eventHandler({ type: 'command_help', description: cliDescription, name: cliName, command: command, + globals: processedGlobals, }); } - if (optionResult === 'version') { + if (optionResult === 'version' || gOptionResult === 'version') { return version !== undefined ? await executeOrLog(version) : await eventHandler({ type: 'version', name: cliName, @@ -889,9 +1052,9 @@ export const run = async (commands: Command[], config?: BroCliConfig): Promise>; }; export type GlobalHelpEvent = { @@ -13,6 +22,7 @@ export type GlobalHelpEvent = { description: string | undefined; name: string | undefined; commands: Command[]; + globals?: ProcessedOptions>; }; export type MissingArgsEvent = { @@ -172,6 +182,34 @@ export const defaultEventHandler: EventHandler = async (event) => { const desc = command.desc ?? command.shortDesc; const subs = command.subcommands?.filter((s) => !s.hidden); const subcommands = subs && subs.length ? subs : undefined; + const defaultGlobals = [ + { + config: { + name: '--help', + aliases: ['-h'], + type: 'boolean' as OptionType, + description: `help for ${commandName}`, + default: undefined, + }, + $output: undefined as any as boolean, + }, + { + config: { + name: '--version', + aliases: ['-v'], + type: 'boolean' as OptionType, + description: `version${cliName ? ` for ${cliName}` : ''}`, + default: undefined, + }, + $output: undefined as any as boolean, + }, + ]; + const globals: { + config: ProcessedBuilderConfig; + $output: OutputType; + }[] = event.globals + ? [...Object.values(event.globals), ...defaultGlobals] + : defaultGlobals; if (desc !== undefined) { console.log(`\n${desc}`); @@ -181,7 +219,7 @@ export const defaultEventHandler: EventHandler = async (event) => { !opt.config.isHidden ); const positionals = opts.filter((opt) => opt.config.type === 'positional'); - const options = opts.filter((opt) => opt.config.type !== 'positional'); + const options = [...opts.filter((opt) => opt.config.type !== 'positional'), ...globals]; console.log('\nUsage:'); if (command.handler) { @@ -273,10 +311,6 @@ export const defaultEventHandler: EventHandler = async (event) => { console.log(data); } - console.log('\nGlobal flags:'); - console.log(` -h, --help help for ${commandName}`); - console.log(` -v, --version version${cliName ? ` for ${cliName}` : ''}`); - if (subcommands) { console.log( `\nUse "${ @@ -292,6 +326,31 @@ export const defaultEventHandler: EventHandler = async (event) => { const cliName = event.name; const desc = event.description; const commands = event.commands.filter((c) => !c.hidden); + const defaultGlobals = [ + { + config: { + name: '--help', + aliases: ['-h'], + type: 'boolean' as OptionType, + description: `help${cliName ? ` for ${cliName}` : ''}`, + default: undefined, + }, + $output: undefined as any as boolean, + }, + { + config: { + name: '--version', + aliases: ['-v'], + type: 'boolean' as OptionType, + description: `version${cliName ? ` for ${cliName}` : ''}`, + default: undefined, + }, + $output: undefined as any as boolean, + }, + ]; + const globals = event.globals + ? [...defaultGlobals, ...Object.values(event.globals)] + : defaultGlobals; if (desc !== undefined) { console.log(`${desc}\n`); @@ -328,10 +387,50 @@ export const defaultEventHandler: EventHandler = async (event) => { console.log('\nNo available commands.'); } + const aliasLength = globals.reduce((p, e) => { + const currentLength = e.config.aliases.reduce((pa, a) => pa + a.length, 0) + + ((e.config.aliases.length - 1) * 2) + 1; // Names + coupling symbols ", " + ending coma + + return currentLength > p ? currentLength : p; + }, 0); + const paddedAliasLength = aliasLength > 0 ? aliasLength + 1 : 0; + const nameLength = globals.reduce((p, e) => { + const typeLen = getOptionTypeText(e.config).length; + const length = typeLen > 0 ? e.config.name.length + 1 + typeLen : e.config.name.length; + + return length > p ? length : p; + }, 0) + 3; + + const preDescPad = paddedAliasLength + nameLength + 2; + const gData = globals.map(({ config: opt }) => + ` ${`${opt.aliases.length ? opt.aliases.join(', ') + ',' : ''}`.padEnd(paddedAliasLength)}${ + `${opt.name}${ + (() => { + const typeText = getOptionTypeText(opt); + return typeText.length ? ' ' + typeText : ''; + })() + }`.padEnd(nameLength) + }${ + (() => { + if (!opt.description?.length) { + return opt.default !== undefined + ? `default: ${JSON.stringify(opt.default)}` + : ''; + } + + const split = opt.description.split('\n'); + const first = split.shift()!; + const def = opt.default !== undefined ? ` (default: ${JSON.stringify(opt.default)})` : ''; + + const final = [first, ...split.map((s) => ''.padEnd(preDescPad) + s)].join('\n') + def; + + return final; + })() + }` + ).join('\n'); + console.log('\nFlags:'); - console.log(` -h, --help help${cliName ? ` for ${cliName}` : ''}`); - console.log(` -v, --version version${cliName ? ` for ${cliName}` : ''}`); - console.log('\n'); + console.log(gData); return true; } diff --git a/tests/main.test.ts b/tests/main.test.ts index 0c8dfda..6d32859 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -850,6 +850,7 @@ describe('Parsing tests', (it) => { expect(eventMocks.global_help.mock.calls.length).toStrictEqual(1); expect(eventMocks.global_help.mock.lastCall).toStrictEqual([{ type: 'global_help', + globals: undefined, name: undefined, description: undefined, commands: commands, @@ -867,6 +868,7 @@ describe('Parsing tests', (it) => { expect(eventMocks.global_help.mock.calls.length).toStrictEqual(2); expect(eventMocks.global_help.mock.lastCall).toStrictEqual([{ type: 'global_help', + globals: undefined, name: undefined, description: undefined, commands: commands, @@ -884,6 +886,7 @@ describe('Parsing tests', (it) => { expect(eventMocks.command_help.mock.calls.length).toStrictEqual(1); expect(eventMocks.command_help.mock.lastCall).toStrictEqual([{ type: 'command_help', + globals: undefined, name: undefined, description: undefined, command: generate, @@ -901,6 +904,7 @@ describe('Parsing tests', (it) => { expect(eventMocks.command_help.mock.calls.length).toStrictEqual(2); expect(eventMocks.command_help.mock.lastCall).toStrictEqual([{ type: 'command_help', + globals: undefined, name: undefined, description: undefined, command: cFirst.subcommands![0], @@ -918,6 +922,7 @@ describe('Parsing tests', (it) => { expect(eventMocks.command_help.mock.calls.length).toStrictEqual(3); expect(eventMocks.command_help.mock.lastCall).toStrictEqual([{ type: 'command_help', + globals: undefined, name: undefined, description: undefined, command: generate, @@ -935,6 +940,7 @@ describe('Parsing tests', (it) => { expect(eventMocks.command_help.mock.calls.length).toStrictEqual(4); expect(eventMocks.command_help.mock.lastCall).toStrictEqual([{ type: 'command_help', + globals: undefined, name: undefined, description: undefined, command: generate, @@ -952,6 +958,7 @@ describe('Parsing tests', (it) => { expect(eventMocks.command_help.mock.calls.length).toStrictEqual(5); expect(eventMocks.command_help.mock.lastCall).toStrictEqual([{ type: 'command_help', + globals: undefined, name: undefined, description: undefined, command: generate, @@ -969,6 +976,7 @@ describe('Parsing tests', (it) => { expect(eventMocks.global_help.mock.calls.length).toStrictEqual(3); expect(eventMocks.global_help.mock.lastCall).toStrictEqual([{ type: 'global_help', + globals: undefined, name: undefined, description: undefined, commands: commands, @@ -986,6 +994,7 @@ describe('Parsing tests', (it) => { expect(eventMocks.command_help.mock.calls.length).toStrictEqual(6); expect(eventMocks.command_help.mock.lastCall).toStrictEqual([{ type: 'command_help', + globals: undefined, name: undefined, description: undefined, command: generate, @@ -1003,6 +1012,7 @@ describe('Parsing tests', (it) => { expect(eventMocks.command_help.mock.calls.length).toStrictEqual(7); expect(eventMocks.command_help.mock.lastCall).toStrictEqual([{ type: 'command_help', + globals: undefined, name: undefined, description: undefined, command: cFirst.subcommands![0]!, @@ -1020,6 +1030,7 @@ describe('Parsing tests', (it) => { expect(eventMocks.command_help.mock.calls.length).toStrictEqual(8); expect(eventMocks.command_help.mock.lastCall).toStrictEqual([{ type: 'command_help', + globals: undefined, name: undefined, description: undefined, command: cFirst.subcommands![1]!, @@ -1692,6 +1703,132 @@ spaces"', }); }); +describe('Globals tests', (it) => { + const globals = { + globalText: string(), + globalFlag: boolean(), + globalEnum: string().alias('-genum').enum('one', 'two', 'three'), + globalTextDef: string().default('strdef'), + globalFlagDef: boolean().default(false), + globalEnumDef: string().enum('one', 'two', 'three').default('three'), + }; + // .toThrowError(new BroCliError(`Can't define option '--flag' - name is already in use by option '--flag'!`)); + + const conflictCmds = [ + command({ + name: 'conflict', + options: { + flag: boolean(), + text: string().alias('txt'), + aliased: boolean('aliased').alias('-al'), + }, + handler: () => '', + }), + ]; + + it('Names conflict', async () => { + expect( + await run(conflictCmds, { + globals: { + flag: boolean(), + }, + // @ts-expect-error + noExit: true, + }), + ).toStrictEqual( + "BroCli error: Global options overlap with option '--flag' of command 'conflict' on name", + ); + }); + + it('Name conflicts with global alias', async () => { + expect( + await run(conflictCmds, { + globals: { + gFlag: boolean().alias('--flag'), + }, + // @ts-expect-error + noExit: true, + }), + ).toStrictEqual( + "BroCli error: Global options overlap with option '--flag' of command 'conflict' on alias '--flag'", + ); + }); + + it('Alias conflicts with global alias', async () => { + expect( + await run(conflictCmds, { + globals: { + gFlag: boolean().alias('-al'), + }, + // @ts-expect-error + noExit: true, + }), + ).toStrictEqual( + "BroCli error: Global options overlap with option '--aliased' of command 'conflict' on alias '-al'", + ); + }); + + it('Alias conflicts with global name', async () => { + expect( + await run(conflictCmds, { + globals: { + txt: string(), + }, + // @ts-expect-error + noExit: true, + }), + ).toStrictEqual( + "BroCli error: Global options overlap with option '--text' of command 'conflict' on alias '--txt'", + ); + }); + + it('Separate', async () => { + let gs: any; + + await run(commands, { + argSource: getArgs('-genum "one" c-first'), + globals, + hook(event, command, globals) { + gs = globals; + }, + // @ts-expect-error + noExit: true, + }); + + expect(gs).toStrictEqual({ + globalFlag: undefined, + globalText: undefined, + globalEnum: 'one', + globalTextDef: 'strdef', + globalFlagDef: false, + globalEnumDef: 'three', + }); + }); + + it('Mixed with command options', async () => { + let gs: any; + + await run(commands, { + argSource: getArgs('c-first --globalFlag true --globalText=str --string strval'), + globals, + hook(event, command, globals) { + gs = globals; + }, + // @ts-expect-error + noExit: true, + }); + + expect(gs).toStrictEqual({ + globalFlag: true, + globalText: 'str', + globalEnum: undefined, + globalTextDef: 'strdef', + globalFlagDef: false, + globalEnumDef: 'three', + }); + }); +}); + describe('Type tests', (it) => { const generateOps = { dialect: string().alias('-d', '-dlc').desc('Database dialect [pg, mysql, sqlite]').enum('pg', 'mysql', 'sqlite') @@ -1773,4 +1910,33 @@ describe('Type tests', (it) => { }, }); }); + + it('Globals type inferrence', () => { + const commands = [command({ + name: 'test', + handler: (opts) => '', + })]; + + type ExpectedType = { + gBool: boolean; + gText: string; + gTextNoDef: string | undefined; + gTextRequired: string; + gEnum: 'variant_one' | 'variant_two' | undefined; + }; + + run(commands, { + globals: { + gBool: boolean().alias('-gb').default(false), + gText: string().alias('-gt').default('text'), + gTextNoDef: string().alias('-gtnd'), + gTextRequired: string().alias('-gtr').required(), + gEnum: string().enum('variant_one', 'variant_two'), + }, + hook(event, command, globals) { + expectTypeOf(globals).toEqualTypeOf(); + }, + theme: testTheme, + }); + }); });