From 952095ab7e33adb3a62546de22ff45a5e49f0efa Mon Sep 17 00:00:00 2001 From: ysknsid25 Date: Thu, 27 Mar 2025 23:25:21 +0900 Subject: [PATCH] feat: support validate for args Signed-off-by: ysknsid25 --- playground/commands/deploy.ts | 13 +++++ src/args.ts | 21 ++++++++- src/command.ts | 7 ++- src/types.ts | 8 +++- test/args.test.ts | 89 ++++++++++++++++++++++++++++++++++- test/main.test.ts | 83 +++++++++++++++++++++++++++++++- 6 files changed, 216 insertions(+), 5 deletions(-) diff --git a/playground/commands/deploy.ts b/playground/commands/deploy.ts index fe53689..df88b65 100644 --- a/playground/commands/deploy.ts +++ b/playground/commands/deploy.ts @@ -52,6 +52,19 @@ export default defineCommand({ description: "Deployment provider", valueHint: "foo|bar|baz|qux", }, + port: { + type: "string", + description: "Port number to listen on", + required: true, + validate: { + notToThrowCLIError: false, + verify: async (value) => { + return Number(value) >= 1 && Number(value) <= 65_536 + ? true + : "Port number must be greater than 1 and less than 65536"; + }, + }, + }, }, run({ args }) { consola.log("Build"); diff --git a/src/args.ts b/src/args.ts index 3d86757..74d94c9 100644 --- a/src/args.ts +++ b/src/args.ts @@ -1,6 +1,6 @@ import { kebabCase, camelCase } from "scule"; import { parseRawArgs } from "./_parser"; -import type { Arg, ArgsDef, ParsedArgs } from "./types"; +import type { Arg, ArgsDef, ParsedArgs, ArgType } from "./types"; import { CLIError, toArray } from "./_utils"; export function parseArgs( @@ -106,3 +106,22 @@ export function resolveArgs(argsDef: ArgsDef): Arg[] { } return args; } + +export async function resolveArgsValidate( + parsedArgs: ParsedArgs, + argsDef: ArgsDef, +): Promise { + for (const [name, argDef] of Object.entries(argsDef || {})) { + const value = parsedArgs[name] as never; + if (!argDef.validate?.verify) { + continue; + } + const result = await argDef.validate.verify(value); + // For optional arguments, validation may always fail depending on the implementation of the validation function. + // If notToThrowCLIError is set, then the verification result can be prevented from propagating to the caller + if (typeof result === "string" && !argDef.validate.notToThrowCLIError) { + const message = result ? ` - ${result}` : ""; + return `Argument validation failed: ${name}${message}`; + } + } +} diff --git a/src/command.ts b/src/command.ts index 4878197..4960130 100644 --- a/src/command.ts +++ b/src/command.ts @@ -1,6 +1,6 @@ import type { CommandContext, CommandDef, ArgsDef } from "./types"; import { CLIError, resolveValue } from "./_utils"; -import { parseArgs } from "./args"; +import { parseArgs, resolveArgsValidate } from "./args"; export function defineCommand( def: CommandDef, @@ -60,6 +60,11 @@ export async function runCommand( } } + const word = await resolveArgsValidate(parsedArgs, cmdArgs); + if (word) { + throw new CLIError(word, "E_VALIDATE_FAILED"); + } + // Handle main command if (typeof cmd.run === "function") { result = await cmd.run(context); diff --git a/src/types.ts b/src/types.ts index e9892f6..00d604e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -10,6 +10,11 @@ export type ArgType = // Args: Definition +type Validate = { + verify?: (value: VT) => Promise; + notToThrowCLIError?: boolean; +}; + export type _ArgDef = { type?: T; description?: string; @@ -17,6 +22,7 @@ export type _ArgDef = { alias?: string | string[]; default?: VT; required?: boolean; + validate?: Validate; options?: (string | number)[]; }; @@ -95,7 +101,7 @@ type ParsedArg = // prettier-ignore export type ParsedArgs = RawArgs & - { [K in keyof T]: ParsedArg; } & + { [K in keyof T]: ParsedArg; } & { [K in keyof T as T[K] extends { alias: string } ? T[K]["alias"] : never]: ParsedArg } & { [K in keyof T as T[K] extends { alias: string[] } ? T[K]["alias"][number] : never]: ParsedArg } & Record; diff --git a/test/args.test.ts b/test/args.test.ts index dcb4b03..2f2ddf6 100644 --- a/test/args.test.ts +++ b/test/args.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { parseArgs } from "../src/args"; +import { parseArgs, resolveArgsValidate } from "../src/args"; import { ArgsDef, ParsedArgs } from "../src"; describe("args", () => { @@ -137,3 +137,90 @@ describe("args", () => { expect(parsed._).toEqual([]); }); }); + +describe("resolveArgsValidate", () => { + it("should pass validation for valid arguments", async () => { + const definition: ArgsDef = { + name: { + type: "string", + validate: { + verify: async (value) => (value === "John" ? false : "Invalid name"), + }, + }, + }; + const parsedArgs = { name: "John", _: [] } as any; + + const result = await resolveArgsValidate(parsedArgs, definition); + + expect(result).toBeUndefined(); + }); + + it("should fail validation for invalid arguments", async () => { + const definition: ArgsDef = { + name: { + type: "string", + validate: { + verify: async (value) => (value === "John" ? "" : "Invalid name"), + }, + }, + }; + const parsedArgs = { name: "Jane", _: [] } as any; + + const result = await resolveArgsValidate(parsedArgs, definition); + + expect(result).toBe("Argument validation failed: name - Invalid name"); + }); + + it("should not throw CLIError if notToThrowCLIError is set", async () => { + const definition: ArgsDef = { + name: { + type: "string", + validate: { + verify: async (value) => (value === "John" ? "" : "Invalid name"), + notToThrowCLIError: true, + }, + }, + }; + const parsedArgs = { name: "Jane", _: [] } as any; + + const result = await resolveArgsValidate(parsedArgs, definition); + + expect(result).toBeUndefined(); + }); + + it("should handle optional arguments without validation errors", async () => { + const definition: ArgsDef = { + name: { + type: "string", + }, + }; + const parsedArgs = { _: [] } as any; + + const result = await resolveArgsValidate(parsedArgs, definition); + + expect(result).toBeUndefined(); + }); + + it("multiple arguments, return the first one caught.", async () => { + const definition: ArgsDef = { + name: { + type: "string", + validate: { + verify: async (value) => (value === "John" ? "" : "Invalid name"), + }, + }, + age: { + type: "number", + validate: { + verify: async (value) => + value >= 18 ? "" : "Age must be at least 18", + }, + }, + }; + const parsedArgs = { name: "John", age: 17, _: [] } as any; + + const result = await resolveArgsValidate(parsedArgs, definition); + + expect(result).toBe("Argument validation failed: name"); + }); +}); diff --git a/test/main.test.ts b/test/main.test.ts index 17f2845..3bed573 100644 --- a/test/main.test.ts +++ b/test/main.test.ts @@ -7,7 +7,6 @@ import { runMain, showUsage, } from "../src"; -import * as mainModule from "../src/main"; import * as commandModule from "../src/command"; describe("runMain", () => { @@ -118,6 +117,88 @@ describe("runMain", () => { expect(mockRunCommand).toHaveBeenCalledWith(command, { rawArgs }); }); + + it("not throw CLIError if verification success", async () => { + const command = defineCommand({ + meta: { + version: "1.0.0", + }, + args: { + foo: { + type: "string", + validate: { + verify: async (value) => { + if (value === "bar") { + return true; + } + return "Invalid value"; + }, + }, + }, + }, + }); + + await runMain(command, { rawArgs: ["--foo", "bar"] }); + + expect(consolaErrorMock).not.toHaveBeenCalledWith( + "Argument validation failed: foo - Invalid value", + ); + }); + + it("not throw CLIError if verification fail, but notToThrowCLIError is true", async () => { + const command = defineCommand({ + meta: { + version: "1.0.0", + }, + args: { + foo: { + type: "string", + validate: { + notToThrowCLIError: true, + verify: async (value) => { + if (value === "bar") { + return true; + } + return "Invalid value"; + }, + }, + }, + }, + }); + + await runMain(command, { rawArgs: ["--foo", "baz"] }); + + expect(consolaErrorMock).not.toHaveBeenCalledWith( + "Argument validation failed: foo - Invalid value", + ); + }); + + it("throw CLIError if verification fails", async () => { + const command = defineCommand({ + meta: { + version: "1.0.0", + }, + args: { + foo: { + type: "string", + validate: { + verify: async (value) => { + if (value === "bar") { + return true; + } + return "Invalid value"; + }, + }, + }, + }, + }); + + await runMain(command, { rawArgs: ["--foo", "baz"] }); + + expect(consolaErrorMock).toHaveBeenCalledWith( + "Argument validation failed: foo - Invalid value", + ); + }); }); describe("createMain", () => {