Skip to content

Commit dfa8d35

Browse files
feat: always suggest rerun command (#746)
## PR Checklist - [x] Addresses an existing open issue: fixes #655 - [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 Finally finishes switching over from using `process.exit` to stop on error towards returning more informative results, the way Go does. This rounds out #652. cc @kristo-baricevic - yay! ```plaintext ┌ Welcome to create-typescript-app ! 🎉 │ │ ⚠️ This template is early stage, opinionated, and not endorsed by the TypeScript team. ⚠️⚠️ If any tooling it sets displeases you, you can always remove that portion manually. ⚠️ │ ◇ ✅ Passed checking GitHub authentication. │ ■ How much tooling would you like the template to set up for you? │ Everything! 🙌 │ │ ● Tip: to run again with the same input values, use: npx create-typescript-app --mode migrate --author JoshuaKGoldberg --description "Fills in missing allcontributors entries for a repository. 👪" --email [email protected] --funding JoshuaKGoldberg --owner JoshuaKGoldberg --repository all-contributors-auto-action --title "All Contributors Auto Action" │ └ Operation cancelled. Exiting - maybe another time? 👋 ``` _(that extra `│` before the tip is annoying me, but I don't want to spend time dealing with its edge cases)_
1 parent 4420d5f commit dfa8d35

21 files changed

+551
-268
lines changed

src/bin/index.test.ts

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,20 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
33

44
import { bin } from "./index.js";
55

6+
const mockCancel = vi.fn();
67
const mockOutro = vi.fn();
78

89
vi.mock("@clack/prompts", () => ({
10+
get cancel() {
11+
return mockCancel;
12+
},
913
intro: vi.fn(),
14+
log: {
15+
info: vi.fn(),
16+
},
1017
get outro() {
1118
return mockOutro;
1219
},
13-
spinner: vi.fn(),
1420
}));
1521

1622
const mockLogLine = vi.fn();
@@ -58,6 +64,17 @@ describe("bin", () => {
5864
vi.spyOn(console, "clear").mockImplementation(() => undefined);
5965
});
6066

67+
it("returns 1 when promptForMode returns undefined", async () => {
68+
mockPromptForMode.mockResolvedValue(undefined);
69+
70+
const result = await bin([]);
71+
72+
expect(mockOutro).toHaveBeenCalledWith(
73+
chalk.red("Operation cancelled. Exiting - maybe another time? 👋"),
74+
);
75+
expect(result).toEqual(1);
76+
});
77+
6178
it("returns 1 when promptForMode returns an error", async () => {
6279
const error = new Error("Oh no!");
6380
mockPromptForMode.mockResolvedValue(error);
@@ -68,19 +85,54 @@ describe("bin", () => {
6885
expect(result).toEqual(1);
6986
});
7087

71-
it("returns the result of a runner promptForMode returns a mode", async () => {
88+
it("returns the success result of the corresponding runner without cancel logging when promptForMode returns a mode that succeeds", async () => {
7289
const mode = "create";
7390
const args = ["--owner", "abc123"];
74-
const expected = 0;
91+
const code = 0;
7592

7693
mockPromptForMode.mockResolvedValue(mode);
77-
mockCreate.mockResolvedValue(expected);
94+
mockCreate.mockResolvedValue({ code, options: {} });
7895

7996
const result = await bin(args);
8097

8198
expect(mockCreate).toHaveBeenCalledWith(args);
99+
expect(mockCancel).not.toHaveBeenCalled();
82100
expect(mockInitialize).not.toHaveBeenCalled();
83101
expect(mockMigrate).not.toHaveBeenCalled();
84-
expect(result).toEqual(expected);
102+
expect(result).toEqual(code);
103+
});
104+
105+
it("returns the cancel result of the corresponding runner and cancel logs when promptForMode returns a mode that cancels", async () => {
106+
const mode = "create";
107+
const args = ["--owner", "abc123"];
108+
const code = 2;
109+
110+
mockPromptForMode.mockResolvedValue(mode);
111+
mockCreate.mockResolvedValue({ code, options: {} });
112+
113+
const result = await bin(args);
114+
115+
expect(mockCreate).toHaveBeenCalledWith(args);
116+
expect(mockCancel).toHaveBeenCalledWith(
117+
`Operation cancelled. Exiting - maybe another time? 👋`,
118+
);
119+
expect(result).toEqual(code);
120+
});
121+
122+
it("returns the cancel result of the corresponding runner and cancel logs when promptForMode returns a mode that fails", async () => {
123+
const mode = "create";
124+
const args = ["--owner", "abc123"];
125+
const code = 1;
126+
127+
mockPromptForMode.mockResolvedValue(mode);
128+
mockCreate.mockResolvedValue({ code, options: {} });
129+
130+
const result = await bin(args);
131+
132+
expect(mockCreate).toHaveBeenCalledWith(args);
133+
expect(mockCancel).toHaveBeenCalledWith(
134+
`Operation failed. Exiting - maybe another time? 👋`,
135+
);
136+
expect(result).toEqual(code);
85137
});
86138
});

src/bin/index.ts

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,17 @@ import * as prompts from "@clack/prompts";
22
import chalk from "chalk";
33
import { parseArgs } from "node:util";
44

5+
import { createRerunSuggestion } from "../create/createRerunSuggestion.js";
56
import { create } from "../create/index.js";
67
import { initialize } from "../initialize/index.js";
78
import { migrate } from "../migrate/index.js";
89
import { logLine } from "../shared/cli/lines.js";
10+
import { StatusCodes } from "../shared/codes.js";
911
import { promptForMode } from "./mode.js";
1012

13+
const operationMessage = (verb: string) =>
14+
`Operation ${verb}. Exiting - maybe another time? 👋`;
15+
1116
export async function bin(args: string[]) {
1217
console.clear();
1318

@@ -40,17 +45,28 @@ export async function bin(args: string[]) {
4045
});
4146

4247
const mode = await promptForMode(values.mode);
43-
if (mode instanceof Error) {
44-
prompts.outro(chalk.red(mode.message));
48+
if (typeof mode !== "string") {
49+
prompts.outro(chalk.red(mode?.message ?? operationMessage("cancelled")));
4550
return 1;
4651
}
4752

48-
logLine();
49-
logLine(
50-
chalk.blue(
51-
"Let's collect some information to fill out repository details...",
52-
),
53+
const { code, options } = await { create, initialize, migrate }[mode](args);
54+
55+
prompts.log.info(
56+
[
57+
chalk.italic(`Tip: to run again with the same input values, use:`),
58+
chalk.blue(createRerunSuggestion(mode, options)),
59+
].join(" "),
5360
);
5461

55-
return await { create, initialize, migrate }[mode](args);
62+
if (code) {
63+
logLine();
64+
prompts.cancel(
65+
code === StatusCodes.Cancelled
66+
? operationMessage("cancelled")
67+
: operationMessage("failed"),
68+
);
69+
}
70+
71+
return code;
5672
}

src/bin/mode.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
import * as prompts from "@clack/prompts";
22
import chalk from "chalk";
33

4-
import { handlePromptCancel } from "../shared/prompts.js";
4+
import { StatusCode } from "../shared/codes.js";
5+
import { filterPromptCancel } from "../shared/prompts.js";
6+
import { Options } from "../shared/types.js";
7+
8+
export interface ModeResult {
9+
code: StatusCode;
10+
options: Partial<Options>;
11+
}
12+
13+
export type ModeRunner = (args: string[]) => Promise<ModeResult>;
514

615
export type Mode = "create" | "initialize" | "migrate";
716

@@ -11,9 +20,7 @@ function isMode(input: boolean | string): input is Mode {
1120
return allowedModes.includes(input as Mode);
1221
}
1322

14-
export async function promptForMode(
15-
input: boolean | string | undefined,
16-
): Promise<Error | Mode> {
23+
export async function promptForMode(input: boolean | string | undefined) {
1724
if (input) {
1825
if (!isMode(input)) {
1926
return new Error(
@@ -26,7 +33,7 @@ export async function promptForMode(
2633
return input;
2734
}
2835

29-
const selection = handlePromptCancel(
36+
const selection = filterPromptCancel(
3037
(await prompts.select({
3138
message: chalk.blue("How would you like to use the template?"),
3239
options: [
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { $ } from "execa";
2+
import fs from "node:fs/promises";
3+
4+
export async function createAndEnterRepository(repository: string) {
5+
if ((await fs.readdir(".")).includes(repository)) {
6+
return false;
7+
}
8+
9+
await fs.mkdir(repository);
10+
process.chdir(repository);
11+
await $`git init`;
12+
13+
return true;
14+
}

src/create/createRerunSuggestion.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ function getFirstMatchingArg(key: string) {
88
);
99
}
1010

11-
export function createRerunSuggestion(mode: Mode, options: Options): string {
11+
export function createRerunSuggestion(
12+
mode: Mode,
13+
options: Partial<Options>,
14+
): string {
1215
const args = Object.entries(options)
1316
.filter(([, value]) => !!value)
1417
.map(([key, value]) => {

src/create/index.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import chalk from "chalk";
2+
import { describe, expect, it, vi } from "vitest";
3+
4+
import { StatusCodes } from "../shared/codes.js";
5+
import { create } from "./index.js";
6+
7+
const mockOutro = vi.fn();
8+
9+
vi.mock("@clack/prompts", () => ({
10+
get outro() {
11+
return mockOutro;
12+
},
13+
spinner: vi.fn(),
14+
}));
15+
16+
const mockReadOptions = vi.fn();
17+
18+
vi.mock("../shared/options/readOptions.js", () => ({
19+
get readOptions() {
20+
return mockReadOptions;
21+
},
22+
}));
23+
24+
const mockCreateAndEnterRepository = vi.fn();
25+
26+
vi.mock("./createAndEnterRepository.js", () => ({
27+
get createAndEnterRepository() {
28+
return mockCreateAndEnterRepository;
29+
},
30+
}));
31+
32+
const optionsBase = {
33+
repository: "TestRepository",
34+
};
35+
36+
describe("create", () => {
37+
it("returns a cancellation code when readOptions cancels", async () => {
38+
mockReadOptions.mockResolvedValue({
39+
cancelled: true,
40+
options: optionsBase,
41+
});
42+
43+
const result = await create([]);
44+
45+
expect(result).toEqual({
46+
code: StatusCodes.Cancelled,
47+
options: optionsBase,
48+
});
49+
});
50+
51+
it("returns a failure code when createAndEnterRepository returns false", async () => {
52+
mockReadOptions.mockResolvedValue({
53+
cancelled: false,
54+
options: optionsBase,
55+
});
56+
57+
mockCreateAndEnterRepository.mockResolvedValue(false);
58+
59+
const result = await create([]);
60+
61+
expect(result).toEqual({
62+
code: StatusCodes.Failure,
63+
options: optionsBase,
64+
});
65+
expect(mockOutro).toHaveBeenCalledWith(
66+
chalk.red(
67+
"The TestRepository directory already exists. Please remove the directory or try a different name.",
68+
),
69+
);
70+
});
71+
});

0 commit comments

Comments
 (0)