UNPKG

zlye

Version:

A type-safe CLI parser with a Zod-like schema validation.

931 lines (920 loc) 26.4 kB
// src/index.ts import pc from "picocolors"; // src/utils.ts function joinWithAnd(items) { return new Intl.ListFormat("en", { type: "conjunction" }).format(items); } function joinWithOr(items) { return new Intl.ListFormat("en", { type: "disjunction" }).format(items); } function getOrdinalNumber(index) { const ordinals = [ "first", "second", "third", "fourth", "fifth", "sixth", "seventh", "eighth", "ninth", "tenth", "eleventh", "twelfth", "thirteenth", "fourteenth", "fifteenth", "sixteenth", "seventeenth", "eighteenth", "nineteenth", "twentieth" ]; if (index < ordinals.length) { return ordinals[index]; } const num = index + 1; const suffix = num % 10 === 1 && num % 100 !== 11 ? "st" : num % 10 === 2 && num % 100 !== 12 ? "nd" : num % 10 === 3 && num % 100 !== 13 ? "rd" : "th"; return `${num}${suffix}`; } // src/index.ts class StringSchemaImpl { _type = "string"; _output; _input; _description; _alias; _example; _isOptional; _defaultValue; _minLength; _maxLength; _regex; _choices; parse(value, path = "value") { if (value === undefined && this._isOptional) return; if (value === undefined && this._defaultValue !== undefined) return this._defaultValue; if (value === undefined) throw new CLIError(`${path} is required`); if (typeof value !== "string") { throw new CLIError(`${path} must be a string, received ${typeof value}`); } if (this._choices && !this._choices.includes(value)) { throw new CLIError(`${path} must be one of ${joinWithOr(Array.from(this._choices))}`); } if (this._minLength !== undefined && value.length < this._minLength) { throw new CLIError(`${path} must be at least ${this._minLength} characters`); } if (this._maxLength !== undefined && value.length > this._maxLength) { throw new CLIError(`${path} must be at most ${this._maxLength} characters`); } if (this._regex && !this._regex.pattern.test(value)) { throw new CLIError(this._regex.message || `${path} must match pattern ${this._regex.pattern}`); } return value; } optional() { const clone = Object.create(this); clone._isOptional = true; return clone; } default(value) { const clone = Object.create(this); clone._defaultValue = value; return clone; } transform(fn) { const clone = Object.create(this); const originalParse = clone.parse.bind(clone); clone.parse = (value, path) => fn(originalParse(value, path)); return clone; } describe(description) { this._description = description; return this; } alias(alias) { this._alias = alias; return this; } example(example) { this._example = example; return this; } min(length) { this._minLength = length; return this; } max(length) { this._maxLength = length; return this; } regex(pattern, message) { this._regex = { pattern, message }; return this; } choices(choices) { const clone = Object.create(this); clone._choices = choices; return clone; } } class NumberSchemaImpl { _type = "number"; _output; _input; _description; _alias; _example; _isOptional; _defaultValue; _min; _max; _isInt; _isPositive; _isNegative; parse(value, path = "value") { if (value === undefined && this._isOptional) return; if (value === undefined && this._defaultValue !== undefined) return this._defaultValue; if (value === undefined) throw new CLIError(`${path} is required`); const num = Number(value); if (Number.isNaN(num)) { throw new CLIError(`${path} must be a number, received ${typeof value}`); } if (this._isInt && !Number.isInteger(num)) { throw new CLIError(`${path} must be an integer`); } if (this._isPositive && num <= 0) { throw new CLIError(`${path} must be positive`); } if (this._isNegative && num >= 0) { throw new CLIError(`${path} must be negative`); } if (this._min !== undefined && num < this._min) { throw new CLIError(`${path} must be at least ${this._min}`); } if (this._max !== undefined && num > this._max) { throw new CLIError(`${path} must be at most ${this._max}`); } return num; } optional() { const clone = Object.create(this); clone._isOptional = true; return clone; } default(value) { const clone = Object.create(this); clone._defaultValue = value; return clone; } transform(fn) { const clone = Object.create(this); const originalParse = clone.parse.bind(clone); clone.parse = (value, path) => fn(originalParse(value, path)); return clone; } describe(description) { this._description = description; return this; } alias(alias) { this._alias = alias; return this; } example(example) { this._example = example; return this; } min(value) { this._min = value; return this; } max(value) { this._max = value; return this; } int() { this._isInt = true; return this; } positive() { this._isPositive = true; return this; } negative() { this._isNegative = true; return this; } } class BooleanSchemaImpl { _type = "boolean"; _output; _input; _description; _alias; _example; _isOptional; _defaultValue; parse(value, path = "value") { if (value === undefined && this._isOptional) return; if (value === undefined && this._defaultValue !== undefined) return this._defaultValue; if (value === undefined) return false; if (value === "true" || value === true || value === "1" || value === 1) return true; if (value === "false" || value === false || value === "0" || value === 0) return false; throw new CLIError(`${path} must be a boolean`); } optional() { const clone = Object.create(this); clone._isOptional = true; return clone; } default(value) { const clone = Object.create(this); clone._defaultValue = value; return clone; } transform(fn) { const clone = Object.create(this); const originalParse = clone.parse.bind(clone); clone.parse = (value, path) => fn(originalParse(value, path)); return clone; } describe(description) { this._description = description; return this; } alias(alias) { this._alias = alias; return this; } example(example) { this._example = example; return this; } } class ArraySchemaImpl { _type = "array"; _output; _input; _itemSchema; _description; _alias; _example; _isOptional; _defaultValue; _minLength; _maxLength; constructor(itemSchema) { this._itemSchema = itemSchema; } parse(value, path = "value") { if (value === undefined && this._isOptional) return; if (value === undefined && this._defaultValue !== undefined) return this._defaultValue; if (value === undefined) throw new CLIError(`${path} is required`); let arr; if (Array.isArray(value)) { arr = value; } else if (typeof value === "string" && value.includes(",")) { arr = value.split(",").map((item) => item.trim()).filter((item) => item !== ""); } else { arr = [value]; } if (this._minLength !== undefined && arr.length < this._minLength) { throw new CLIError(`${path} must have at least ${this._minLength} items`); } if (this._maxLength !== undefined && arr.length > this._maxLength) { throw new CLIError(`${path} must have at most ${this._maxLength} items`); } return arr.map((item, i) => { const ordinalPath = path.includes('"') || path.includes(" ") ? `${getOrdinalNumber(i)} value of ${path}` : `${path}: ${getOrdinalNumber(i)} value`; return this._itemSchema.parse(item, ordinalPath); }); } optional() { const clone = Object.create(this); clone._isOptional = true; return clone; } default(value) { const clone = Object.create(this); clone._defaultValue = value; return clone; } transform(fn) { const clone = Object.create(this); const originalParse = clone.parse.bind(clone); clone.parse = (value, path) => fn(originalParse(value, path)); return clone; } describe(description) { this._description = description; return this; } alias(alias) { this._alias = alias; return this; } example(example) { this._example = example; return this; } min(length) { this._minLength = length; return this; } max(length) { this._maxLength = length; return this; } } class ObjectSchemaImpl { _type = "object"; _output; _input; _shape; _description; _alias; _example; _isOptional; _defaultValue; constructor(shape) { this._shape = shape; } parse(value, path = "value") { if (value === undefined && this._isOptional) return; if (value === undefined && this._defaultValue !== undefined) return this._defaultValue; const objectValue = value === undefined ? {} : value; if (typeof objectValue !== "object" || objectValue === null) { throw new CLIError(`${path} must be an object`); } const result = {}; for (const [key, schema] of Object.entries(this._shape)) { result[key] = schema.parse(objectValue[key], `${path}.${key}`); } return result; } optional() { const clone = Object.create(this); clone._isOptional = true; return clone; } default(value) { const clone = Object.create(this); clone._defaultValue = value; return clone; } transform(fn) { const clone = Object.create(this); const originalParse = clone.parse.bind(clone); clone.parse = (value, path) => fn(originalParse(value, path)); return clone; } describe(description) { this._description = description; return this; } alias(alias) { this._alias = alias; return this; } example(example) { this._example = example; return this; } } class PositionalSchemaImpl { _type = "string"; _output; _input; _name; _description; _baseSchema; constructor(name, schema) { this._name = name; this._baseSchema = schema || new StringSchemaImpl; this._description = schema?._description; } parse(value, path) { return this._baseSchema.parse(value, path || this._name); } optional() { const clone = Object.create(this); clone._baseSchema = this._baseSchema.optional(); return clone; } default(value) { const clone = Object.create(this); clone._baseSchema = this._baseSchema.default(value); return clone; } transform(fn) { const clone = Object.create(this); clone._baseSchema = this._baseSchema.transform(fn); return clone; } describe(description) { this._description = description; return this; } alias(alias) { return this; } example(example) { if ("example" in this._baseSchema) { this._baseSchema.example(example); } return this; } } class CommandBuilderImpl { _name; _options; _description; _usage; _examples = []; _positionals = []; constructor(name, options) { this._name = name; this._options = options; } description(desc) { this._description = desc; return this; } usage(usage) { this._usage = usage; return this; } example(example) { if (Array.isArray(example)) { this._examples.push(...example); } else { this._examples.push(example); } return this; } positional(name, schema) { this._positionals.push(new PositionalSchemaImpl(name, schema)); return this; } action(fn) { return { name: this._name, description: this._description, usage: this._usage, example: this._examples.length > 0 ? this._examples : undefined, options: this._options, positionals: this._positionals, action: fn }; } } class CLIError extends Error { constructor(message) { super(message); this.name = "CLIError"; } } var z = { string: () => new StringSchemaImpl, number: () => new NumberSchemaImpl, boolean: () => new BooleanSchemaImpl, array: (schema) => new ArraySchemaImpl(schema), object: (shape) => new ObjectSchemaImpl(shape) }; class CLIImpl { _name; _version; _description; _usage; _examples = []; _options = {}; _positionals = []; _commands = []; name(name) { this._name = name; return this; } version(version) { this._version = version; return this; } description(description) { this._description = description; return this; } usage(usage) { this._usage = usage; return this; } example(example) { if (Array.isArray(example)) { this._examples.push(...example); } else { this._examples.push(example); } return this; } option(name, schema) { this._options[name] = schema; return this; } positional(name, schema) { this._positionals.push(new PositionalSchemaImpl(name, schema)); return this; } command(name, options) { const builder = new CommandBuilderImpl(name, options || {}); return new Proxy(builder, { get: (target, prop) => { if (prop === "action") { return (fn) => { const cmd = target.action(fn); this._commands.push(cmd); return cmd; }; } return target[prop]; } }); } parse(argv = process.argv.slice(2)) { try { let args = [...argv]; if (args.includes("--version") || args.includes("-v")) { if (this._version) { console.log(this._version); process.exit(0); } } const helpIndex = args.findIndex((arg) => arg === "--help" || arg === "-h"); if (helpIndex !== -1) { const commandBeforeHelp = helpIndex > 0 ? args[helpIndex - 1] : null; const command = commandBeforeHelp && this._commands.find((c) => c.name === commandBeforeHelp); if (command) { this.showCommandHelp(command); } else { this.showHelp(); } process.exit(0); } let commandName; let commandOptions = this._options; let commandAction; let commandPositionals = this._positionals; const positionalArgs = []; if (args.length > 0 && !args[0].startsWith("-")) { commandName = args[0]; const cmd = this._commands.find((c) => c.name === commandName); if (cmd) { commandOptions = cmd.options; commandPositionals = cmd.positionals || []; commandAction = cmd.action; args = args.slice(1); } else if (this._commands.length > 0) { throw new CLIError(`Unknown command: ${commandName}`); } else { positionalArgs.push(args[0]); args = args.slice(1); } } const parsed = {}; const rawOptions = {}; for (let i = 0;i < args.length; i++) { const arg = args[i]; if (arg.startsWith("--")) { const [key, ...valueParts] = arg.slice(2).split("="); let value; if (valueParts.length > 0) { value = valueParts.join("="); } else { const schema = commandOptions[key]; if (schema && schema._type === "boolean") { value = true; } else if (i + 1 < args.length && !args[i + 1].startsWith("-")) { value = args[++i]; } else { value = undefined; } } const keys = key.split("."); let current = rawOptions; for (let j = 0;j < keys.length - 1; j++) { current[keys[j]] = current[keys[j]] || {}; current = current[keys[j]]; } const lastKey = keys[keys.length - 1]; if (current[lastKey] !== undefined) { if (!Array.isArray(current[lastKey])) { current[lastKey] = [current[lastKey]]; } current[lastKey].push(value); } else { current[lastKey] = value; } } else if (arg.startsWith("-")) { const alias = arg.slice(1); const optionName = Object.entries(commandOptions).find(([_, schema]) => schema._alias === alias)?.[0]; if (optionName) { const schema = commandOptions[optionName]; let value; if (schema && schema._type === "boolean") { value = true; } else if (i + 1 < args.length && !args[i + 1].startsWith("-")) { value = args[++i]; } else { value = undefined; } rawOptions[optionName] = value; } } else { positionalArgs.push(arg); } } for (const [key, schema] of Object.entries(commandOptions)) { parsed[key] = schema.parse(rawOptions[key], `--${key}`); } const parsedPositionals = []; if (commandPositionals.length > 0) { for (let i = 0;i < commandPositionals.length; i++) { const positional = commandPositionals[i]; const value = positionalArgs[i]; try { const parsedValue = positional.parse(value, positional._name); parsedPositionals.push(parsedValue); } catch (error) { throw new CLIError(`Argument "${positional._name}": ${error instanceof Error ? error.message.replace(`${positional._name} `, "").replace(`${positional._name}: `, "") : error}`); } } if (positionalArgs.length > commandPositionals.length) { const extra = positionalArgs.slice(commandPositionals.length); throw new CLIError(`Unexpected argument${extra.length > 1 ? "s" : ""}: ${joinWithAnd(extra)}`); } } else { parsedPositionals.push(...positionalArgs); } if (commandAction) { const result = commandAction({ options: parsed, positionals: positionalArgs }); if (result instanceof Promise) { result.catch((err) => { this.showError(err); process.exit(1); }); } return; } return { options: parsed, positionals: parsedPositionals }; } catch (error) { this.showError(error); process.exit(1); } } showHelp() { console.log(); if (this._description && this._version) { console.log(`${this._description} ${pc.dim(`(${this._version})`)}`); console.log(); } else if (this._name) { console.log(pc.bold(this._name)); if (this._version) { console.log(pc.dim(`v${this._version}`)); } if (this._description) { console.log(); console.log(this._description); } console.log(); } if (this._usage) { console.log(`${pc.bold("Usage:")} ${this._usage}`); } else { const name = this._name || "cli"; const parts = [name]; if (this._commands.length > 0) { parts.push(pc.blue("<command>")); } if (Object.keys(this._options).length > 0) { parts.push(pc.blue("[...flags]")); } if (this._positionals.length > 0) { parts.push(...this._positionals.map((p) => pc.dim(`<${p._name}>`))); } else { parts.push(pc.blue("[...args]")); } console.log(`${pc.bold("Usage:")} ${parts.join(" ")}`); } console.log(); if (this._positionals.length > 0) { console.log(pc.bold("Arguments:")); for (const pos of this._positionals) { console.log(` ${pc.cyan(`<${pos._name}>`)} ${pos._description || ""}`); } console.log(); } if (this._commands.length > 0) { console.log(pc.bold("Commands:")); const commandRows = []; for (const cmd of this._commands) { const example = Array.isArray(cmd.example) ? cmd.example[0] : cmd.example; commandRows.push([cmd.name, example || "", cmd.description || ""]); } const nameWidth = Math.max(...commandRows.map((r) => r[0].length)); const exampleWidth = Math.max(...commandRows.map((r) => r[1].length)); for (const [name, example, description] of commandRows) { const namePart = ` ${pc.cyan(name.padEnd(nameWidth))}`; const examplePart = example ? ` ${pc.dim(example.padEnd(exampleWidth))}` : ` ${" ".repeat(exampleWidth)}`; const descPart = description ? ` ${description}` : ""; console.log(`${namePart}${examplePart}${descPart}`); } console.log(); console.log(` ${pc.cyan("<command> --help".padEnd(nameWidth))}${exampleWidth > 0 ? ` ${" ".repeat(exampleWidth)}` : ""} ${pc.dim("Print help text for command.")}`); console.log(); } if (Object.keys(this._options).length > 0) { console.log(pc.bold("Flags:")); this.showOptionsHelp(this._options); console.log(); } if (this._examples.length > 0) { console.log(pc.bold("Examples:")); for (const example of this._examples) { const lines = example.split(` `); if (lines.length > 1) { console.log(` ${lines[0]}`); console.log(` ${pc.green(lines.slice(1).join(` `))}`); } else { console.log(` ${pc.green(example)}`); } console.log(); } } } showCommandHelp(command) { console.log(); const usage = command.usage || `${this._name || "cli"} ${command.name}${Object.keys(command.options).length > 0 ? ` ${pc.cyan("[...flags]")}` : ""}${command.positionals ? ` ${command.positionals.map((p) => pc.dim(`<${p._name}>`)).join(" ")}` : ""}`; console.log(`${pc.bold("Usage:")} ${usage}`); console.log(); if (command.description) { console.log(` ${command.description}`); console.log(); } if (command.positionals && command.positionals.length > 0) { console.log(pc.bold("Arguments:")); for (const pos of command.positionals) { console.log(` ${pc.cyan(`<${pos._name}>`)} ${pos._description || ""}`); } console.log(); } if (Object.keys(command.options).length > 0) { console.log(pc.bold("Flags:")); this.showOptionsHelp(command.options); console.log(); } const examples = Array.isArray(command.example) ? command.example : command.example ? [command.example] : []; if (examples.length > 0) { console.log(pc.bold("Examples:")); for (const example of examples) { const lines = example.split(` `); if (lines.length > 1) { console.log(` ${lines[0]}`); console.log(` ${pc.green(lines.slice(1).join(` `))}`); } else { console.log(` ${pc.green(example)}`); } console.log(); } } } showOptionsHelp(options) { const optionRows = []; for (const [key, schema] of Object.entries(options)) { const flags = schema._alias ? `-${schema._alias}, --${key}` : ` --${key}`; const type = this.getOptionTypeString(key, schema); const desc = this.getOptionDescription(schema); optionRows.push({ flags, type, desc }); } const flagsWidth = Math.max(...optionRows.map((r) => r.flags.length)); const typeWidth = Math.max(...optionRows.map((r) => r.type.length)); for (const { flags, type, desc } of optionRows) { console.log(` ${pc.cyan(flags.padEnd(flagsWidth))}${type.padEnd(typeWidth)} ${desc}`); } console.log(` ${pc.cyan("-h, --help".padEnd(flagsWidth))}${pc.dim("").padEnd(typeWidth)} ${pc.dim("Display this menu and exit")}`); } getOptionTypeString(_, schema) { if (schema._type === "boolean") { return pc.dim(""); } let valueType = "val"; if (schema._type === "string" && schema._choices) { const choices = schema._choices; if (choices && choices.length <= 3) { valueType = choices.join("|"); } } else if (schema._type === "number") { valueType = "n"; } else if (schema._type === "array") { valueType = "val,..."; } return ` ${pc.dim(`<${valueType}>`)} `; } getOptionDescription(schema) { const parts = []; if (schema._description) { parts.push(schema._description); } const constraints = this.getConstraintsString(schema); if (constraints) { parts.push(pc.dim(constraints)); } return parts.join(" "); } getConstraintsString(schema) { const constraints = []; if (schema._defaultValue !== undefined) { if (schema._type === "boolean") { constraints.push(`default: ${schema._defaultValue}`); } else { constraints.push(`default: ${JSON.stringify(schema._defaultValue)}`); } } if (schema._type === "string") { const strSchema = schema; if (strSchema._minLength !== undefined) { constraints.push(`min: ${strSchema._minLength}`); } if (strSchema._maxLength !== undefined) { constraints.push(`max: ${strSchema._maxLength}`); } if (strSchema._regex) { constraints.push(strSchema._regex.message || `pattern: ${strSchema._regex.pattern}`); } } if (schema._type === "number") { const numSchema = schema; if (numSchema._min !== undefined) { constraints.push(`min: ${numSchema._min}`); } if (numSchema._max !== undefined) { constraints.push(`max: ${numSchema._max}`); } if (numSchema._isInt) { constraints.push("integer"); } if (numSchema._isPositive) { constraints.push("positive"); } if (numSchema._isNegative) { constraints.push("negative"); } } if (schema._type === "array") { const arrSchema = schema; if (arrSchema._minLength !== undefined) { constraints.push(`min: ${arrSchema._minLength}`); } if (arrSchema._maxLength !== undefined) { constraints.push(`max: ${arrSchema._maxLength}`); } } return constraints.length > 0 ? `(${joinWithAnd(constraints)})` : ""; } showError(error) { console.error(); console.error(pc.red(pc.bold("Error:")), error instanceof Error ? error.message : String(error)); console.error(); console.error("Run with --help for usage information"); } } function cli() { return new CLIImpl; } export { z, cli };