mirror of
https://github.com/chenasraf/massarg.git
synced 2026-05-18 01:39:05 +00:00
feat: v2 poc
This commit is contained in:
15
package.json
15
package.json
@@ -22,16 +22,15 @@
|
||||
"test": "jest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^27.0.3",
|
||||
"@types/jest": "^27.5.2",
|
||||
"@types/lodash": "^4.14.177",
|
||||
"@types/node": "^16.11.11",
|
||||
"jest": "^27.4.3",
|
||||
"ts-jest": "^27.0.7",
|
||||
"ts-node": "^10.1.0",
|
||||
"typescript": "^4.5.2"
|
||||
"@types/node": "^16.18.61",
|
||||
"jest": "^27.5.1",
|
||||
"ts-jest": "^27.1.5",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^4.9.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": "^4.1.2",
|
||||
"lodash": "^4.17.21"
|
||||
"zod": "^3.22.4"
|
||||
}
|
||||
}
|
||||
|
||||
2797
pnpm-lock.yaml
generated
Normal file
2797
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
555
src/_old/index.ts
Normal file
555
src/_old/index.ts
Normal file
@@ -0,0 +1,555 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import chalk from "chalk"
|
||||
import merge from "lodash/merge"
|
||||
import camelCase from "lodash/camelCase"
|
||||
import path from "path"
|
||||
import { OptionsBase, CommandDef, HelpDef, MainDef, OptionDef, ExampleDef } from "./types"
|
||||
import { ArrayOr, asArray, colorCount, COLOR_CODE_LEN, wrap } from "./utils"
|
||||
import { RequiredError } from "./errors"
|
||||
import { assertCommand, assertExample, assertHelp, assertMain, assertOption } from "./assertions"
|
||||
|
||||
export class Massarg<Options> {
|
||||
private _main?: MainDef<Options>
|
||||
private _options: OptionDef<Options, any>[] = []
|
||||
private _commands: CommandDef<Options>[] = []
|
||||
private _runCommand?: CommandDef<Options>
|
||||
private _examples: ExampleDef[] = []
|
||||
private _maxNameLen = 0
|
||||
/**
|
||||
* These are the parsed options passed via args. They will only be available after using `parse()` or `printHelp()`,
|
||||
* or when returned by `parseArgs()`. */
|
||||
public data: Options & OptionsBase = { help: false, extras: [] as string[] } as Options & OptionsBase
|
||||
|
||||
private _help: Required<HelpDef> = {
|
||||
binName: undefined as any,
|
||||
normalColors: "dim",
|
||||
highlightColors: "yellow",
|
||||
titleColors: ["bold", "white"],
|
||||
subtitleColors: ["bold", "dim"],
|
||||
bodyColors: "white",
|
||||
printWidth: 80,
|
||||
header: "",
|
||||
footer: "",
|
||||
commandNameSeparator: " | ",
|
||||
optionNameSeparator: "|",
|
||||
useGlobalColumns: true,
|
||||
usageExample: "[command] [options]",
|
||||
useColors: true,
|
||||
includeDefaults: true,
|
||||
exampleInputPrefix: "$",
|
||||
exampleOutputPrefix: "➜",
|
||||
}
|
||||
private _requiredOptions: Record<"all" | string, Record<string, boolean>> = {}
|
||||
|
||||
constructor() {
|
||||
this.option({
|
||||
name: "help",
|
||||
aliases: ["h"],
|
||||
description: "Display help information",
|
||||
parse: Boolean,
|
||||
})
|
||||
}
|
||||
|
||||
/** Define the main command to run when no commands are passed. */
|
||||
public main(run: MainDef<Options>): Massarg<Options> {
|
||||
assertMain(run)
|
||||
this._main = run
|
||||
return this
|
||||
}
|
||||
|
||||
/** Add option to be parsed */
|
||||
public option<Value>(option: OptionDef<Options, Value>): Massarg<Options> {
|
||||
let defaultValue = option.defaultValue as any
|
||||
|
||||
// detect boolean values
|
||||
option.boolean ??= (option.parse as any) === Boolean || [true, false].includes(defaultValue)
|
||||
// detect array values
|
||||
option.array ??= Array.isArray(defaultValue)
|
||||
// default parser
|
||||
option.parse ??= (option.boolean ? this._isTruthy : (a: any) => a) as any
|
||||
|
||||
assertOption(option, this._options)
|
||||
|
||||
if (option.array && defaultValue === undefined) {
|
||||
defaultValue = []
|
||||
}
|
||||
|
||||
this._options.push(option)
|
||||
this._prepareRequired(option)
|
||||
return this
|
||||
}
|
||||
|
||||
/** Add example line to be added to the help text. */
|
||||
public example(example: ExampleDef): Massarg<Options> {
|
||||
assertExample(example)
|
||||
|
||||
this._examples.push(example as ExampleDef)
|
||||
return this
|
||||
}
|
||||
|
||||
/** Add command to be run */
|
||||
public command(command: CommandDef<Options>): Massarg<Options> {
|
||||
assertCommand(command, this._commands)
|
||||
|
||||
this._commands.push(command)
|
||||
for (const opt of this._commandOptions(command)) {
|
||||
this._prepareRequired(opt)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
/** Set options for behavior of the help text print. */
|
||||
public help(help: HelpDef): Massarg<Options> {
|
||||
assertHelp(help)
|
||||
|
||||
this._help = merge(this._help, help)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the help text without being required to pass option.
|
||||
*
|
||||
* @param args If args weren't already parsed, you can add them here
|
||||
*/
|
||||
public printHelp(args?: string[]): void {
|
||||
console.log(this.getHelpString(args).join("\n"))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the help text as an array of lines. Useful for manipulating the response or querying before displaying
|
||||
* to the user.
|
||||
*/
|
||||
public getHelpString(args?: string[]): string[] {
|
||||
const lines: string[] = []
|
||||
|
||||
if (args?.length) {
|
||||
this.parseArgs(args)
|
||||
}
|
||||
|
||||
const { bodyColors, highlightColors, normalColors, titleColors, binName, usageExample } = this._help
|
||||
|
||||
lines.push(
|
||||
[
|
||||
this.color(titleColors, "Usage:"),
|
||||
this.color(highlightColors, binName ?? path.basename(process.argv[1])),
|
||||
this.color(normalColors, usageExample),
|
||||
].join(" ")
|
||||
)
|
||||
|
||||
lines.push("")
|
||||
|
||||
if (this._help.header) {
|
||||
lines.push(this.color(bodyColors, this._help.header))
|
||||
lines.push("")
|
||||
}
|
||||
|
||||
if (this._commands.length) {
|
||||
lines.push(this.color(titleColors, "Commands:"))
|
||||
lines.push("")
|
||||
lines.push(...this._printCommands())
|
||||
}
|
||||
|
||||
lines.push(...this._printOptions())
|
||||
|
||||
if (this._examples.length) {
|
||||
lines.push(this.color(titleColors, "Examples:"))
|
||||
lines.push("")
|
||||
lines.push(...this._printExamples())
|
||||
}
|
||||
|
||||
if (this._help.footer) {
|
||||
lines.push(this.color(bodyColors, this._help.footer))
|
||||
lines.push("")
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the arguments without running the commands related to them. Useful for testing or querying the data from the
|
||||
* args manually, if it is for some reason not enough to parse it normally through defining commands.
|
||||
* @param args Arguments to parse. Defaults to `process.argv`
|
||||
* @returns Parsed options
|
||||
*/
|
||||
public parseArgs(args = process.argv): Options & OptionsBase {
|
||||
for (const option of this._options) {
|
||||
if (option.defaultValue !== undefined) {
|
||||
this._addOptionToData(option, option.defaultValue)
|
||||
} else if (option.array) {
|
||||
this._pushToArrayData(option)
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i]
|
||||
const option = this._options.find((o) => `--${o.name}` === arg || o.aliases?.map((a) => `-${a}`).includes(arg))
|
||||
|
||||
if (option) {
|
||||
let tempValue: any
|
||||
const hasNextToken = args.length > i + 1
|
||||
const nextTokenIsValue = hasNextToken && !args[i + 1].startsWith("-")
|
||||
|
||||
if (option.boolean && (!hasNextToken || !nextTokenIsValue)) {
|
||||
// parse boolean args w/o value
|
||||
tempValue = true
|
||||
} else if (!hasNextToken || !nextTokenIsValue) {
|
||||
// non-boolean args with no value
|
||||
throw new TypeError(`Missing value for: ${option.name}`)
|
||||
} else {
|
||||
// any args (incl. boolean) with value
|
||||
tempValue = args[i + 1]
|
||||
args.shift()
|
||||
}
|
||||
const value = option.parse!(tempValue, this.data)
|
||||
this._addOptionToData(option, value)
|
||||
|
||||
// continue
|
||||
}
|
||||
|
||||
const command = this._commands.find((o) => o.name === arg || o.aliases?.includes(arg))
|
||||
|
||||
let justFoundCommand = false
|
||||
|
||||
if (command) {
|
||||
if (!this._runCommand) {
|
||||
this._runCommand = command
|
||||
justFoundCommand = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!option && (!command || (command && !justFoundCommand))) {
|
||||
const defOpts = this._options.filter((o) => o.isDefault)
|
||||
if (defOpts.length) {
|
||||
for (const option of defOpts) {
|
||||
this._addOptionToData(option, option.parse!(arg, this.data))
|
||||
}
|
||||
} else {
|
||||
this.data.extras.push(arg)
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the given args, running any relevant commands in the process.
|
||||
*
|
||||
* @param args args to parse. Defaults to `process.argv`
|
||||
*/
|
||||
public parse(args?: string[]): void {
|
||||
this.parseArgs(args)
|
||||
|
||||
if (this.data.help) {
|
||||
this.printHelp()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (this._runCommand) {
|
||||
this._ensureRequired(this._runCommand)
|
||||
this._runCommand.run(this.data)
|
||||
} else if (this._main) {
|
||||
this._ensureRequired()
|
||||
this._main(this.data)
|
||||
} else {
|
||||
this._ensureRequired()
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (RequiredError.isRequiredError(e)) {
|
||||
console.error(chalk.red`${e.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
private _prepareRequired(options: OptionDef<Options, any>) {
|
||||
if (options.required) {
|
||||
if (options.commands?.length) {
|
||||
for (const command of this._optionCommands(options)) {
|
||||
this._requiredOptions[command.name] ??= {}
|
||||
this._requiredOptions[command.name][options.name] = true
|
||||
}
|
||||
} else {
|
||||
this._requiredOptions["all"] ??= {}
|
||||
this._requiredOptions["all"][options.name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _printExamples(): string[] {
|
||||
const lines: string[] = []
|
||||
const { normalColors, highlightColors, bodyColors, titleColors } = this._help
|
||||
for (const example of this._examples) {
|
||||
if (example.description) {
|
||||
lines.push(
|
||||
...wrap(this.color(titleColors, example.description), {
|
||||
colorCount: this.colorCount(titleColors),
|
||||
indent: 2,
|
||||
printWidth: this._help.printWidth,
|
||||
})
|
||||
)
|
||||
lines.push("")
|
||||
}
|
||||
|
||||
lines.push(
|
||||
...wrap(
|
||||
[this.color(normalColors, this._help.exampleInputPrefix), this.color(highlightColors, example.input)].join(
|
||||
" "
|
||||
),
|
||||
{
|
||||
colorCount: this.colorCount(highlightColors),
|
||||
firstLineIndent: 2,
|
||||
indent: 3 + this._help.exampleInputPrefix.length,
|
||||
// indent: this.colorCount(normalColors) + 4,
|
||||
printWidth: this._help.printWidth,
|
||||
}
|
||||
)
|
||||
)
|
||||
if (example.output) {
|
||||
lines.push(
|
||||
...wrap(
|
||||
[this.color(normalColors, this._help.exampleOutputPrefix), this.color(bodyColors, example.output)].join(
|
||||
" "
|
||||
),
|
||||
{
|
||||
colorCount: this.colorCount(bodyColors),
|
||||
firstLineIndent: 2,
|
||||
indent: 3 + this._help.exampleOutputPrefix.length,
|
||||
// indent: this.colorCount(normalColors) + 4,
|
||||
printWidth: this._help.printWidth,
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
lines.push("")
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
private _isTruthy(v: any): boolean {
|
||||
v = String(v).toLowerCase()
|
||||
return ["1", "true", "yes", "y", "on"].includes(v) || !["0", "false", "no", "n", "off"].includes(v)
|
||||
}
|
||||
|
||||
private _ensureRequired(cmd?: CommandDef<Options>) {
|
||||
const cmdName = cmd?.name ?? "all"
|
||||
|
||||
for (const optName in this._requiredOptions[cmdName]) {
|
||||
if (this._requiredOptions[cmdName][optName]) {
|
||||
throw new RequiredError(optName, cmdName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _addOptionToData(option: OptionDef<Options, any>, value: any) {
|
||||
const _d: Record<string, any> = this.data
|
||||
|
||||
const set = (value: any) => {
|
||||
_d[option.name] = value
|
||||
_d[camelCase(option.name)] = value
|
||||
option.aliases?.forEach((a) => (_d[a] = value))
|
||||
}
|
||||
const push = (value: any) => {
|
||||
this._pushToArrayData(option, value)
|
||||
}
|
||||
if (!option.array) {
|
||||
// single value
|
||||
set(value)
|
||||
} else {
|
||||
// multiple values
|
||||
if (Array.isArray(value) && value.length) {
|
||||
for (const el of value) {
|
||||
push(el)
|
||||
}
|
||||
} else if (!Array.isArray(value)) {
|
||||
push(value)
|
||||
}
|
||||
}
|
||||
if (value !== option.defaultValue && value !== undefined) {
|
||||
for (const key in this._requiredOptions) {
|
||||
this._requiredOptions[key][option.name] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _pushToArrayData(option: OptionDef<Options, any>, value?: any) {
|
||||
const _d: Record<string, any> = this.data
|
||||
|
||||
const ccSame = camelCase(option.name) === option.name
|
||||
_d[option.name] ??= []
|
||||
_d[camelCase(option.name)] ??= []
|
||||
option.aliases?.forEach((a) => (_d[a] ??= []))
|
||||
|
||||
if (value !== undefined) {
|
||||
_d[option.name].push(value)
|
||||
if (!ccSame) {
|
||||
_d[camelCase(option.name)].push(value)
|
||||
}
|
||||
option.aliases?.forEach((a) => _d[a].push(value))
|
||||
}
|
||||
}
|
||||
|
||||
private _getWrappedLines(
|
||||
list: Array<{ name: string; description?: string; additionalColorCount?: number }>
|
||||
): string[] {
|
||||
const { normalColors, highlightColors } = this._help
|
||||
const lines: string[] = []
|
||||
|
||||
let maxNameLen = this._help.useGlobalColumns ? this._maxNameLen ?? 0 : 0
|
||||
for (const item of list) {
|
||||
if (item.name.length > maxNameLen) {
|
||||
maxNameLen = item.name.length
|
||||
}
|
||||
}
|
||||
if (this._help.useGlobalColumns) {
|
||||
this._maxNameLen = maxNameLen
|
||||
}
|
||||
|
||||
const ARG_SPACE_LEN = 2
|
||||
const INDENT_LEN = 2
|
||||
const nameFullSize = maxNameLen + ARG_SPACE_LEN + INDENT_LEN
|
||||
|
||||
for (const item of list) {
|
||||
const cmdName = this.color(highlightColors, `${item.name}`).padEnd(
|
||||
nameFullSize + this.colorCount(highlightColors) * COLOR_CODE_LEN,
|
||||
" "
|
||||
)
|
||||
const cmdDesc = this.color(normalColors, item.description ?? "")
|
||||
|
||||
for (const line of wrap(cmdName + cmdDesc, {
|
||||
indent: nameFullSize + INDENT_LEN,
|
||||
colorCount: this.colorCount(
|
||||
normalColors,
|
||||
highlightColors,
|
||||
item.additionalColorCount ? new Array({ length: item.additionalColorCount }) : []
|
||||
),
|
||||
firstLineIndent: INDENT_LEN,
|
||||
printWidth: this._help.printWidth,
|
||||
})) {
|
||||
lines.push(line)
|
||||
}
|
||||
|
||||
lines.push("")
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
private _printCommands(): string[] {
|
||||
return this._getWrappedLines(
|
||||
this._commands.map((c) => ({ name: this._fullCmdName(c), description: c.description }))
|
||||
)
|
||||
}
|
||||
|
||||
private _printOptions(): string[] {
|
||||
const lines: string[] = []
|
||||
|
||||
const { titleColors, subtitleColors } = this._help
|
||||
|
||||
const commandOpts: string[] = []
|
||||
|
||||
for (const cmd of this._commands) {
|
||||
const opts = this._commandOptions(cmd)
|
||||
if (opts.length) {
|
||||
commandOpts.push(this.color(subtitleColors, `${cmd.name}:`))
|
||||
commandOpts.push("")
|
||||
|
||||
for (const line of this._getWrappedLines(
|
||||
opts.map((c) => ({
|
||||
name: this._fullOptName(c),
|
||||
description: this._optionDescription(c),
|
||||
additionalColorCount: c.defaultValue !== undefined ? 1 : 0,
|
||||
}))
|
||||
)) {
|
||||
commandOpts.push(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(this.color(titleColors, commandOpts.length ? "Command Options:" : "Options:"))
|
||||
lines.push("")
|
||||
|
||||
for (const line of commandOpts) {
|
||||
lines.push(line)
|
||||
}
|
||||
|
||||
const globalOpts = this._globalOptions()
|
||||
if (globalOpts.length) {
|
||||
if (commandOpts.length) {
|
||||
lines.push(this.color(titleColors, "Global Options:"))
|
||||
lines.push("")
|
||||
}
|
||||
for (const line of this._getWrappedLines(
|
||||
globalOpts.map((c) => ({ name: this._fullOptName(c), description: this._optionDescription(c) }))
|
||||
)) {
|
||||
lines.push(line)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
private _optionDescription(c: OptionDef<Options, any>): string | undefined {
|
||||
if (c.defaultValue === undefined || !this._help.includeDefaults) {
|
||||
return c.description
|
||||
}
|
||||
|
||||
return [c.description!, this.color(this._help.bodyColors, `(default: ${c.defaultValue.toString().trim()})`)]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
private _fullCmdName(cmd: CommandDef<Options>) {
|
||||
return [cmd.name, ...(cmd.aliases ?? [])].join(this._help.commandNameSeparator)
|
||||
}
|
||||
|
||||
private _fullOptName(opt: OptionDef<Options, any>) {
|
||||
return [`--${opt.name}`, ...(opt.aliases ?? []).map((a) => `-${a}`)].join(this._help.optionNameSeparator)
|
||||
}
|
||||
|
||||
private _commandOptions(cmd: CommandDef<Options>): OptionDef<Options, any>[] {
|
||||
return this._options.filter(
|
||||
(o) =>
|
||||
(asArray(o.commands).length && asArray(o.commands).includes(cmd.name)) ||
|
||||
cmd.aliases?.some((a) => asArray(o.commands).includes(a))
|
||||
)
|
||||
}
|
||||
|
||||
private _optionCommands(opt: OptionDef<Options, any>): OptionDef<Options, any>[] {
|
||||
return this._commands.filter((c) => {
|
||||
return asArray(opt.commands).some((_c) => {
|
||||
return [c.name, ...(c.aliases ?? [])].includes(_c!)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private _globalOptions(): OptionDef<Options, any>[] {
|
||||
return this._options.filter((o) => !o.commands)
|
||||
}
|
||||
|
||||
private color(color: ArrayOr<keyof typeof chalk>, ...text: any[]): string {
|
||||
if (!this._help.useColors) {
|
||||
return text.join(" ")
|
||||
}
|
||||
let output: string = undefined as any
|
||||
for (const c of asArray(color)) {
|
||||
output = (chalk[c as keyof typeof chalk] as typeof chalk.dim)(...(output ? [output] : text))
|
||||
}
|
||||
return chalk.reset(output)
|
||||
}
|
||||
|
||||
private colorCount(...colors: any[]): number {
|
||||
if (!this._help.useColors) {
|
||||
return 0
|
||||
}
|
||||
return colorCount(...colors)
|
||||
}
|
||||
}
|
||||
|
||||
export function massarg<T>() {
|
||||
return new Massarg<T>()
|
||||
}
|
||||
|
||||
export default massarg
|
||||
88
src/_old/utils.ts
Normal file
88
src/_old/utils.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import chalk from "chalk"
|
||||
// import chunk from "lodash/chunk"
|
||||
import repeat from "lodash/repeat"
|
||||
import merge from "lodash/merge"
|
||||
|
||||
export function color(color: ArrayOr<keyof typeof chalk>, ...text: any[]): string {
|
||||
let output: string = undefined as any
|
||||
for (const c of asArray(color)) {
|
||||
output = (chalk[c as keyof typeof chalk] as typeof chalk.dim)(...(output ? [output] : text))
|
||||
}
|
||||
return chalk.reset(output)
|
||||
}
|
||||
|
||||
export function colorCount(...colors: any[]): number {
|
||||
return asArray(colors).reduce((all, colorSet) => all + asArray(colorSet).length, 0)
|
||||
}
|
||||
|
||||
export interface WrapOptions {
|
||||
indent?: number
|
||||
firstLineIndent?: number
|
||||
printWidth?: number
|
||||
colorCount?: number
|
||||
}
|
||||
|
||||
export function wrap(text: string, options?: WrapOptions): string[] {
|
||||
const _opts = merge(
|
||||
{
|
||||
printWidth: 100,
|
||||
indent: 0,
|
||||
colorCount: 0,
|
||||
} as WrapOptions,
|
||||
options
|
||||
) as Required<WrapOptions>
|
||||
|
||||
const indentSize = _opts.indent ?? 0
|
||||
const firstIndentSize = _opts.firstLineIndent ?? indentSize
|
||||
const maxLineLength = _opts.printWidth - firstIndentSize + COLOR_CODE_LEN * _opts.colorCount
|
||||
|
||||
function indent(i: number, l: string): string {
|
||||
return repeat(" ", i === 0 ? firstIndentSize : indentSize) + l
|
||||
}
|
||||
|
||||
if (!_opts.printWidth || maxLineLength <= 0) {
|
||||
return text.split("\n").map((l, i) => indent(i, l))
|
||||
}
|
||||
|
||||
let lines = chunk(text, maxLineLength).map((l, i) => indent(i, l))
|
||||
|
||||
lines = [
|
||||
lines[0],
|
||||
...chunk(
|
||||
lines
|
||||
.slice(1)
|
||||
.map((l) => l.trim())
|
||||
.join(" ")
|
||||
.trim(),
|
||||
maxLineLength - indentSize - COLOR_CODE_LEN
|
||||
).map((l, i) => indent(i + 1, l)),
|
||||
].filter((l) => l.trim().length)
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
export const COLOR_CODE_LEN = color("yellow", " ").length - 1
|
||||
|
||||
function chunk(text: string, len: number): string[] {
|
||||
const arr = text.split(" ")
|
||||
const result = []
|
||||
let subStr = arr[0]
|
||||
for (let i = 1; i < arr.length; i++) {
|
||||
let word = arr[i]
|
||||
if (subStr.length + word.length + 1 <= len) {
|
||||
subStr = subStr + " " + word
|
||||
} else {
|
||||
result.push(subStr)
|
||||
subStr = word
|
||||
}
|
||||
}
|
||||
if (subStr.length) {
|
||||
result.push(subStr)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export type ArrayOr<T> = T | T[]
|
||||
export function asArray<T>(obj: T | T[]): T[] {
|
||||
return Array.isArray(obj) ? obj ?? [] : obj ? [obj] : []
|
||||
}
|
||||
144
src/command.ts
Normal file
144
src/command.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { z } from "zod"
|
||||
import { ValidationError } from "./error"
|
||||
import MassargOption, { MassargFlag, MassargNumber, OptionConfig, OptionType } from "./option"
|
||||
import { isZodError } from "./utils"
|
||||
|
||||
export const CommandConfig = <RunArgs extends z.ZodType>(args: RunArgs) =>
|
||||
z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
aliases: z.string().array().optional(),
|
||||
run: z
|
||||
.function()
|
||||
.args(args)
|
||||
.returns(z.union([z.promise(z.void()), z.void()])) as z.ZodType<(args: z.infer<RunArgs>) => Promise<void> | void>,
|
||||
})
|
||||
|
||||
export type CommandConfig<T = unknown> = z.infer<ReturnType<typeof CommandConfig<z.ZodType<T>>>>
|
||||
|
||||
export type ArgsObject = Record<string, unknown>
|
||||
|
||||
export default class MassargCommand<Args extends ArgsObject = ArgsObject> {
|
||||
name: string
|
||||
description: string
|
||||
private aliases: string[]
|
||||
private _run?: (options: Args) => Promise<void> | void
|
||||
private options: MassargOption[] = []
|
||||
private commands: MassargCommand<any>[] = []
|
||||
private args: Partial<Args> = {}
|
||||
|
||||
constructor(options: CommandConfig<Args>) {
|
||||
CommandConfig(z.any()).parse(options)
|
||||
this.name = options.name
|
||||
this.description = options.description
|
||||
this.aliases = options.aliases ?? []
|
||||
this._run = options.run
|
||||
}
|
||||
|
||||
command(config: CommandConfig<Args>): MassargCommand<Args>
|
||||
command(config: MassargCommand<Args>): MassargCommand<Args>
|
||||
command(config: CommandConfig<Args> | MassargCommand<Args>): MassargCommand<Args> {
|
||||
try {
|
||||
const command = config instanceof MassargCommand ? config : new MassargCommand(config)
|
||||
this.commands.push(command)
|
||||
return this
|
||||
} catch (e) {
|
||||
if (isZodError(e)) {
|
||||
throw new ValidationError({
|
||||
path: [config.name, ...e.issues[0].path.map((p) => p.toString())],
|
||||
code: e.issues[0].code,
|
||||
message: e.issues[0].message,
|
||||
})
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
option<T = string>(config: MassargOption<T>): MassargCommand<Args>
|
||||
option<T = string>(config: OptionConfig<T> & { type?: OptionType }): MassargCommand<Args>
|
||||
option<T = string>(config: (OptionConfig<T> & { type?: OptionType }) | MassargOption<T>): MassargCommand<Args> {
|
||||
const factory = () => {
|
||||
if (!("type" in config)) {
|
||||
return new MassargOption(config as OptionConfig<T>)
|
||||
}
|
||||
switch (config.type) {
|
||||
case "string":
|
||||
return new MassargOption<string>(config as OptionConfig<string>)
|
||||
case "number":
|
||||
return new MassargNumber(config as OptionConfig<number>)
|
||||
case "boolean":
|
||||
return new MassargFlag(config)
|
||||
}
|
||||
}
|
||||
try {
|
||||
const option = config instanceof MassargOption ? config : factory()
|
||||
this.options.push(option as MassargOption)
|
||||
return this
|
||||
} catch (e) {
|
||||
if (isZodError(e)) {
|
||||
throw new ValidationError({
|
||||
path: [config.name, ...e.issues[0].path.map((p) => p.toString())],
|
||||
code: e.issues[0].code,
|
||||
message: e.issues[0].message,
|
||||
})
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
main(run: (options: Args) => Promise<void> | void): MassargCommand<Args> {
|
||||
this._run = run
|
||||
return this
|
||||
}
|
||||
|
||||
parse(argv: string[], args?: Partial<Args>): Promise<void> | void {
|
||||
console.log("parse:", this.name)
|
||||
console.log(argv)
|
||||
this.args ??= {}
|
||||
this.args = { ...this.args, ...args }
|
||||
let _argv = [...argv]
|
||||
while (_argv.length) {
|
||||
const arg = _argv.shift()!
|
||||
console.log("parsing:", arg, _argv)
|
||||
if (arg.startsWith("-")) {
|
||||
console.log("option:", arg, _argv)
|
||||
_argv = this.parseOption(arg, _argv)
|
||||
continue
|
||||
}
|
||||
|
||||
const command = this.commands.find((c) => c.name === arg || c.aliases.includes(arg))
|
||||
if (command) {
|
||||
console.log("command:", arg, _argv)
|
||||
return command.parse(_argv, this.args)
|
||||
}
|
||||
// TODO pass all un-handled args to an "args" option
|
||||
console.log("Nothing to do", arg, _argv)
|
||||
}
|
||||
if (this._run) {
|
||||
console.log("run:", this.args)
|
||||
this._run({ ...args, ...this.args } as Args)
|
||||
}
|
||||
}
|
||||
|
||||
private parseOption(arg: string, argv: string[]): string[] {
|
||||
const option = this.options.find(
|
||||
(o) => (arg.startsWith("--") && o.name === arg.slice(2)) || o.aliases.includes(arg.slice(1)),
|
||||
)
|
||||
if (!option) {
|
||||
// TODO create custom error object
|
||||
throw new Error(`Unknown option ${arg}`)
|
||||
}
|
||||
const res = option.valueFromArgv([arg, ...argv])
|
||||
this.args[res.key as keyof Args] = res.value as Args[keyof Args]
|
||||
console.log("option response:", { value: res.value, argv: res.argv })
|
||||
return res.argv
|
||||
}
|
||||
|
||||
getArgs(argv: string[]): Args {
|
||||
console.log("getArgs:", this.name)
|
||||
console.log(argv)
|
||||
return {} as Args
|
||||
}
|
||||
}
|
||||
|
||||
export { MassargCommand }
|
||||
28
src/error.ts
Normal file
28
src/error.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export class ValidationError extends Error {
|
||||
path: string[]
|
||||
code: string
|
||||
message: string
|
||||
|
||||
constructor({ path, code, message }: { path: string[]; code: string; message: string }) {
|
||||
const msg = `${path.join(".")}: ${message}`
|
||||
super(msg)
|
||||
this.path = path
|
||||
this.code = code
|
||||
this.message = msg
|
||||
this.name = "ValidationError"
|
||||
}
|
||||
}
|
||||
export class ParseError extends Error {
|
||||
path: string[]
|
||||
code: string
|
||||
message: string
|
||||
|
||||
constructor({ path, code, message }: { path: string[]; code: string; message: string }) {
|
||||
const msg = `${path.join(".")}: ${message}`
|
||||
super(msg)
|
||||
this.path = path
|
||||
this.code = code
|
||||
this.message = msg
|
||||
this.name = "ParseError"
|
||||
}
|
||||
}
|
||||
41
src/example.ts
Normal file
41
src/example.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { massarg } from "."
|
||||
import MassargCommand from "./command"
|
||||
|
||||
type A = { test: boolean }
|
||||
const args = massarg<A>({
|
||||
name: "my-cli",
|
||||
description: "This is an example CLI",
|
||||
})
|
||||
.main((opts) => {
|
||||
console.log("Main command - printing all opts")
|
||||
console.log(opts)
|
||||
})
|
||||
.command(
|
||||
new MassargCommand<A>({
|
||||
name: "command",
|
||||
description: "Example command",
|
||||
aliases: ["c"],
|
||||
run: (opts) => {
|
||||
console.log("`command` Command - printing all opts")
|
||||
console.log(opts)
|
||||
},
|
||||
}).option({
|
||||
name: "command-option",
|
||||
description: "Example command option",
|
||||
aliases: ["o"],
|
||||
// aliases: "" as never,
|
||||
}),
|
||||
)
|
||||
.option({
|
||||
name: "number",
|
||||
description: "Example number option",
|
||||
aliases: ["n"],
|
||||
type: "number",
|
||||
parse: (s) => parseFloat(s),
|
||||
})
|
||||
|
||||
const opts = args.getArgs(process.argv.slice(2))
|
||||
|
||||
console.log("Opts:", opts)
|
||||
|
||||
args.parse(process.argv.slice(2))
|
||||
571
src/index.ts
571
src/index.ts
@@ -1,570 +1 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import chalk from "chalk"
|
||||
import merge from "lodash/merge"
|
||||
import camelCase from "lodash/camelCase"
|
||||
import path from "path"
|
||||
import { OptionsBase, CommandDef, HelpDef, MainDef, OptionDef, ExampleDef } from "./types"
|
||||
import { ArrayOr, asArray, colorCount, COLOR_CODE_LEN, wrap } from "./utils"
|
||||
import { RequiredError } from "./errors"
|
||||
import { assertCommand, assertExample, assertHelp, assertMain, assertOption } from "./assertions"
|
||||
|
||||
export class Massarg<Options> {
|
||||
private _main?: MainDef<Options>
|
||||
private _options: OptionDef<Options, any>[] = []
|
||||
private _commands: CommandDef<Options>[] = []
|
||||
private _runCommand?: CommandDef<Options>
|
||||
private _examples: ExampleDef[] = []
|
||||
private _maxNameLen = 0
|
||||
/**
|
||||
* These are the parsed options passed via args. They will only be available after using `parse()` or `printHelp()`,
|
||||
* or when returned by `parseArgs()`. */
|
||||
public data: Options & OptionsBase = { help: false, extras: [] as string[] } as Options & OptionsBase
|
||||
|
||||
private _help: Required<HelpDef> = {
|
||||
binName: undefined as any,
|
||||
normalColors: "dim",
|
||||
highlightColors: "yellow",
|
||||
titleColors: ["bold", "white"],
|
||||
subtitleColors: ["bold", "dim"],
|
||||
bodyColors: "white",
|
||||
printWidth: 80,
|
||||
header: "",
|
||||
footer: "",
|
||||
commandNameSeparator: " | ",
|
||||
optionNameSeparator: "|",
|
||||
useGlobalColumns: true,
|
||||
usageExample: "[command] [options]",
|
||||
useColors: true,
|
||||
includeDefaults: true,
|
||||
exampleInputPrefix: "$",
|
||||
exampleOutputPrefix: "➜",
|
||||
}
|
||||
private _requiredOptions: Record<"all" | string, Record<string, boolean>> = {}
|
||||
|
||||
constructor() {
|
||||
this.option({
|
||||
name: "help",
|
||||
aliases: ["h"],
|
||||
description: "Display help information",
|
||||
parse: Boolean,
|
||||
})
|
||||
}
|
||||
|
||||
/** Define the main command to run when no commands are passed. */
|
||||
public main(run: MainDef<Options>): Massarg<Options> {
|
||||
assertMain(run)
|
||||
this._main = run
|
||||
return this
|
||||
}
|
||||
|
||||
/** Add option to be parsed */
|
||||
public option<Value>(option: OptionDef<Options, Value>): Massarg<Options> {
|
||||
let defaultValue = option.defaultValue as any
|
||||
|
||||
// detect boolean values
|
||||
option.boolean ??= (option.parse as any) === Boolean || [true, false].includes(defaultValue)
|
||||
// detect array values
|
||||
option.array ??= Array.isArray(defaultValue)
|
||||
// default parser
|
||||
option.parse ??= (option.boolean ? this._isTruthy : (a: any) => a) as any
|
||||
|
||||
assertOption(option, this._options)
|
||||
|
||||
if (option.array && defaultValue === undefined) {
|
||||
defaultValue = []
|
||||
}
|
||||
|
||||
this._options.push(option)
|
||||
this._prepareRequired(option)
|
||||
return this
|
||||
}
|
||||
|
||||
/** Add example line to be added to the help text. */
|
||||
public example(example: ExampleDef): Massarg<Options> {
|
||||
assertExample(example)
|
||||
|
||||
this._examples.push(example as ExampleDef)
|
||||
return this
|
||||
}
|
||||
|
||||
/** Add command to be run */
|
||||
public command(command: CommandDef<Options>): Massarg<Options> {
|
||||
assertCommand(command, this._commands)
|
||||
|
||||
this._commands.push(command)
|
||||
for (const opt of this._commandOptions(command)) {
|
||||
this._prepareRequired(opt)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
/** Set options for behavior of the help text print. */
|
||||
public help(help: HelpDef): Massarg<Options> {
|
||||
assertHelp(help)
|
||||
|
||||
this._help = merge(this._help, help)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the help text without being required to pass option.
|
||||
*
|
||||
* @param args If args weren't already parsed, you can add them here
|
||||
*/
|
||||
public printHelp(args?: string[]): void {
|
||||
console.log(this.getHelpString(args).join("\n"))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the help text as an array of lines. Useful for manipulating the response or querying before displaying
|
||||
* to the user.
|
||||
*/
|
||||
public getHelpString(args?: string[]): string[] {
|
||||
const lines: string[] = []
|
||||
|
||||
if (args?.length) {
|
||||
this.parseArgs(args)
|
||||
}
|
||||
|
||||
const { bodyColors, highlightColors, normalColors, titleColors, binName, usageExample } = this._help
|
||||
|
||||
lines.push(
|
||||
[
|
||||
this.color(titleColors, "Usage:"),
|
||||
this.color(highlightColors, binName ?? path.basename(process.argv[1])),
|
||||
this.color(normalColors, usageExample),
|
||||
].join(" ")
|
||||
)
|
||||
|
||||
lines.push("")
|
||||
|
||||
if (this._help.header) {
|
||||
lines.push(this.color(bodyColors, this._help.header))
|
||||
lines.push("")
|
||||
}
|
||||
|
||||
if (this._commands.length) {
|
||||
lines.push(this.color(titleColors, "Commands:"))
|
||||
lines.push("")
|
||||
lines.push(...this._printCommands())
|
||||
}
|
||||
|
||||
lines.push(...this._printOptions())
|
||||
|
||||
if (this._examples.length) {
|
||||
lines.push(this.color(titleColors, "Examples:"))
|
||||
lines.push("")
|
||||
lines.push(...this._printExamples())
|
||||
}
|
||||
|
||||
if (this._help.footer) {
|
||||
lines.push(this.color(bodyColors, this._help.footer))
|
||||
lines.push("")
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the arguments without running the commands related to them. Useful for testing or querying the data from the
|
||||
* args manually, if it is for some reason not enough to parse it normally through defining commands.
|
||||
* @param args Arguments to parse. Defaults to `process.argv`
|
||||
* @returns Parsed options
|
||||
*/
|
||||
public parseArgs(args = process.argv): Options & OptionsBase {
|
||||
for (const option of this._options) {
|
||||
if (option.defaultValue !== undefined) {
|
||||
this._addOptionToData(option, option.defaultValue)
|
||||
} else if (option.array) {
|
||||
this._pushToArrayData(option)
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i]
|
||||
const option = this._options.find(
|
||||
(o) =>
|
||||
// long format
|
||||
`--${o.name}` === arg ||
|
||||
// short format - boolean negate
|
||||
(o.boolean && `--no-${o.name}` === arg) ||
|
||||
// short format
|
||||
o.aliases?.map((a) => `-${a}`).includes(arg) ||
|
||||
// short format - boolean negate
|
||||
(o.boolean && o.aliases?.map((a) => `-!${a}`).includes(arg))
|
||||
//
|
||||
)
|
||||
|
||||
const mightContainDefaultValue = this._options.some((o) => o.isDefault)
|
||||
|
||||
if (option) {
|
||||
let tempValue: any
|
||||
const hasNextToken = args.length > i + 1
|
||||
const nextTokenIsValue =
|
||||
hasNextToken &&
|
||||
(option.boolean ? mightContainDefaultValue || !args[i + 1].startsWith("-") : !args[i + 1].startsWith("-"))
|
||||
|
||||
if (option.boolean && (!hasNextToken || !nextTokenIsValue)) {
|
||||
// parse boolean args w/o value
|
||||
tempValue = !arg.replace(/^-+/, "").startsWith("!")
|
||||
} else if (!hasNextToken || !nextTokenIsValue) {
|
||||
// non-boolean args with no value
|
||||
throw new TypeError(`Missing value for: ${option.name}`)
|
||||
} else {
|
||||
// any args (incl. boolean) with value
|
||||
tempValue = args[i + 1]
|
||||
args.shift()
|
||||
}
|
||||
const value = option.parse!(tempValue, this.data)
|
||||
this._addOptionToData(option, value)
|
||||
|
||||
// continue
|
||||
}
|
||||
|
||||
const command = this._commands.find((o) => o.name === arg || o.aliases?.includes(arg))
|
||||
|
||||
let justFoundCommand = false
|
||||
|
||||
if (command) {
|
||||
if (!this._runCommand) {
|
||||
this._runCommand = command
|
||||
justFoundCommand = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!option && (!command || (command && !justFoundCommand))) {
|
||||
const defOpts = this._options.filter((o) => o.isDefault)
|
||||
if (defOpts.length) {
|
||||
for (const option of defOpts) {
|
||||
this._addOptionToData(option, option.parse!(arg, this.data))
|
||||
}
|
||||
} else {
|
||||
this.data.extras.push(arg)
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the given args, running any relevant commands in the process.
|
||||
*
|
||||
* @param args args to parse. Defaults to `process.argv`
|
||||
*/
|
||||
public parse(args?: string[]): void {
|
||||
this.parseArgs(args)
|
||||
|
||||
if (this.data.help) {
|
||||
this.printHelp()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (this._runCommand) {
|
||||
this._ensureRequired(this._runCommand)
|
||||
this._runCommand.run(this.data)
|
||||
} else if (this._main) {
|
||||
this._ensureRequired()
|
||||
this._main(this.data)
|
||||
} else {
|
||||
this._ensureRequired()
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (RequiredError.isRequiredError(e)) {
|
||||
console.error(chalk.red`${e.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
private _prepareRequired(options: OptionDef<Options, any>) {
|
||||
if (options.required) {
|
||||
if (options.commands?.length) {
|
||||
for (const command of this._optionCommands(options)) {
|
||||
this._requiredOptions[command.name] ??= {}
|
||||
this._requiredOptions[command.name][options.name] = true
|
||||
}
|
||||
} else {
|
||||
this._requiredOptions["all"] ??= {}
|
||||
this._requiredOptions["all"][options.name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _printExamples(): string[] {
|
||||
const lines: string[] = []
|
||||
const { normalColors, highlightColors, bodyColors, titleColors } = this._help
|
||||
for (const example of this._examples) {
|
||||
if (example.description) {
|
||||
lines.push(
|
||||
...wrap(this.color(titleColors, example.description), {
|
||||
colorCount: this.colorCount(titleColors),
|
||||
indent: 2,
|
||||
printWidth: this._help.printWidth,
|
||||
})
|
||||
)
|
||||
lines.push("")
|
||||
}
|
||||
|
||||
lines.push(
|
||||
...wrap(
|
||||
[this.color(normalColors, this._help.exampleInputPrefix), this.color(highlightColors, example.input)].join(
|
||||
" "
|
||||
),
|
||||
{
|
||||
colorCount: this.colorCount(highlightColors),
|
||||
firstLineIndent: 2,
|
||||
indent: 3 + this._help.exampleInputPrefix.length,
|
||||
// indent: this.colorCount(normalColors) + 4,
|
||||
printWidth: this._help.printWidth,
|
||||
}
|
||||
)
|
||||
)
|
||||
if (example.output) {
|
||||
lines.push(
|
||||
...wrap(
|
||||
[this.color(normalColors, this._help.exampleOutputPrefix), this.color(bodyColors, example.output)].join(
|
||||
" "
|
||||
),
|
||||
{
|
||||
colorCount: this.colorCount(bodyColors),
|
||||
firstLineIndent: 2,
|
||||
indent: 3 + this._help.exampleOutputPrefix.length,
|
||||
// indent: this.colorCount(normalColors) + 4,
|
||||
printWidth: this._help.printWidth,
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
lines.push("")
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
private _isTruthy(v: any): boolean {
|
||||
v = String(v).toLowerCase()
|
||||
return ["1", "true", "yes", "y", "on"].includes(v) || !["0", "false", "no", "n", "off"].includes(v)
|
||||
}
|
||||
|
||||
private _ensureRequired(cmd?: CommandDef<Options>) {
|
||||
const cmdName = cmd?.name ?? "all"
|
||||
|
||||
for (const optName in this._requiredOptions[cmdName]) {
|
||||
if (this._requiredOptions[cmdName][optName]) {
|
||||
throw new RequiredError(optName, cmdName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _addOptionToData(option: OptionDef<Options, any>, value: any) {
|
||||
const _d: Record<string, any> = this.data
|
||||
|
||||
const set = (value: any) => {
|
||||
_d[option.name] = value
|
||||
_d[camelCase(option.name)] = value
|
||||
option.aliases?.forEach((a) => (_d[a] = value))
|
||||
}
|
||||
const push = (value: any) => {
|
||||
this._pushToArrayData(option, value)
|
||||
}
|
||||
if (!option.array) {
|
||||
// single value
|
||||
set(value)
|
||||
} else {
|
||||
// multiple values
|
||||
if (Array.isArray(value) && value.length) {
|
||||
for (const el of value) {
|
||||
push(el)
|
||||
}
|
||||
} else if (!Array.isArray(value)) {
|
||||
push(value)
|
||||
}
|
||||
}
|
||||
if (value !== option.defaultValue && value !== undefined) {
|
||||
for (const key in this._requiredOptions) {
|
||||
this._requiredOptions[key][option.name] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _pushToArrayData(option: OptionDef<Options, any>, value?: any) {
|
||||
const _d: Record<string, any> = this.data
|
||||
|
||||
const ccSame = camelCase(option.name) === option.name
|
||||
_d[option.name] ??= []
|
||||
_d[camelCase(option.name)] ??= []
|
||||
option.aliases?.forEach((a) => (_d[a] ??= []))
|
||||
|
||||
if (value !== undefined) {
|
||||
_d[option.name].push(value)
|
||||
if (!ccSame) {
|
||||
_d[camelCase(option.name)].push(value)
|
||||
}
|
||||
option.aliases?.forEach((a) => _d[a].push(value))
|
||||
}
|
||||
}
|
||||
|
||||
private _getWrappedLines(
|
||||
list: Array<{ name: string; description?: string; additionalColorCount?: number }>
|
||||
): string[] {
|
||||
const { normalColors, highlightColors } = this._help
|
||||
const lines: string[] = []
|
||||
|
||||
let maxNameLen = this._help.useGlobalColumns ? this._maxNameLen ?? 0 : 0
|
||||
for (const item of list) {
|
||||
if (item.name.length > maxNameLen) {
|
||||
maxNameLen = item.name.length
|
||||
}
|
||||
}
|
||||
if (this._help.useGlobalColumns) {
|
||||
this._maxNameLen = maxNameLen
|
||||
}
|
||||
|
||||
const ARG_SPACE_LEN = 2
|
||||
const INDENT_LEN = 2
|
||||
const nameFullSize = maxNameLen + ARG_SPACE_LEN + INDENT_LEN
|
||||
|
||||
for (const item of list) {
|
||||
const cmdName = this.color(highlightColors, `${item.name}`).padEnd(
|
||||
nameFullSize + this.colorCount(highlightColors) * COLOR_CODE_LEN,
|
||||
" "
|
||||
)
|
||||
const cmdDesc = this.color(normalColors, item.description ?? "")
|
||||
|
||||
for (const line of wrap(cmdName + cmdDesc, {
|
||||
indent: nameFullSize + INDENT_LEN,
|
||||
colorCount: this.colorCount(
|
||||
normalColors,
|
||||
highlightColors,
|
||||
item.additionalColorCount ? new Array({ length: item.additionalColorCount }) : []
|
||||
),
|
||||
firstLineIndent: INDENT_LEN,
|
||||
printWidth: this._help.printWidth,
|
||||
})) {
|
||||
lines.push(line)
|
||||
}
|
||||
|
||||
lines.push("")
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
private _printCommands(): string[] {
|
||||
return this._getWrappedLines(
|
||||
this._commands.map((c) => ({ name: this._fullCmdName(c), description: c.description }))
|
||||
)
|
||||
}
|
||||
|
||||
private _printOptions(): string[] {
|
||||
const lines: string[] = []
|
||||
|
||||
const { titleColors, subtitleColors } = this._help
|
||||
|
||||
const commandOpts: string[] = []
|
||||
|
||||
for (const cmd of this._commands) {
|
||||
const opts = this._commandOptions(cmd)
|
||||
if (opts.length) {
|
||||
commandOpts.push(this.color(subtitleColors, `${cmd.name}:`))
|
||||
commandOpts.push("")
|
||||
|
||||
for (const line of this._getWrappedLines(
|
||||
opts.map((c) => ({
|
||||
name: this._fullOptName(c),
|
||||
description: this._optionDescription(c),
|
||||
additionalColorCount: c.defaultValue !== undefined ? 1 : 0,
|
||||
}))
|
||||
)) {
|
||||
commandOpts.push(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(this.color(titleColors, commandOpts.length ? "Command Options:" : "Options:"))
|
||||
lines.push("")
|
||||
|
||||
for (const line of commandOpts) {
|
||||
lines.push(line)
|
||||
}
|
||||
|
||||
const globalOpts = this._globalOptions()
|
||||
if (globalOpts.length) {
|
||||
if (commandOpts.length) {
|
||||
lines.push(this.color(titleColors, "Global Options:"))
|
||||
lines.push("")
|
||||
}
|
||||
for (const line of this._getWrappedLines(
|
||||
globalOpts.map((c) => ({ name: this._fullOptName(c), description: this._optionDescription(c) }))
|
||||
)) {
|
||||
lines.push(line)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
private _optionDescription(c: OptionDef<Options, any>): string | undefined {
|
||||
if (c.defaultValue === undefined || !this._help.includeDefaults) {
|
||||
return c.description
|
||||
}
|
||||
|
||||
return [c.description!, this.color(this._help.bodyColors, `(default: ${c.defaultValue.toString().trim()})`)]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
private _fullCmdName(cmd: CommandDef<Options>) {
|
||||
return [cmd.name, ...(cmd.aliases ?? [])].join(this._help.commandNameSeparator)
|
||||
}
|
||||
|
||||
private _fullOptName(opt: OptionDef<Options, any>) {
|
||||
return [`--${opt.name}`, ...(opt.aliases ?? []).map((a) => `-${a}`)].join(this._help.optionNameSeparator)
|
||||
}
|
||||
|
||||
private _commandOptions(cmd: CommandDef<Options>): OptionDef<Options, any>[] {
|
||||
return this._options.filter(
|
||||
(o) =>
|
||||
(asArray(o.commands).length && asArray(o.commands).includes(cmd.name)) ||
|
||||
cmd.aliases?.some((a) => asArray(o.commands).includes(a))
|
||||
)
|
||||
}
|
||||
|
||||
private _optionCommands(opt: OptionDef<Options, any>): OptionDef<Options, any>[] {
|
||||
return this._commands.filter((c) => {
|
||||
return asArray(opt.commands).some((_c) => {
|
||||
return [c.name, ...(c.aliases ?? [])].includes(_c!)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private _globalOptions(): OptionDef<Options, any>[] {
|
||||
return this._options.filter((o) => !o.commands)
|
||||
}
|
||||
|
||||
private color(color: ArrayOr<keyof typeof chalk>, ...text: any[]): string {
|
||||
if (!this._help.useColors) {
|
||||
return text.join(" ")
|
||||
}
|
||||
let output: string = undefined as any
|
||||
for (const c of asArray(color)) {
|
||||
output = (chalk[c as keyof typeof chalk] as typeof chalk.dim)(...(output ? [output] : text))
|
||||
}
|
||||
return chalk.reset(output)
|
||||
}
|
||||
|
||||
private colorCount(...colors: any[]): number {
|
||||
if (!this._help.useColors) {
|
||||
return 0
|
||||
}
|
||||
return colorCount(...colors)
|
||||
}
|
||||
}
|
||||
|
||||
export function massarg<T>() {
|
||||
return new Massarg<T>()
|
||||
}
|
||||
|
||||
export default massarg
|
||||
export * from "./massarg"
|
||||
|
||||
23
src/massarg.ts
Normal file
23
src/massarg.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import MassargCommand, { ArgsObject, CommandConfig } from "./command"
|
||||
|
||||
type MinimalCommandConfig<Args extends ArgsObject> = Omit<CommandConfig<Args>, "aliases" | "run">
|
||||
|
||||
export default class Massarg<Args extends ArgsObject = ArgsObject> extends MassargCommand<Args> {
|
||||
constructor(options: MinimalCommandConfig<Args>) {
|
||||
// TODO consider re-using name and description for general help, and pass them to super
|
||||
super({
|
||||
...options,
|
||||
aliases: [],
|
||||
run: () => {
|
||||
throw new Error("Massarg is not a command")
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
export { Massarg }
|
||||
|
||||
export function massarg<Args extends ArgsObject = ArgsObject>(
|
||||
options: MinimalCommandConfig<Args>,
|
||||
): MassargCommand<Args> {
|
||||
return new Massarg(options)
|
||||
}
|
||||
113
src/option.ts
Normal file
113
src/option.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { z } from "zod"
|
||||
import { ParseError } from "./error"
|
||||
import { isZodError } from "./utils"
|
||||
|
||||
export const OptionConfig = <T extends z.ZodType>(type: T) =>
|
||||
z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
defaultValue: z.any().optional(),
|
||||
aliases: z.string().array(),
|
||||
parse: z.function().args(z.string()).returns(type).optional(),
|
||||
})
|
||||
export type OptionConfig<T = unknown> = z.infer<ReturnType<typeof OptionConfig<z.ZodType<T>>>>
|
||||
|
||||
export type OptionType = "string" | "number" | "boolean"
|
||||
|
||||
export default class MassargOption<T = unknown> {
|
||||
name: string
|
||||
description: string
|
||||
defaultValue?: T
|
||||
aliases: string[]
|
||||
parse: (value: string) => T
|
||||
|
||||
constructor(options: OptionConfig<T>) {
|
||||
OptionConfig(z.any()).parse(options)
|
||||
this.name = options.name
|
||||
this.description = options.description
|
||||
this.defaultValue = options.defaultValue
|
||||
this.aliases = options.aliases
|
||||
this.parse = options.parse ?? ((x) => x as unknown as T)
|
||||
}
|
||||
|
||||
valueFromArgv(argv: string[]): { argv: string[]; value: T; key: string } {
|
||||
// TODO: support --option=value
|
||||
argv.shift()
|
||||
try {
|
||||
const value = this.parse(argv.shift()!)
|
||||
return { key: this.name, value, argv }
|
||||
} catch (e) {
|
||||
if (isZodError(e)) {
|
||||
throw new ParseError({
|
||||
path: [this.name, ...e.issues[0].path.map((p) => p.toString())],
|
||||
code: e.issues[0].code,
|
||||
message: e.issues[0].message,
|
||||
})
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class MassargNumber extends MassargOption<number> {
|
||||
constructor(options: Omit<OptionConfig<number>, "parse">) {
|
||||
super({
|
||||
...options,
|
||||
parse: (value) => Number(value),
|
||||
})
|
||||
}
|
||||
|
||||
valueFromArgv(argv: string[]): { argv: string[]; value: number; key: string } {
|
||||
try {
|
||||
const { argv: _argv, value } = super.valueFromArgv(argv)
|
||||
if (isNaN(value)) {
|
||||
throw new ParseError({
|
||||
path: [this.name],
|
||||
code: "invalid_type",
|
||||
message: "Expected a number",
|
||||
})
|
||||
}
|
||||
return { key: this.name, value, argv: _argv }
|
||||
} catch (e) {
|
||||
if (isZodError(e)) {
|
||||
throw new ParseError({
|
||||
path: [this.name, ...e.issues[0].path.map((p) => p.toString())],
|
||||
code: e.issues[0].code,
|
||||
message: e.issues[0].message,
|
||||
})
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class MassargFlag extends MassargOption<boolean> {
|
||||
constructor(options: Omit<OptionConfig<boolean>, "parse">) {
|
||||
super({
|
||||
...options,
|
||||
parse: () => true,
|
||||
})
|
||||
}
|
||||
|
||||
valueFromArgv(argv: string[]): { argv: string[]; value: boolean; key: string } {
|
||||
try {
|
||||
const isNegation = argv[0]?.startsWith("-!")
|
||||
argv.shift()
|
||||
if (isNegation) {
|
||||
return { key: this.name, value: false, argv }
|
||||
}
|
||||
return { key: this.name, value: true, argv }
|
||||
} catch (e) {
|
||||
if (isZodError(e)) {
|
||||
throw new ParseError({
|
||||
path: [this.name, ...e.issues[0].path.map((p) => p.toString())],
|
||||
code: e.issues[0].code,
|
||||
message: e.issues[0].message,
|
||||
})
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { MassargOption }
|
||||
89
src/utils.ts
89
src/utils.ts
@@ -1,88 +1,5 @@
|
||||
import chalk from "chalk"
|
||||
// import chunk from "lodash/chunk"
|
||||
import repeat from "lodash/repeat"
|
||||
import merge from "lodash/merge"
|
||||
import { z } from "zod"
|
||||
|
||||
export function color(color: ArrayOr<keyof typeof chalk>, ...text: any[]): string {
|
||||
let output: string = undefined as any
|
||||
for (const c of asArray(color)) {
|
||||
output = (chalk[c as keyof typeof chalk] as typeof chalk.dim)(...(output ? [output] : text))
|
||||
}
|
||||
return chalk.reset(output)
|
||||
}
|
||||
|
||||
export function colorCount(...colors: any[]): number {
|
||||
return asArray(colors).reduce((all, colorSet) => all + asArray(colorSet).length, 0)
|
||||
}
|
||||
|
||||
export interface WrapOptions {
|
||||
indent?: number
|
||||
firstLineIndent?: number
|
||||
printWidth?: number
|
||||
colorCount?: number
|
||||
}
|
||||
|
||||
export function wrap(text: string, options?: WrapOptions): string[] {
|
||||
const _opts = merge(
|
||||
{
|
||||
printWidth: 100,
|
||||
indent: 0,
|
||||
colorCount: 0,
|
||||
} as WrapOptions,
|
||||
options
|
||||
) as Required<WrapOptions>
|
||||
|
||||
const indentSize = _opts.indent ?? 0
|
||||
const firstIndentSize = _opts.firstLineIndent ?? indentSize
|
||||
const maxLineLength = _opts.printWidth - firstIndentSize + COLOR_CODE_LEN * _opts.colorCount
|
||||
|
||||
function indent(i: number, l: string): string {
|
||||
return repeat(" ", i === 0 ? firstIndentSize : indentSize) + l
|
||||
}
|
||||
|
||||
if (!_opts.printWidth || maxLineLength <= 0) {
|
||||
return text.split("\n").map((l, i) => indent(i, l))
|
||||
}
|
||||
|
||||
let lines = chunk(text, maxLineLength).map((l, i) => indent(i, l))
|
||||
|
||||
lines = [
|
||||
lines[0],
|
||||
...chunk(
|
||||
lines
|
||||
.slice(1)
|
||||
.map((l) => l.trim())
|
||||
.join(" ")
|
||||
.trim(),
|
||||
maxLineLength - indentSize - COLOR_CODE_LEN
|
||||
).map((l, i) => indent(i + 1, l)),
|
||||
].filter((l) => l.trim().length)
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
export const COLOR_CODE_LEN = color("yellow", " ").length - 1
|
||||
|
||||
function chunk(text: string, len: number): string[] {
|
||||
const arr = text.split(" ")
|
||||
const result = []
|
||||
let subStr = arr[0]
|
||||
for (let i = 1; i < arr.length; i++) {
|
||||
let word = arr[i]
|
||||
if (subStr.length + word.length + 1 <= len) {
|
||||
subStr = subStr + " " + word
|
||||
} else {
|
||||
result.push(subStr)
|
||||
subStr = word
|
||||
}
|
||||
}
|
||||
if (subStr.length) {
|
||||
result.push(subStr)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export type ArrayOr<T> = T | T[]
|
||||
export function asArray<T>(obj: T | T[]): T[] {
|
||||
return Array.isArray(obj) ? obj ?? [] : obj ? [obj] : []
|
||||
export function isZodError(e: unknown): e is z.ZodError {
|
||||
return e instanceof z.ZodError
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user