Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions playground/commands/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
21 changes: 20 additions & 1 deletion src/args.ts
Original file line number Diff line number Diff line change
@@ -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<T extends ArgsDef = ArgsDef>(
Expand Down Expand Up @@ -106,3 +106,22 @@ export function resolveArgs(argsDef: ArgsDef): Arg[] {
}
return args;
}

export async function resolveArgsValidate<T extends ArgsDef = ArgsDef>(
parsedArgs: ParsedArgs<T>,
argsDef: ArgsDef,
): Promise<string | undefined> {
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}`;
}
}
}
7 changes: 6 additions & 1 deletion src/command.ts
Original file line number Diff line number Diff line change
@@ -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<const T extends ArgsDef = ArgsDef>(
def: CommandDef<T>,
Expand Down Expand Up @@ -60,6 +60,11 @@ export async function runCommand<T extends ArgsDef = ArgsDef>(
}
}

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);
Expand Down
8 changes: 7 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,19 @@ export type ArgType =

// Args: Definition

type Validate<VT> = {
verify?: (value: VT) => Promise<boolean | string | undefined>;
notToThrowCLIError?: boolean;
};

export type _ArgDef<T extends ArgType, VT extends boolean | number | string> = {
type?: T;
description?: string;
valueHint?: string;
alias?: string | string[];
default?: VT;
required?: boolean;
validate?: Validate<VT>;
options?: (string | number)[];
};

Expand Down Expand Up @@ -95,7 +101,7 @@ type ParsedArg<T extends ArgDef> =

// prettier-ignore
export type ParsedArgs<T extends ArgsDef = ArgsDef> = RawArgs &
{ [K in keyof T]: ParsedArg<T[K]>; } &
{ [K in keyof T]: ParsedArg<T[K]>; } &
{ [K in keyof T as T[K] extends { alias: string } ? T[K]["alias"] : never]: ParsedArg<T[K]> } &
{ [K in keyof T as T[K] extends { alias: string[] } ? T[K]["alias"][number] : never]: ParsedArg<T[K]> } &
Record<string, string | number | boolean | string[]>;
Expand Down
89 changes: 88 additions & 1 deletion test/args.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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");
});
});
83 changes: 82 additions & 1 deletion test/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
runMain,
showUsage,
} from "../src";
import * as mainModule from "../src/main";
import * as commandModule from "../src/command";

describe("runMain", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down