feat: example lines, help style updates

This commit is contained in:
2023-11-20 03:26:08 +02:00
committed by Chen Asraf
parent 571920b454
commit ecd7ced3e8
7 changed files with 290 additions and 159 deletions

View File

@@ -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",

View File

@@ -8,6 +8,7 @@ import MassargOption, {
MassargHelpFlag,
} from './option'
import { setOrPush } from './utils'
import MassargExample, { ExampleConfig } from './example'
export const CommandConfig = <RunArgs extends z.ZodType>(args: RunArgs) =>
z.object({
@@ -54,8 +55,9 @@ export default class MassargCommand<Args extends ArgsObject = ArgsObject> {
description: string
aliases: string[]
private _run?: Runner<Args>
options: MassargOption[] = []
commands: MassargCommand<any>[] = []
options: MassargOption[] = []
examples: MassargExample[] = []
args: Partial<Args> = {}
constructor(options: CommandConfig<Args>) {
@@ -79,6 +81,14 @@ export default class MassargCommand<Args extends ArgsObject = ArgsObject> {
): MassargCommand<Args> {
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<Args extends ArgsObject = ArgsObject> {
}
}
flag(config: Omit<OptionConfig<boolean>, 'parse'>): MassargCommand<Args>
flag(config: Omit<OptionConfig<boolean>, 'parse' | 'isDefault'>): MassargCommand<Args>
flag(config: MassargFlag): MassargCommand<Args>
flag(config: Omit<OptionConfig<boolean>, 'parse'> | MassargFlag): MassargCommand<Args> {
flag(
config: Omit<OptionConfig<boolean>, 'parse' | 'isDefault'> | MassargFlag,
): MassargCommand<Args> {
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<Args extends ArgsObject = ArgsObject> {
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<Args extends ArgsObject = ArgsObject> {
}
}
example(config: ExampleConfig): MassargCommand<Args> {
this.examples.push(new MassargExample(config))
return this
}
main<A extends ArgsObject = Args>(run: Runner<A>): MassargCommand<Args> {
this._run = run
return this

View File

@@ -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<any>({
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<typeof ExampleConfig>
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<A>({
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 }

View File

@@ -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<T extends Partial<GenerateTableCommandConfig>>(
})
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<T extends Partial<GenerateTableCommandConfig>>(
return table.join('\n')
}
type Parseable = string | number | boolean | Record<string, unknown>
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')
}

103
src/sample.ts Normal file
View File

@@ -0,0 +1,103 @@
import { massarg } from '.'
import MassargCommand from './command'
import { ParseError } from './error'
type A = { test: boolean }
const echoCmd = massarg<any>({
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<A>({
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))

View File

@@ -8,3 +8,41 @@ export function setOrPush<T>(
}
return newValue as T
}
type Parseable = string | number | boolean | null | undefined | Record<string, unknown>
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')
}

View File

@@ -1,6 +1,6 @@
{
"extends": "./tsconfig.json",
"exclude": [
"src/example.ts"
"src/sample.ts"
]
}