From c042a3481cf3aa418d13c909306e956ff1303a66 Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Mon, 20 Nov 2023 03:26:08 +0200 Subject: [PATCH] feat: example lines, help style updates --- package.json | 4 +- src/command.ts | 43 +++++++++++++- src/example.ts | 124 ++++++++++------------------------------ src/help.ts | 135 +++++++++++++++++++++++++------------------- src/sample.ts | 103 +++++++++++++++++++++++++++++++++ src/utils.ts | 38 +++++++++++++ tsconfig.build.json | 2 +- 7 files changed, 290 insertions(+), 159 deletions(-) create mode 100644 src/sample.ts diff --git a/package.json b/package.json index 399738d..074dff2 100644 --- a/package.json +++ b/package.json @@ -17,9 +17,9 @@ "scripts": { "build": "tsc -p tsconfig.build.json && cp package.json README.md build", "dev": "tsc --watch", - "example": "ts-node src/example.ts", + "cmd": "ts-node src/sample.ts", "test": "jest", - "docs": "typedoc --out docs src --plugin typedoc-plugin-zod --theme default" + "docgen": "typedoc --out docs src --plugin typedoc-plugin-zod --theme default" }, "devDependencies": { "@types/jest": "^29.5.8", diff --git a/src/command.ts b/src/command.ts index bc5b30c..4c567e0 100644 --- a/src/command.ts +++ b/src/command.ts @@ -8,6 +8,7 @@ import MassargOption, { MassargHelpFlag, } from './option' import { setOrPush } from './utils' +import MassargExample, { ExampleConfig } from './example' export const CommandConfig = (args: RunArgs) => z.object({ @@ -54,8 +55,9 @@ export default class MassargCommand { description: string aliases: string[] private _run?: Runner - options: MassargOption[] = [] commands: MassargCommand[] = [] + options: MassargOption[] = [] + examples: MassargExample[] = [] args: Partial = {} constructor(options: CommandConfig) { @@ -79,6 +81,14 @@ export default class MassargCommand { ): MassargCommand { try { const command = config instanceof MassargCommand ? config : new MassargCommand(config) + const existing = this.commands.find((c) => c.name === command.name) + if (existing) { + throw new ValidationError({ + code: 'duplicate_command', + message: `Command "${command.name}" already exists`, + path: [this.name, command.name], + }) + } this.commands.push(command) return this } catch (e) { @@ -93,11 +103,23 @@ export default class MassargCommand { } } - flag(config: Omit, 'parse'>): MassargCommand + flag(config: Omit, 'parse' | 'isDefault'>): MassargCommand flag(config: MassargFlag): MassargCommand - flag(config: Omit, 'parse'> | MassargFlag): MassargCommand { + flag( + config: Omit, 'parse' | 'isDefault'> | MassargFlag, + ): MassargCommand { try { const flag = config instanceof MassargFlag ? config : new MassargFlag(config) + if (flag.isDefault) { + const defaultOption = this.options.find((o) => o.isDefault) + if (defaultOption) { + throw new ValidationError({ + code: 'duplicate_default_option', + message: `Option "${flag.name}" cannot be set as default because option "${defaultOption.name}" is already set as default`, + path: [this.name, flag.name], + }) + } + } this.options.push(flag as MassargOption) return this } catch (e) { @@ -118,6 +140,16 @@ export default class MassargCommand { try { const option = config instanceof MassargOption ? config : MassargOption.fromTypedConfig(config) + if (option.isDefault) { + const defaultOption = this.options.find((o) => o.isDefault) + if (defaultOption) { + throw new ValidationError({ + code: 'duplicate_default_option', + message: `Option "${option.name}" cannot be set as default because option "${defaultOption.name}" is already set as default`, + path: [this.name, option.name], + }) + } + } this.options.push(option as MassargOption) return this } catch (e) { @@ -132,6 +164,11 @@ export default class MassargCommand { } } + example(config: ExampleConfig): MassargCommand { + this.examples.push(new MassargExample(config)) + return this + } + main(run: Runner): MassargCommand { this._run = run return this diff --git a/src/example.ts b/src/example.ts index 4556d5c..d0fde31 100644 --- a/src/example.ts +++ b/src/example.ts @@ -1,98 +1,34 @@ -import { massarg } from '.' -import MassargCommand from './command' -import { ParseError } from './error' +import z from 'zod' +import { ValidationError } from './error' -type A = { test: boolean } -const echoCmd = massarg({ - name: 'echo', - description: 'Echo back the arguments', - aliases: ['e'], - run: (opts) => { - console.log('Echoing back', opts) - }, +export const ExampleConfig = z.object({ + description: z.string().optional(), + input: z.string().optional(), + output: z.string().optional(), }) -const addCmd = massarg<{ component: string }>({ - name: 'add', - description: 'Add a component', - aliases: ['a'], - run: (opts, parser) => { - parser.printHelp() - console.log('Adding component', opts.component) - }, -}) - .option({ - name: 'component', - description: - 'Component to add. Ut consectetur eu et occaecat enim magna amet eiusmod laboris deserunt proident culpa nulla ipsum adipiscing ullamco laboris sed est', - aliases: ['c'], - // aliases: "" as never, - }) - .option({ - name: 'classes', - description: 'Classes to add', - aliases: ['l'], - array: true, - }) - .option({ - name: 'custom', - description: 'Custom option', - aliases: ['x'], - parse: (value) => { - const asNumber = Number(value) - if (isNaN(asNumber)) { - throw new ParseError({ - path: ['custom'], - message: 'Custom option must be a number', - code: 'invalid_number', - }) - } - return { - value: asNumber, - half: asNumber / 2, - double: asNumber * 2, - } - }, - }) +export type ExampleConfig = z.infer -const removeCmd = new MassargCommand<{ component: string }>({ - name: 'remove', - description: 'Remove a component', - aliases: ['r'], - run: (opts) => { - console.log('Removing component', opts.component) - }, -}).option({ - name: 'component', - description: 'Component to remove', - aliases: ['c'], -}) +export default class MassargExample { + description: string | undefined + input: string | undefined + output: string | undefined -const args = massarg({ - name: 'my-cli', - description: 'This is an example CLI', - bindHelpOption: true, - bindHelpCommand: true, -}) - .main((opts, parser) => { - console.log('Main command - printing all opts') - console.log(opts, '\n') - parser.printHelp() - }) - .command(echoCmd) - .command(addCmd) - .command(removeCmd) - .flag({ - name: 'bool', - description: 'Example boolean option', - aliases: ['b'], - }) - .option({ - name: 'number', - description: 'Example number option', - aliases: ['n'], - type: 'number', - }) - -// console.log("Opts:", args.getArgs(process.argv.slice(2)), "\n") - -args.parse(process.argv.slice(2)) + constructor(config: ExampleConfig) { + ExampleConfig.parse(config) + if ( + config.description === undefined && + config.input === undefined && + config.output === undefined + ) { + throw new ValidationError({ + code: 'invalid_example', + message: 'Example must have at least one of description, input, or output', + path: ['example'], + }) + } + this.description = config.description + this.input = config.input + this.output = config.output + } +} +export { MassargExample } diff --git a/src/help.ts b/src/help.ts index 825b767..8bbfffe 100644 --- a/src/help.ts +++ b/src/help.ts @@ -1,5 +1,6 @@ import { format, StringStyle, stripColors } from './color' import MassargCommand from './command' +import { chainStr, indent } from './utils' export type GenerateTableCommandConfig = { maxRowLength?: number @@ -25,6 +26,11 @@ export type GenerateHelpOptions = { descriptionStyle?: StringStyle subtitleStyle?: StringStyle usageStyle?: StringStyle + exampleStyles?: { + description?: StringStyle + input?: StringStyle + output?: StringStyle + } } export type HelpItem = { @@ -61,40 +67,80 @@ export class HelpGenerator { aliasPrefix: '', ...this.config.commandOptions, }) + const examples = entry.examples + .map((example) => { + const { description, input, output } = example + return chainStr( + description && [ + format(description, { + reset: true, + bold: true, + ...this.config.exampleStyles?.description, + }), + '', + ], + input && + format(input, { reset: true, color: 'yellow', ...this.config.exampleStyles?.input }), + output && + format(output, { + reset: true, + color: 'brightWhite', + ...this.config.exampleStyles?.output, + }), + ) + }) + .join('\n') - return chainStr( - format(entry.name, { - bold: true, - color: 'brightWhite', - reset: true, - ...this.config.titleStyle, - }), - '', - format(entry.description, { reset: true, ...this.config.descriptionStyle }), - commands.length && [ - '', - format(`Commands for ${entry.name}:`, { + return ( + chainStr( + format(`Usage: ${entry.name} [...options]`, { bold: true, + color: 'yellow', reset: true, - color: 'brightWhite', - underline: true, - ...this.config.subtitleStyle, + ...this.config.titleStyle, }), '', - commands, - ], - options.length && [ - '', - format(`Options for ${entry.name}:`, { - bold: true, - reset: true, - color: 'brightWhite', - underline: true, - ...this.config.subtitleStyle, - }), - '', - options, - ], + format(entry.description, { reset: true, ...this.config.descriptionStyle }), + commands.length && + indent([ + '', + format(`Commands for ${entry.name}:`, { + bold: true, + reset: true, + color: 'brightWhite', + underline: true, + ...this.config.subtitleStyle, + }), + '', + indent(commands), + ]), + options.length && + indent([ + '', + format(`Options for ${entry.name}:`, { + bold: true, + reset: true, + color: 'brightWhite', + underline: true, + ...this.config.subtitleStyle, + }), + '', + indent(options), + ]), + examples.length && + indent([ + '', + format('Examples:', { + bold: true, + reset: true, + color: 'brightWhite', + underline: true, + ...this.config.subtitleStyle, + }), + '', + indent(examples), + ]), + ) + '\n' ) } @@ -122,7 +168,7 @@ function generateHelpTable>( }) const maxNameLength = Math.max(...rows.map((o) => o.name.length)) const nameStyle = (name: string) => - format(name, { bold: true, color: 'brightWhite', reset: true, ...config.nameStyle }) + format(name, { color: 'yellow', reset: true, ...config.nameStyle }) const descStyle = (desc: string) => format(desc, { color: 'gray', reset: true, ...config.descriptionStyle }) const table = rows.map((row) => { @@ -157,32 +203,3 @@ function generateHelpTable>( return table.join('\n') } - -type Parseable = string | number | boolean | Record - -function chainStr(...strs: (Parseable | Parseable[])[]) { - const res: string[] = [] - for (const str of strs) { - if (typeof str === 'string') { - res.push(str) - continue - } - if (Array.isArray(str)) { - res.push(chainStr(...str)) - continue - } - if (typeof str === 'object') { - for (const [key, value] of Object.entries(str)) { - if (Boolean(value)) { - res.push(key) - } - } - continue - } - if (Boolean(str)) { - res.push(str.toString()) - continue - } - } - return res.join('\n') -} diff --git a/src/sample.ts b/src/sample.ts new file mode 100644 index 0000000..a3a7ed3 --- /dev/null +++ b/src/sample.ts @@ -0,0 +1,103 @@ +import { massarg } from '.' +import MassargCommand from './command' +import { ParseError } from './error' + +type A = { test: boolean } +const echoCmd = massarg({ + name: 'echo', + description: 'Echo back the arguments', + aliases: ['e'], + run: (opts) => { + console.log('Echoing back', opts) + }, +}) +const addCmd = massarg<{ component: string }>({ + name: 'add', + description: 'Add a component', + aliases: ['a'], + run: (opts, parser) => { + parser.printHelp() + console.log('Adding component', opts.component) + }, +}) + .option({ + name: 'component', + description: + 'Component to add. Ut consectetur eu et occaecat enim magna amet eiusmod laboris deserunt proident culpa nulla ipsum adipiscing ullamco laboris sed est', + aliases: ['c'], + // aliases: "" as never, + }) + .option({ + name: 'classes', + description: 'Classes to add', + aliases: ['l'], + array: true, + }) + .option({ + name: 'custom', + description: 'Custom option', + aliases: ['x'], + parse: (value) => { + const asNumber = Number(value) + if (isNaN(asNumber)) { + throw new ParseError({ + path: ['custom'], + message: 'Custom option must be a number', + code: 'invalid_number', + }) + } + return { + value: asNumber, + half: asNumber / 2, + double: asNumber * 2, + } + }, + }) + .example({ + description: 'Add a component', + input: 'my-cli add foo', + output: 'Adding component foo', + }) + +const removeCmd = new MassargCommand<{ component: string }>({ + name: 'remove', + description: 'Remove a component', + aliases: ['r'], + run: (opts) => { + console.log('Removing component', opts.component) + }, +}).option({ + name: 'component', + description: 'Component to remove', + aliases: ['c'], +}) + +const main = massarg({ + name: 'my-cli', + description: 'This is an example CLI', + bindHelpOption: true, + bindHelpCommand: true, +}) + .main((opts, parser) => { + console.log('Main command - printing all opts') + console.log(opts, '\n') + parser.printHelp() + }) + .command(echoCmd) + .command(addCmd) + .command(removeCmd) + .flag({ + name: 'bool', + description: 'Example boolean option', + aliases: ['b'], + }) + .option({ + name: 'number', + description: 'Example number option', + aliases: ['n'], + type: 'number', + }) + +// console.log("Opts:", main.getArgs(process.argv.slice(2)), "\n") + +main.parse(process.argv.slice(2)) diff --git a/src/utils.ts b/src/utils.ts index bc23f51..33e23e6 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -8,3 +8,41 @@ export function setOrPush( } return newValue as T } +type Parseable = string | number | boolean | null | undefined | Record + +export function chainStr(...strs: (Parseable | Parseable[])[]) { + const res: string[] = [] + for (const str of strs) { + if (typeof str === 'string') { + res.push(str) + continue + } + if (Array.isArray(str)) { + res.push(chainStr(...str)) + continue + } + if (str == null) { + continue + } + if (typeof str === 'object') { + for (const [key, value] of Object.entries(str)) { + if (Boolean(value)) { + res.push(key) + } + } + continue + } + if (Boolean(str)) { + res.push(str.toString()) + continue + } + } + return res.join('\n') +} + +export function indent(str: Parseable | Parseable[], indent = 2): string { + return chainStr(str) + .split('\n') + .map((s) => ' '.repeat(indent) + s) + .join('\n') +} diff --git a/tsconfig.build.json b/tsconfig.build.json index 692f2ab..087de88 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,6 +1,6 @@ { "extends": "./tsconfig.json", "exclude": [ - "src/example.ts" + "src/sample.ts" ] }