Skip to content

Commit f4e5830

Browse files
feat: add validation for readOptions with zod (#826)
<!-- 👋 Hi, thanks for sending a PR to create-typescript-app! 💖. Please fill out all fields below and make sure each item is true and [x] checked. Otherwise we may not be able to review your PR. --> ## PR Checklist - [x] Addresses an existing open issue: fixes #784 - [x] That issue was marked as [`status: accepting prs`](https://github.com/JoshuaKGoldberg/create-typescript-app/issues?q=is%3Aopen+is%3Aissue+label%3A%22status%3A+accepting+prs%22) - [x] Steps in [CONTRIBUTING.md](https://github.com/JoshuaKGoldberg/create-typescript-app/blob/main/.github/CONTRIBUTING.md) were taken ## Overview Removed type assertions and replaced them with zod validation. --------- Co-authored-by: Josh Goldberg ✨ <[email protected]>
1 parent 9471054 commit f4e5830

File tree

13 files changed

+248
-62
lines changed

13 files changed

+248
-62
lines changed

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,9 @@
5656
"octokit": "^3.1.0",
5757
"prettier": "^3.0.2",
5858
"replace-in-file": "^7.0.1",
59-
"title-case": "^3.0.3"
59+
"title-case": "^3.0.3",
60+
"zod": "^3.22.2",
61+
"zod-validation-error": "^1.5.0"
6062
},
6163
"devDependencies": {
6264
"@octokit/request-error": "^5.0.0",

pnpm-lock.yaml

Lines changed: 6 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/bin/index.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import chalk from "chalk";
22
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
import z from "zod";
34

45
import { bin } from "./index.js";
56

@@ -119,6 +120,34 @@ describe("bin", () => {
119120
expect(result).toEqual(code);
120121
});
121122

123+
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 () => {
124+
const mode = "initialize";
125+
const args = ["--email", "abc123"];
126+
const code = 2;
127+
128+
const validationResult = z
129+
.object({ email: z.string().email() })
130+
.safeParse({ email: "abc123" });
131+
132+
mockPromptForMode.mockResolvedValue(mode);
133+
mockInitialize.mockResolvedValue({
134+
code: 2,
135+
options: {},
136+
zodError: (validationResult as z.SafeParseError<{ email: string }>).error,
137+
});
138+
139+
const result = await bin(args);
140+
141+
expect(mockInitialize).toHaveBeenCalledWith(args);
142+
expect(mockLogLine).toHaveBeenCalledWith(
143+
chalk.red('Validation error: Invalid email at "email"'),
144+
);
145+
expect(mockCancel).toHaveBeenCalledWith(
146+
`Operation cancelled. Exiting - maybe another time? 👋`,
147+
);
148+
expect(result).toEqual(code);
149+
});
150+
122151
it("returns the cancel result of the corresponding runner and cancel logs when promptForMode returns a mode that fails", async () => {
123152
const mode = "create";
124153
const args = ["--owner", "abc123"];

src/bin/index.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as prompts from "@clack/prompts";
22
import chalk from "chalk";
33
import { parseArgs } from "node:util";
4+
import { fromZodError } from "zod-validation-error";
45

56
import { createRerunSuggestion } from "../create/createRerunSuggestion.js";
67
import { create } from "../create/index.js";
@@ -50,7 +51,8 @@ export async function bin(args: string[]) {
5051
return 1;
5152
}
5253

53-
const { code, options } = await { create, initialize, migrate }[mode](args);
54+
const runners = { create, initialize, migrate };
55+
const { code, options, zodError } = await runners[mode](args);
5456

5557
prompts.log.info(
5658
[
@@ -61,6 +63,13 @@ export async function bin(args: string[]) {
6163

6264
if (code) {
6365
logLine();
66+
67+
if (zodError) {
68+
const validationError = fromZodError(zodError);
69+
logLine(chalk.red(validationError));
70+
logLine();
71+
}
72+
6473
prompts.cancel(
6574
code === StatusCodes.Cancelled
6675
? operationMessage("cancelled")

src/bin/mode.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as prompts from "@clack/prompts";
22
import chalk from "chalk";
3+
import z from "zod";
34

45
import { StatusCode } from "../shared/codes.js";
56
import { filterPromptCancel } from "../shared/prompts.js";
@@ -8,6 +9,7 @@ import { Options } from "../shared/types.js";
89
export interface ModeResult {
910
code: StatusCode;
1011
options: Partial<Options>;
12+
zodError?: z.ZodError<object>;
1113
}
1214

1315
export type ModeRunner = (args: string[]) => Promise<ModeResult>;

src/create/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export async function create(args: string[]): Promise<ModeResult> {
1616
return {
1717
code: StatusCodes.Cancelled,
1818
options: inputs.options,
19+
zodError: inputs.zodError,
1920
};
2021
}
2122

src/initialize/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export const initialize: ModeRunner = async (args) => {
1212
return {
1313
code: StatusCodes.Cancelled,
1414
options: inputs.options,
15+
zodError: inputs.zodError,
1516
};
1617
}
1718

src/migrate/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export const migrate: ModeRunner = async (args) => {
1212
return {
1313
code: StatusCodes.Cancelled,
1414
options: inputs.options,
15+
zodError: inputs.zodError,
1516
};
1617
}
1718

src/shared/options/augmentOptionsWithExcludes.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
import * as prompts from "@clack/prompts";
22

33
import { filterPromptCancel } from "../prompts.js";
4-
import { Options } from "../types.js";
5-
6-
type Base = "everything" | "minimum" | "prompt";
4+
import { InputBase, Options } from "../types.js";
75

86
const exclusionDescriptions = {
97
excludeCompliance: {
@@ -85,7 +83,7 @@ export async function augmentOptionsWithExcludes(
8583

8684
const base =
8785
options.base ??
88-
filterPromptCancel<Base | symbol>(
86+
filterPromptCancel<InputBase | symbol>(
8987
await prompts.select({
9088
message: `How much tooling would you like the template to set up for you?`,
9189
options: [
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import z from "zod";
3+
4+
import { readOptions } from "./readOptions.js";
5+
6+
const emptyOptions = {
7+
author: undefined,
8+
base: undefined,
9+
createRepository: undefined,
10+
description: undefined,
11+
email: undefined,
12+
excludeCompliance: undefined,
13+
excludeContributors: undefined,
14+
excludeLintJson: undefined,
15+
excludeLintKnip: undefined,
16+
excludeLintMd: undefined,
17+
excludeLintPackageJson: undefined,
18+
excludeLintPackages: undefined,
19+
excludeLintPerfectionist: undefined,
20+
excludeLintSpelling: undefined,
21+
excludeLintYml: undefined,
22+
excludeReleases: undefined,
23+
excludeRenovate: undefined,
24+
excludeTests: undefined,
25+
funding: undefined,
26+
owner: undefined,
27+
repository: undefined,
28+
skipGitHubApi: false,
29+
skipInstall: false,
30+
skipRemoval: false,
31+
skipRestore: undefined,
32+
skipUninstall: false,
33+
title: undefined,
34+
};
35+
36+
const mockOptions = {
37+
base: "prompt",
38+
github: "mock.git",
39+
repository: "mock.repository",
40+
};
41+
42+
vi.mock("./getPrefillOrPromptedOption.js", () => ({
43+
getPrefillOrPromptedOption() {
44+
return () => "mock";
45+
},
46+
}));
47+
48+
vi.mock("./ensureRepositoryExists.js", () => ({
49+
ensureRepositoryExists() {
50+
return {
51+
github: mockOptions.github,
52+
repository: mockOptions.repository,
53+
};
54+
},
55+
}));
56+
57+
vi.mock("../../shared/cli/spinners.ts", () => ({
58+
withSpinner() {
59+
return () => ({});
60+
},
61+
}));
62+
63+
vi.mock("./augmentOptionsWithExcludes.js", () => ({
64+
augmentOptionsWithExcludes() {
65+
return { ...emptyOptions, ...mockOptions };
66+
},
67+
}));
68+
69+
describe("readOptions", () => {
70+
it("cancels the function when --email is invalid", async () => {
71+
const validationResult = z
72+
.object({ email: z.string().email() })
73+
.safeParse({ email: "wrongEmail" });
74+
75+
expect(await readOptions(["--email", "wrongEmail"])).toStrictEqual({
76+
cancelled: true,
77+
options: { ...emptyOptions, email: "wrongEmail" },
78+
zodError: (validationResult as z.SafeParseError<{ email: string }>).error,
79+
});
80+
});
81+
82+
it("successfully runs the function when --base is valid", async () => {
83+
expect(await readOptions(["--base", mockOptions.base])).toStrictEqual({
84+
cancelled: false,
85+
github: mockOptions.github,
86+
options: {
87+
...emptyOptions,
88+
...mockOptions,
89+
},
90+
});
91+
});
92+
});

0 commit comments

Comments
 (0)