mirror of
https://github.com/chenasraf/massarg.git
synced 2026-05-18 01:39:05 +00:00
feat: array & typed options
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod"
|
||||
import { ValidationError } from "./error"
|
||||
import MassargOption, { MassargFlag, MassargNumber, OptionConfig, OptionType } from "./option"
|
||||
import { isZodError } from "./utils"
|
||||
import MassargOption, { MassargFlag, MassargNumber, OptionConfig, TypedOptionConfig } from "./option"
|
||||
import { generateCommandsHelpTable, generateOptionsHelpTable, isZodError } from "./utils"
|
||||
|
||||
export const CommandConfig = <RunArgs extends z.ZodType>(args: RunArgs) =>
|
||||
z.object({
|
||||
@@ -18,14 +18,16 @@ export type CommandConfig<T = unknown> = z.infer<ReturnType<typeof CommandConfig
|
||||
|
||||
export type ArgsObject = Record<string, unknown>
|
||||
|
||||
// export type RunFn<Args extends ArgsObject> = (options: Args) => Promise<void> | void
|
||||
|
||||
export default class MassargCommand<Args extends ArgsObject = ArgsObject> {
|
||||
name: string
|
||||
description: string
|
||||
private aliases: string[]
|
||||
aliases: string[]
|
||||
private _run?: (options: Args) => Promise<void> | void
|
||||
private options: MassargOption[] = []
|
||||
private commands: MassargCommand<any>[] = []
|
||||
private args: Partial<Args> = {}
|
||||
options: MassargOption[] = []
|
||||
commands: MassargCommand<any>[] = []
|
||||
args: Partial<Args> = {}
|
||||
|
||||
constructor(options: CommandConfig<Args>) {
|
||||
CommandConfig(z.any()).parse(options)
|
||||
@@ -35,9 +37,9 @@ export default class MassargCommand<Args extends ArgsObject = ArgsObject> {
|
||||
this._run = options.run
|
||||
}
|
||||
|
||||
command(config: CommandConfig<Args>): MassargCommand<Args>
|
||||
command(config: MassargCommand<Args>): MassargCommand<Args>
|
||||
command(config: CommandConfig<Args> | MassargCommand<Args>): MassargCommand<Args> {
|
||||
command<A extends ArgsObject = Args>(config: CommandConfig<A>): MassargCommand<Args>
|
||||
command<A extends ArgsObject = Args>(config: MassargCommand<A>): MassargCommand<Args>
|
||||
command<A extends ArgsObject = Args>(config: CommandConfig<A> | MassargCommand<A>): MassargCommand<Args> {
|
||||
try {
|
||||
const command = config instanceof MassargCommand ? config : new MassargCommand(config)
|
||||
this.commands.push(command)
|
||||
@@ -55,23 +57,10 @@ export default class MassargCommand<Args extends ArgsObject = ArgsObject> {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
option<T = string>(config: TypedOptionConfig<T>): MassargCommand<Args>
|
||||
option<T = string>(config: TypedOptionConfig<T> | MassargOption<T>): MassargCommand<Args> {
|
||||
try {
|
||||
const option = config instanceof MassargOption ? config : factory()
|
||||
const option = config instanceof MassargOption ? config : MassargOption.fromTypedConfig(config)
|
||||
this.options.push(option as MassargOption)
|
||||
return this
|
||||
} catch (e) {
|
||||
@@ -100,7 +89,8 @@ export default class MassargCommand<Args extends ArgsObject = ArgsObject> {
|
||||
while (_argv.length) {
|
||||
const arg = _argv.shift()!
|
||||
console.log("parsing:", arg, _argv)
|
||||
if (arg.startsWith("-")) {
|
||||
const found = this.options.some((o) => o._isOption(arg))
|
||||
if (found) {
|
||||
console.log("option:", arg, _argv)
|
||||
_argv = this.parseOption(arg, _argv)
|
||||
continue
|
||||
@@ -121,15 +111,21 @@ export default class MassargCommand<Args extends ArgsObject = ArgsObject> {
|
||||
}
|
||||
|
||||
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)),
|
||||
)
|
||||
const option = this.options.find((o) => o._match(arg))
|
||||
|
||||
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 class name", option.constructor.name)
|
||||
if (option.isArray) {
|
||||
this.args[res.key as keyof Args] ??= [] as Args[keyof Args]
|
||||
const _a = this.args[res.key as keyof Args] as unknown[]
|
||||
_a.push(res.value) as Args[keyof Args]
|
||||
} else {
|
||||
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
|
||||
}
|
||||
@@ -139,6 +135,22 @@ export default class MassargCommand<Args extends ArgsObject = ArgsObject> {
|
||||
console.log(argv)
|
||||
return {} as Args
|
||||
}
|
||||
|
||||
helpString(): string {
|
||||
const options = generateOptionsHelpTable(this.options)
|
||||
const commands = generateCommandsHelpTable(this.commands)
|
||||
return [
|
||||
`${this.name} - ${this.description}`,
|
||||
commands.length && "",
|
||||
commands.length && `Commands for ${this.name}:`,
|
||||
commands.length && commands,
|
||||
options.length && "",
|
||||
options.length && `Options for ${this.name}:`,
|
||||
options.length && options,
|
||||
]
|
||||
.filter((s) => typeof s === "string")
|
||||
.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
export { MassargCommand }
|
||||
|
||||
@@ -1,37 +1,84 @@
|
||||
import { massarg } from "."
|
||||
import MassargCommand from "./command"
|
||||
import { ParseError } from "./error"
|
||||
|
||||
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)
|
||||
})
|
||||
// .main((opts) => {
|
||||
// console.log("Main command - printing all opts")
|
||||
// console.log(opts)
|
||||
// })
|
||||
.command(
|
||||
new MassargCommand<A>({
|
||||
name: "command",
|
||||
description: "Example command",
|
||||
aliases: ["c"],
|
||||
massarg<{ component: string }>({
|
||||
name: "add",
|
||||
description: "Add a component",
|
||||
aliases: ["a"],
|
||||
run: (opts) => {
|
||||
console.log("`command` Command - printing all opts")
|
||||
console.log(opts)
|
||||
console.log("Adding component", opts.component)
|
||||
},
|
||||
})
|
||||
.option({
|
||||
name: "component",
|
||||
description: "Component to add",
|
||||
aliases: ["c"],
|
||||
// aliases: "" as never,
|
||||
})
|
||||
.option({
|
||||
name: "classes",
|
||||
description: "Classes to add",
|
||||
aliases: ["l"],
|
||||
array: true,
|
||||
})
|
||||
.option({
|
||||
name: "custom",
|
||||
description: "Custom option",
|
||||
aliases: ["x"],
|
||||
parse: (value) => {
|
||||
const asNumber = Number(value)
|
||||
if (isNaN(asNumber)) {
|
||||
throw new ParseError({
|
||||
path: ["custom"],
|
||||
message: "Custom option must be a number",
|
||||
code: "invalid_number",
|
||||
})
|
||||
}
|
||||
return {
|
||||
value: asNumber,
|
||||
half: asNumber / 2,
|
||||
double: asNumber * 2,
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
.command(
|
||||
new MassargCommand<{ component: string }>({
|
||||
name: "remove",
|
||||
description: "Remove a component",
|
||||
aliases: ["r"],
|
||||
run: (opts) => {
|
||||
console.log("Removing component", opts.component)
|
||||
},
|
||||
}).option({
|
||||
name: "command-option",
|
||||
description: "Example command option",
|
||||
aliases: ["o"],
|
||||
name: "component",
|
||||
description: "Component to remove",
|
||||
aliases: ["c"],
|
||||
// aliases: "" as never,
|
||||
}),
|
||||
)
|
||||
.option({
|
||||
name: "bool",
|
||||
description: "Example number option",
|
||||
aliases: ["b"],
|
||||
type: "boolean",
|
||||
})
|
||||
.option({
|
||||
name: "number",
|
||||
description: "Example number option",
|
||||
aliases: ["n"],
|
||||
type: "number",
|
||||
parse: (s) => parseFloat(s),
|
||||
})
|
||||
|
||||
const opts = args.getArgs(process.argv.slice(2))
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import MassargCommand, { ArgsObject, CommandConfig } from "./command"
|
||||
|
||||
type MinimalCommandConfig<Args extends ArgsObject> = Omit<CommandConfig<Args>, "aliases" | "run">
|
||||
type MinimalCommandConfig<Args extends ArgsObject> = Omit<CommandConfig<Args>, "aliases" | "run"> &
|
||||
Partial<Pick<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")
|
||||
console.log(this.helpString())
|
||||
// throw new Error("No main command provided")
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,32 @@ export const OptionConfig = <T extends z.ZodType>(type: T) =>
|
||||
defaultValue: z.any().optional(),
|
||||
aliases: z.string().array(),
|
||||
parse: z.function().args(z.string()).returns(type).optional(),
|
||||
array: z.boolean().optional(),
|
||||
})
|
||||
export type OptionConfig<T = unknown> = z.infer<ReturnType<typeof OptionConfig<z.ZodType<T>>>>
|
||||
|
||||
export type OptionType = "string" | "number" | "boolean"
|
||||
export const TypedOptionConfig = <T extends z.ZodType>(type: T) =>
|
||||
OptionConfig(type).merge(
|
||||
z.object({
|
||||
type: z.enum(["string", "number", "boolean"]).optional(),
|
||||
}),
|
||||
)
|
||||
export type TypedOptionConfig<T = unknown> = z.infer<ReturnType<typeof TypedOptionConfig<z.ZodType<T>>>>
|
||||
|
||||
export const ArrayOptionConfig = <T extends z.ZodType>(type: T) =>
|
||||
TypedOptionConfig(z.array(type)).merge(
|
||||
z.object({
|
||||
defaultValue: z.array(type).optional(),
|
||||
}),
|
||||
)
|
||||
export type ArrayOptionConfig<T = unknown> = z.infer<ReturnType<typeof ArrayOptionConfig<z.ZodType<T>>>>
|
||||
|
||||
const OPT_FULL_PREFIX = "--"
|
||||
const OPT_SHORT_PREFIX = "-"
|
||||
const NEGATE_FULL_PREFIX = "no-"
|
||||
const NEGATE_SHORT_PREFIX = "^"
|
||||
|
||||
export type ArgvValue<T> = { argv: string[]; value: T; key: string }
|
||||
|
||||
export default class MassargOption<T = unknown> {
|
||||
name: string
|
||||
@@ -20,6 +42,7 @@ export default class MassargOption<T = unknown> {
|
||||
defaultValue?: T
|
||||
aliases: string[]
|
||||
parse: (value: string) => T
|
||||
isArray: boolean
|
||||
|
||||
constructor(options: OptionConfig<T>) {
|
||||
OptionConfig(z.any()).parse(options)
|
||||
@@ -28,9 +51,20 @@ export default class MassargOption<T = unknown> {
|
||||
this.defaultValue = options.defaultValue
|
||||
this.aliases = options.aliases
|
||||
this.parse = options.parse ?? ((x) => x as unknown as T)
|
||||
this.isArray = options.array ?? false
|
||||
}
|
||||
|
||||
valueFromArgv(argv: string[]): { argv: string[]; value: T; key: string } {
|
||||
static fromTypedConfig<T = unknown>(config: TypedOptionConfig<T>): MassargOption<T> {
|
||||
switch (config.type) {
|
||||
case "number":
|
||||
return new MassargNumber(config as OptionConfig<number>) as MassargOption<T>
|
||||
case "boolean":
|
||||
return new MassargFlag(config) as MassargOption<T>
|
||||
}
|
||||
return new MassargOption(config as OptionConfig<T>)
|
||||
}
|
||||
|
||||
valueFromArgv(argv: string[]): ArgvValue<T> {
|
||||
// TODO: support --option=value
|
||||
argv.shift()
|
||||
try {
|
||||
@@ -47,6 +81,36 @@ export default class MassargOption<T = unknown> {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
helpString(): string {
|
||||
const aliases = this.aliases.length ? `|${this.aliases.join("|-")}` : ""
|
||||
return `--${this.name}${aliases} ${this.description}`
|
||||
}
|
||||
|
||||
_match(arg: string): boolean {
|
||||
// full prefix
|
||||
if (arg.startsWith(OPT_FULL_PREFIX)) {
|
||||
// negate full prefix
|
||||
if (arg.startsWith(`--${NEGATE_FULL_PREFIX}`)) {
|
||||
return this.name === arg.slice(`--${NEGATE_FULL_PREFIX}`.length)
|
||||
}
|
||||
return this.name === arg.slice(OPT_FULL_PREFIX.length)
|
||||
}
|
||||
// short prefix
|
||||
if (arg.startsWith(OPT_SHORT_PREFIX) || arg.startsWith(NEGATE_SHORT_PREFIX)) {
|
||||
return this.aliases.includes(arg.slice(OPT_SHORT_PREFIX.length))
|
||||
}
|
||||
// negate short prefix
|
||||
if (arg.startsWith(NEGATE_SHORT_PREFIX)) {
|
||||
return this.aliases.includes(arg.slice(NEGATE_SHORT_PREFIX.length))
|
||||
}
|
||||
// no prefix
|
||||
return false
|
||||
}
|
||||
|
||||
_isOption(arg: string): boolean {
|
||||
return arg.startsWith(OPT_FULL_PREFIX) || arg.startsWith(OPT_SHORT_PREFIX) || arg.startsWith(NEGATE_SHORT_PREFIX)
|
||||
}
|
||||
}
|
||||
|
||||
export class MassargNumber extends MassargOption<number> {
|
||||
@@ -57,7 +121,7 @@ export class MassargNumber extends MassargOption<number> {
|
||||
})
|
||||
}
|
||||
|
||||
valueFromArgv(argv: string[]): { argv: string[]; value: number; key: string } {
|
||||
valueFromArgv(argv: string[]): ArgvValue<number> {
|
||||
try {
|
||||
const { argv: _argv, value } = super.valueFromArgv(argv)
|
||||
if (isNaN(value)) {
|
||||
@@ -89,9 +153,9 @@ export class MassargFlag extends MassargOption<boolean> {
|
||||
})
|
||||
}
|
||||
|
||||
valueFromArgv(argv: string[]): { argv: string[]; value: boolean; key: string } {
|
||||
valueFromArgv(argv: string[]): ArgvValue<boolean> {
|
||||
try {
|
||||
const isNegation = argv[0]?.startsWith("-!")
|
||||
const isNegation = argv[0]?.startsWith("^")
|
||||
argv.shift()
|
||||
if (isNegation) {
|
||||
return { key: this.name, value: false, argv }
|
||||
|
||||
62
src/utils.ts
62
src/utils.ts
@@ -1,5 +1,67 @@
|
||||
import { z } from "zod"
|
||||
import MassargCommand from "./command"
|
||||
import MassargOption from "./option"
|
||||
|
||||
export function isZodError(e: unknown): e is z.ZodError {
|
||||
return e instanceof z.ZodError
|
||||
}
|
||||
|
||||
type GenerateTableOptions = {
|
||||
maxRowLength?: number
|
||||
namePrefix?: string
|
||||
aliasPrefix?: string
|
||||
}
|
||||
|
||||
/** generates an aligned table of options and their descriptions */
|
||||
export function generateHelpTable(
|
||||
items: { name: string; aliases: string[]; description: string }[],
|
||||
{ maxRowLength = 80, namePrefix = "", aliasPrefix = "" }: GenerateTableOptions = {},
|
||||
): string {
|
||||
const rows = items.map((o) => {
|
||||
const name = `${namePrefix}${o.name}${
|
||||
o.aliases.length ? ` | ${aliasPrefix}${o.aliases.join(`|${aliasPrefix}`)}` : ""
|
||||
}`
|
||||
const description = o.description
|
||||
return { name, description }
|
||||
})
|
||||
const maxNameLength = Math.max(...rows.map((o) => o.name.length))
|
||||
const table = rows.map((row) => {
|
||||
const name = row.name.padEnd(maxNameLength + 2)
|
||||
const description = row.description
|
||||
const length = name.length + description.length
|
||||
if (length <= maxRowLength) {
|
||||
return `${name}${description}`
|
||||
}
|
||||
const subRows: string[] = [name]
|
||||
const words = description.split(" ")
|
||||
let currentRow = subRows[0]
|
||||
|
||||
for (const word of words) {
|
||||
if (currentRow.length + word.length + 1 > maxRowLength) {
|
||||
subRows.push(currentRow)
|
||||
currentRow = ""
|
||||
}
|
||||
currentRow += `${word} `
|
||||
}
|
||||
|
||||
return subRows.join("\n")
|
||||
})
|
||||
|
||||
return table.join("\n")
|
||||
}
|
||||
|
||||
export function generateOptionsHelpTable(options: MassargOption<unknown>[], config?: GenerateTableOptions): string {
|
||||
return generateHelpTable(options, {
|
||||
namePrefix: "--",
|
||||
aliasPrefix: "-",
|
||||
...config,
|
||||
})
|
||||
}
|
||||
|
||||
export function generateCommandsHelpTable(commands: MassargCommand[], config?: GenerateTableOptions): string {
|
||||
return generateHelpTable(commands, {
|
||||
namePrefix: "",
|
||||
aliasPrefix: "",
|
||||
...config,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user