diff --git a/package.json b/package.json index ff729443b..27e22df50 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,9 @@ "octokit": "^3.1.0", "prettier": "^3.0.2", "replace-in-file": "^7.0.1", - "title-case": "^3.0.3" + "title-case": "^3.0.3", + "zod": "^3.22.2", + "zod-validation-error": "^1.5.0" }, "devDependencies": { "@octokit/request-error": "^5.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c31c8c2dc..d0d870a48 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,12 @@ dependencies: title-case: specifier: ^3.0.3 version: 3.0.3 + zod: + specifier: ^3.22.2 + version: 3.22.2 + zod-validation-error: + specifier: ^1.5.0 + version: 1.5.0(zod@3.22.2) devDependencies: '@octokit/request-error': @@ -7577,8 +7583,6 @@ packages: zod: ^3.18.0 dependencies: zod: 3.22.2 - dev: true /zod@3.22.2: resolution: {integrity: sha512-wvWkphh5WQsJbVk1tbx1l1Ly4yg+XecD+Mq280uBGt9wa5BKSWf4Mhp6GmrkPixhMxmabYY7RbzlwVP32pbGCg==} - dev: true diff --git a/src/bin/index.test.ts b/src/bin/index.test.ts index 7b2717c6b..07467ccfa 100644 --- a/src/bin/index.test.ts +++ b/src/bin/index.test.ts @@ -1,5 +1,6 @@ import chalk from "chalk"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import z from "zod"; import { bin } from "./index.js"; @@ -119,6 +120,34 @@ describe("bin", () => { expect(result).toEqual(code); }); + it("returns the cancel result containing zod error of the corresponding runner and output plus cancel logs when promptForMode returns a mode that cancels", async () => { + const mode = "initialize"; + const args = ["--email", "abc123"]; + const code = 2; + + const validationResult = z + .object({ email: z.string().email() }) + .safeParse({ email: "abc123" }); + + mockPromptForMode.mockResolvedValue(mode); + mockInitialize.mockResolvedValue({ + code: 2, + options: {}, + zodError: (validationResult as z.SafeParseError<{ email: string }>).error, + }); + + const result = await bin(args); + + expect(mockInitialize).toHaveBeenCalledWith(args); + expect(mockLogLine).toHaveBeenCalledWith( + chalk.red('Validation error: Invalid email at "email"'), + ); + expect(mockCancel).toHaveBeenCalledWith( + `Operation cancelled. Exiting - maybe another time? 👋`, + ); + expect(result).toEqual(code); + }); + it("returns the cancel result of the corresponding runner and cancel logs when promptForMode returns a mode that fails", async () => { const mode = "create"; const args = ["--owner", "abc123"]; diff --git a/src/bin/index.ts b/src/bin/index.ts index 5778b66ea..a0979d414 100644 --- a/src/bin/index.ts +++ b/src/bin/index.ts @@ -1,6 +1,7 @@ import * as prompts from "@clack/prompts"; import chalk from "chalk"; import { parseArgs } from "node:util"; +import { fromZodError } from "zod-validation-error"; import { createRerunSuggestion } from "../create/createRerunSuggestion.js"; import { create } from "../create/index.js"; @@ -50,7 +51,8 @@ export async function bin(args: string[]) { return 1; } - const { code, options } = await { create, initialize, migrate }[mode](args); + const runners = { create, initialize, migrate }; + const { code, options, zodError } = await runners[mode](args); prompts.log.info( [ @@ -61,6 +63,13 @@ export async function bin(args: string[]) { if (code) { logLine(); + + if (zodError) { + const validationError = fromZodError(zodError); + logLine(chalk.red(validationError)); + logLine(); + } + prompts.cancel( code === StatusCodes.Cancelled ? operationMessage("cancelled") diff --git a/src/bin/mode.ts b/src/bin/mode.ts index ec13dc111..b0a453c1d 100644 --- a/src/bin/mode.ts +++ b/src/bin/mode.ts @@ -1,5 +1,6 @@ import * as prompts from "@clack/prompts"; import chalk from "chalk"; +import z from "zod"; import { StatusCode } from "../shared/codes.js"; import { filterPromptCancel } from "../shared/prompts.js"; @@ -8,6 +9,7 @@ import { Options } from "../shared/types.js"; export interface ModeResult { code: StatusCode; options: Partial; + zodError?: z.ZodError; } export type ModeRunner = (args: string[]) => Promise; diff --git a/src/create/index.ts b/src/create/index.ts index 3e54443d7..b13a6d32d 100644 --- a/src/create/index.ts +++ b/src/create/index.ts @@ -16,6 +16,7 @@ export async function create(args: string[]): Promise { return { code: StatusCodes.Cancelled, options: inputs.options, + zodError: inputs.zodError, }; } diff --git a/src/initialize/index.ts b/src/initialize/index.ts index e228f6402..a03f5e596 100644 --- a/src/initialize/index.ts +++ b/src/initialize/index.ts @@ -12,6 +12,7 @@ export const initialize: ModeRunner = async (args) => { return { code: StatusCodes.Cancelled, options: inputs.options, + zodError: inputs.zodError, }; } diff --git a/src/migrate/index.ts b/src/migrate/index.ts index d9742c93a..5e07444dc 100644 --- a/src/migrate/index.ts +++ b/src/migrate/index.ts @@ -12,6 +12,7 @@ export const migrate: ModeRunner = async (args) => { return { code: StatusCodes.Cancelled, options: inputs.options, + zodError: inputs.zodError, }; } diff --git a/src/shared/options/augmentOptionsWithExcludes.ts b/src/shared/options/augmentOptionsWithExcludes.ts index 660eaa47d..810a820e4 100644 --- a/src/shared/options/augmentOptionsWithExcludes.ts +++ b/src/shared/options/augmentOptionsWithExcludes.ts @@ -1,9 +1,7 @@ import * as prompts from "@clack/prompts"; import { filterPromptCancel } from "../prompts.js"; -import { Options } from "../types.js"; - -type Base = "everything" | "minimum" | "prompt"; +import { InputBase, Options } from "../types.js"; const exclusionDescriptions = { excludeCompliance: { @@ -85,7 +83,7 @@ export async function augmentOptionsWithExcludes( const base = options.base ?? - filterPromptCancel( + filterPromptCancel( await prompts.select({ message: `How much tooling would you like the template to set up for you?`, options: [ diff --git a/src/shared/options/readOptions.test.ts b/src/shared/options/readOptions.test.ts new file mode 100644 index 000000000..5f4b12b86 --- /dev/null +++ b/src/shared/options/readOptions.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from "vitest"; +import z from "zod"; + +import { readOptions } from "./readOptions.js"; + +const emptyOptions = { + author: undefined, + base: undefined, + createRepository: undefined, + description: undefined, + email: undefined, + excludeCompliance: undefined, + excludeContributors: undefined, + excludeLintJson: undefined, + excludeLintKnip: undefined, + excludeLintMd: undefined, + excludeLintPackageJson: undefined, + excludeLintPackages: undefined, + excludeLintPerfectionist: undefined, + excludeLintSpelling: undefined, + excludeLintYml: undefined, + excludeReleases: undefined, + excludeRenovate: undefined, + excludeTests: undefined, + funding: undefined, + owner: undefined, + repository: undefined, + skipGitHubApi: false, + skipInstall: false, + skipRemoval: false, + skipRestore: undefined, + skipUninstall: false, + title: undefined, +}; + +const mockOptions = { + base: "prompt", + github: "mock.git", + repository: "mock.repository", +}; + +vi.mock("./getPrefillOrPromptedOption.js", () => ({ + getPrefillOrPromptedOption() { + return () => "mock"; + }, +})); + +vi.mock("./ensureRepositoryExists.js", () => ({ + ensureRepositoryExists() { + return { + github: mockOptions.github, + repository: mockOptions.repository, + }; + }, +})); + +vi.mock("../../shared/cli/spinners.ts", () => ({ + withSpinner() { + return () => ({}); + }, +})); + +vi.mock("./augmentOptionsWithExcludes.js", () => ({ + augmentOptionsWithExcludes() { + return { ...emptyOptions, ...mockOptions }; + }, +})); + +describe("readOptions", () => { + it("cancels the function when --email is invalid", async () => { + const validationResult = z + .object({ email: z.string().email() }) + .safeParse({ email: "wrongEmail" }); + + expect(await readOptions(["--email", "wrongEmail"])).toStrictEqual({ + cancelled: true, + options: { ...emptyOptions, email: "wrongEmail" }, + zodError: (validationResult as z.SafeParseError<{ email: string }>).error, + }); + }); + + it("successfully runs the function when --base is valid", async () => { + expect(await readOptions(["--base", mockOptions.base])).toStrictEqual({ + cancelled: false, + github: mockOptions.github, + options: { + ...emptyOptions, + ...mockOptions, + }, + }); + }); +}); diff --git a/src/shared/options/readOptions.ts b/src/shared/options/readOptions.ts index c1651ad07..a8d4e07f7 100644 --- a/src/shared/options/readOptions.ts +++ b/src/shared/options/readOptions.ts @@ -1,8 +1,9 @@ import { parseArgs } from "node:util"; import { titleCase } from "title-case"; +import { z } from "zod"; import { withSpinner } from "../cli/spinners.js"; -import { InputBase, Options } from "../types.js"; +import { Options } from "../types.js"; import { allArgOptions } from "./args.js"; import { augmentOptionsWithExcludes } from "./augmentOptionsWithExcludes.js"; import { ensureRepositoryExists } from "./ensureRepositoryExists.js"; @@ -17,7 +18,8 @@ export interface GitHubAndOptions { export interface OptionsParseCancelled { cancelled: true; - options: Partial; + options: object; + zodError?: z.ZodError; } export interface OptionsParseSuccess extends GitHubAndOptions { @@ -35,42 +37,50 @@ export async function readOptions(args: string[]): Promise { tokens: true, }); - const options = { - author: values.author as string | undefined, - base: values.base as InputBase, - createRepository: values["create-repository"] as boolean | undefined, - description: values.description as string | undefined, - email: values.email as string | undefined, - excludeCompliance: values["exclude-compliance"] as boolean | undefined, - excludeContributors: values["exclude-contributors"] as boolean | undefined, - excludeLintJson: values["exclude-lint-json"] as boolean | undefined, - excludeLintKnip: values["exclude-lint-knip"] as boolean | undefined, - excludeLintMd: values["exclude-lint-md"] as boolean | undefined, - excludeLintPackageJson: values["exclude-lint-package-json"] as - | boolean - | undefined, - excludeLintPackages: values["exclude-lint-packages"] as boolean | undefined, - excludeLintPerfectionist: values["exclude-lint-perfectionist"] as - | boolean - | undefined, - excludeLintSpelling: values["exclude-lint-spelling"] as boolean | undefined, - excludeLintYml: values["exclude-lint-yml"] as boolean | undefined, - excludeReleases: values["exclude-releases"] as boolean | undefined, - excludeRenovate: values["exclude-renovate"] as boolean | undefined, - excludeTests: values["unit-tests"] as boolean | undefined, - funding: values.funding as string | undefined, - owner: values.owner as string | undefined, - repository: values.repository as string | undefined, + const mappedOptions = { + author: values.author, + base: values.base, + createRepository: values["create-repository"], + description: values.description, + email: values.email, + excludeCompliance: values["exclude-compliance"], + excludeContributors: values["exclude-contributors"], + excludeLintJson: values["exclude-lint-json"], + excludeLintKnip: values["exclude-lint-knip"], + excludeLintMd: values["exclude-lint-md"], + excludeLintPackageJson: values["exclude-lint-package-json"], + excludeLintPackages: values["exclude-lint-packages"], + excludeLintPerfectionist: values["exclude-lint-perfectionist"], + excludeLintSpelling: values["exclude-lint-spelling"], + excludeLintYml: values["exclude-lint-yml"], + excludeReleases: values["exclude-releases"], + excludeRenovate: values["exclude-renovate"], + excludeTests: values["unit-tests"], + funding: values.funding, + owner: values.owner, + repository: values.repository, skipGitHubApi: !!values["skip-github-api"], skipInstall: !!values["skip-install"], skipRemoval: !!values["skip-removal"], - skipRestore: values["skip-restore"] as boolean | undefined, + skipRestore: values["skip-restore"], skipUninstall: !!values["skip-uninstall"], - title: values.title as string | undefined, + title: values.title, }; + const optionsParseResult = optionsSchema.safeParse(mappedOptions); + + if (!optionsParseResult.success) { + return { + cancelled: true, + options: mappedOptions, + zodError: optionsParseResult.error, + }; + } + + const options = optionsParseResult.data; + options.owner ??= await getPrefillOrPromptedOption( - values.owner as string | undefined, + options.owner, "What organization or user will the repository be under?", defaults.owner, ); @@ -130,10 +140,13 @@ export async function readOptions(args: string[]): Promise { const augmentedOptions = await augmentOptionsWithExcludes({ ...options, author: options.author ?? (await defaults.owner()), + description: options.description, email: options.email ?? (await defaults.email()), funding: options.funding ?? (await defaults.funding()), + owner: options.owner, repository, - } as Options); + title: options.title, + }); if (!augmentedOptions) { return { @@ -148,3 +161,35 @@ export async function readOptions(args: string[]): Promise { options: augmentedOptions, }; } + +const optionsSchema = z.object({ + author: z.string().optional(), + base: z + .union([z.literal("everything"), z.literal("minimum"), z.literal("prompt")]) + .optional(), + createRepository: z.boolean().optional(), + description: z.string().optional(), + email: z.string().email().optional(), + excludeCompliance: z.boolean().optional(), + excludeContributors: z.boolean().optional(), + excludeLintJson: z.boolean().optional(), + excludeLintKnip: z.boolean().optional(), + excludeLintMd: z.boolean().optional(), + excludeLintPackageJson: z.boolean().optional(), + excludeLintPackages: z.boolean().optional(), + excludeLintPerfectionist: z.boolean().optional(), + excludeLintSpelling: z.boolean().optional(), + excludeLintYml: z.boolean().optional(), + excludeReleases: z.boolean().optional(), + excludeRenovate: z.boolean().optional(), + excludeTests: z.boolean().optional(), + funding: z.string().optional(), + owner: z.string().optional(), + repository: z.string().optional(), + skipGitHubApi: z.boolean(), + skipInstall: z.boolean(), + skipRemoval: z.boolean(), + skipRestore: z.boolean().optional(), + skipUninstall: z.boolean(), + title: z.string().optional(), +}); diff --git a/src/shared/types.ts b/src/shared/types.ts index d7f05ed83..0d53c1d51 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -20,31 +20,31 @@ export interface PartialPackageData { export type InputBase = "everything" | "minimum" | "prompt"; export interface Options { - author: string | undefined; - base: InputBase | undefined; - createRepository: boolean | undefined; + author?: string; + base?: InputBase; + createRepository?: boolean; description: string; - email: string | undefined; - excludeCompliance: boolean | undefined; - excludeContributors: boolean | undefined; - excludeLintJson: boolean | undefined; - excludeLintKnip: boolean | undefined; - excludeLintMd: boolean | undefined; - excludeLintPackageJson: boolean | undefined; - excludeLintPackages: boolean | undefined; - excludeLintPerfectionist: boolean | undefined; - excludeLintSpelling: boolean | undefined; - excludeLintYml: boolean | undefined; - excludeReleases: boolean | undefined; - excludeRenovate: boolean | undefined; - excludeTests: boolean | undefined; - funding: string | undefined; + email?: string; + excludeCompliance?: boolean; + excludeContributors?: boolean; + excludeLintJson?: boolean; + excludeLintKnip?: boolean; + excludeLintMd?: boolean; + excludeLintPackageJson?: boolean; + excludeLintPackages?: boolean; + excludeLintPerfectionist?: boolean; + excludeLintSpelling?: boolean; + excludeLintYml?: boolean; + excludeReleases?: boolean; + excludeRenovate?: boolean; + excludeTests?: boolean; + funding?: string; owner: string; repository: string; skipGitHubApi: boolean; - skipInstall: boolean | undefined; - skipRemoval: boolean | undefined; - skipRestore: boolean | undefined; - skipUninstall: boolean | undefined; + skipInstall?: boolean; + skipRemoval?: boolean; + skipRestore?: boolean; + skipUninstall?: boolean; title: string; } diff --git a/src/steps/uninstallPackages.ts b/src/steps/uninstallPackages.ts index 50ad3f28e..c5b453d70 100644 --- a/src/steps/uninstallPackages.ts +++ b/src/steps/uninstallPackages.ts @@ -20,6 +20,8 @@ export async function uninstallPackages() { "prettier", "replace-in-file", "title-case", + "zod", + "zod-validation-error", ], packageData.dependencies, );