feat: v2 poc

This commit is contained in:
2023-11-18 23:26:20 +02:00
committed by Chen Asraf
parent 3dcdef25c7
commit 54763276e7
15 changed files with 3800 additions and 664 deletions

View File

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

File diff suppressed because it is too large Load Diff

555
src/_old/index.ts Normal file
View 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
View 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
View 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
View 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
View 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))

View File

@@ -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
View 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
View 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 }

View File

@@ -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
}