Skip to content

Commit

Permalink
feat: smarter initial mode prompt (#919)
Browse files Browse the repository at this point in the history
## PR Checklist

- [x] Addresses an existing open issue: fixes #884
- [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

Enhances `promptForMode` to give different options based on the current
directory:

* If it's empty, offer to `create` a new repository in it or a child
directory
* If it's a Git directory, offer to `initialize` or `migrate`
* If it's not a Git directory, runs `create` for a new repository in a
child directory

In doing so, adds an optional `--directory` that defaults to the
repository's name.

Also cleans up `getPrefillOrPromptedOption` a bit. Instead of allowing
an `existingValue` parameter, calls to `getPrefillOrPromptedOption` are
just put in the right-hand-side of a `??`.
  • Loading branch information
JoshuaKGoldberg authored Oct 1, 2023
1 parent 1a69169 commit 001882b
Show file tree
Hide file tree
Showing 38 changed files with 500 additions and 211 deletions.
1 change: 1 addition & 0 deletions docs/Options.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ The setup scripts also allow for optional overrides of the following inputs whos

- `--access` _(`"public" | "restricted"`)_: Which [`npm publish --access`](https://docs.npmjs.com/cli/commands/npm-publish#access) to release npm packages with (by default, `"public"`)
- `--author` _(`string`)_: Username on npm to publish packages under (by default, an existing npm author, or the currently logged in npm user, or `owner.toLowerCase()`)
- `--directory` _(`string`)_: Directory to create the repository in (by default, the same name as the repository)
- `--email` _(`string`)_: Email address to be listed as the point of contact in docs and packages (e.g. `example@joshuakgoldberg.com`)
- Optionally, `--email-github` _(`string`)_ and/or `--email-npm` _(`string`)_ may be provided to use different emails in `.md` files and `package.json`, respectively
- `--funding` _(`string`)_: GitHub organization or username to mention in `funding.yml` (by default, `owner`)
Expand Down
33 changes: 18 additions & 15 deletions src/bin/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ vi.mock("../migrate/index.js", () => ({

const mockPromptForMode = vi.fn();

vi.mock("./mode.js", () => ({
vi.mock("./promptForMode.js", () => ({
get promptForMode() {
return mockPromptForMode;
},
Expand All @@ -65,8 +65,8 @@ describe("bin", () => {
vi.spyOn(console, "clear").mockImplementation(() => undefined);
});

it("returns 1 when promptForMode returns undefined", async () => {
mockPromptForMode.mockResolvedValue(undefined);
it("returns 1 when promptForMode returns an undefined mode", async () => {
mockPromptForMode.mockResolvedValue({});

const result = await bin([]);

Expand All @@ -76,9 +76,9 @@ describe("bin", () => {
expect(result).toBe(1);
});

it("returns 1 when promptForMode returns an error", async () => {
it("returns 1 when promptForMode returns an error mode", async () => {
const error = new Error("Oh no!");
mockPromptForMode.mockResolvedValue(error);
mockPromptForMode.mockResolvedValue({ mode: error });

const result = await bin([]);

Expand All @@ -90,13 +90,14 @@ describe("bin", () => {
const mode = "create";
const args = ["--owner", "abc123"];
const code = 0;
const promptedOptions = { directory: "." };

mockPromptForMode.mockResolvedValue(mode);
mockPromptForMode.mockResolvedValue({ mode, options: promptedOptions });
mockCreate.mockResolvedValue({ code, options: {} });

const result = await bin(args);

expect(mockCreate).toHaveBeenCalledWith(args);
expect(mockCreate).toHaveBeenCalledWith(args, promptedOptions);
expect(mockCancel).not.toHaveBeenCalled();
expect(mockInitialize).not.toHaveBeenCalled();
expect(mockMigrate).not.toHaveBeenCalled();
Expand All @@ -107,13 +108,14 @@ describe("bin", () => {
const mode = "create";
const args = ["--owner", "abc123"];
const code = 2;
const promptedOptions = { directory: "." };

mockPromptForMode.mockResolvedValue(mode);
mockPromptForMode.mockResolvedValue({ mode, options: promptedOptions });
mockCreate.mockResolvedValue({ code, options: {} });

const result = await bin(args);

expect(mockCreate).toHaveBeenCalledWith(args);
expect(mockCreate).toHaveBeenCalledWith(args, promptedOptions);
expect(mockCancel).toHaveBeenCalledWith(
`Operation cancelled. Exiting - maybe another time? 👋`,
);
Expand All @@ -126,7 +128,7 @@ describe("bin", () => {
const code = 2;
const error = "Oh no!";

mockPromptForMode.mockResolvedValue(mode);
mockPromptForMode.mockResolvedValue({ mode });
mockInitialize.mockResolvedValue({
code: 2,
error,
Expand All @@ -135,7 +137,7 @@ describe("bin", () => {

const result = await bin(args);

expect(mockInitialize).toHaveBeenCalledWith(args);
expect(mockInitialize).toHaveBeenCalledWith(args, undefined);
expect(mockLogLine).toHaveBeenCalledWith(chalk.red(error));
expect(mockCancel).toHaveBeenCalledWith(
`Operation cancelled. Exiting - maybe another time? 👋`,
Expand All @@ -152,7 +154,7 @@ describe("bin", () => {
.object({ email: z.string().email() })
.safeParse({ email: "abc123" });

mockPromptForMode.mockResolvedValue(mode);
mockPromptForMode.mockResolvedValue({ mode });
mockInitialize.mockResolvedValue({
code: 2,
error: (validationResult as z.SafeParseError<{ email: string }>).error,
Expand All @@ -161,7 +163,7 @@ describe("bin", () => {

const result = await bin(args);

expect(mockInitialize).toHaveBeenCalledWith(args);
expect(mockInitialize).toHaveBeenCalledWith(args, undefined);
expect(mockLogLine).toHaveBeenCalledWith(
chalk.red('Validation error: Invalid email at "email"'),
);
Expand All @@ -175,13 +177,14 @@ describe("bin", () => {
const mode = "create";
const args = ["--owner", "abc123"];
const code = 1;
const promptedOptions = { directory: "." };

mockPromptForMode.mockResolvedValue(mode);
mockPromptForMode.mockResolvedValue({ mode, options: promptedOptions });
mockCreate.mockResolvedValue({ code, options: {} });

const result = await bin(args);

expect(mockCreate).toHaveBeenCalledWith(args);
expect(mockCreate).toHaveBeenCalledWith(args, promptedOptions);
expect(mockCancel).toHaveBeenCalledWith(
`Operation failed. Exiting - maybe another time? 👋`,
);
Expand Down
6 changes: 3 additions & 3 deletions src/bin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { initialize } from "../initialize/index.js";
import { migrate } from "../migrate/index.js";
import { logLine } from "../shared/cli/lines.js";
import { StatusCodes } from "../shared/codes.js";
import { promptForMode } from "./mode.js";
import { promptForMode } from "./promptForMode.js";

const operationMessage = (verb: string) =>
`Operation ${verb}. Exiting - maybe another time? 👋`;
Expand Down Expand Up @@ -45,14 +45,14 @@ export async function bin(args: string[]) {
strict: false,
});

const mode = await promptForMode(values.mode);
const { mode, options: promptedOptions } = await promptForMode(values.mode);
if (typeof mode !== "string") {
prompts.outro(chalk.red(mode?.message ?? operationMessage("cancelled")));
return 1;
}

const runners = { create, initialize, migrate };
const { code, error, options } = await runners[mode](args);
const { code, error, options } = await runners[mode](args, promptedOptions);

prompts.log.info(
[
Expand Down
39 changes: 0 additions & 39 deletions src/bin/mode.test.ts

This file was deleted.

67 changes: 0 additions & 67 deletions src/bin/mode.ts

This file was deleted.

117 changes: 117 additions & 0 deletions src/bin/promptForMode.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import chalk from "chalk";
import { describe, expect, it, vi } from "vitest";

import { promptForMode } from "./promptForMode.js";

const mockSelect = vi.fn();

vi.mock("@clack/prompts", () => ({
isCancel: () => false,
get select() {
return mockSelect;
},
}));

const mockReaddir = vi.fn();

vi.mock("node:fs/promises", () => ({
get readdir() {
return mockReaddir;
},
}));

const mockCwd = vi.fn();

vi.mock("node:process", () => ({
get cwd() {
return mockCwd;
},
}));

const mockLogLine = vi.fn();

vi.mock("../shared/cli/lines.js", () => ({
get logLine() {
return mockLogLine;
},
}));
describe("promptForMode", () => {
it("returns an error when the input exists and is not a mode", async () => {
const mode = await promptForMode("other");

expect(mode).toMatchInlineSnapshot(
`
{
"mode": [Error: Unknown --mode: other. Allowed modes are: create, initialize, migrate.],
}
`,
);
});

it("returns the input when it is a mode", async () => {
const input = "create";

const mode = await promptForMode(input);

expect(mode).toEqual({ mode: input });
});

it("returns creating in the current directory when the current directory is empty and the user selects create-current", async () => {
mockSelect.mockResolvedValueOnce("create-current");
const directory = "test-directory";

mockReaddir.mockResolvedValueOnce([]);
mockCwd.mockReturnValueOnce(`/path/to/${directory}`);

const actual = await promptForMode(undefined);

expect(actual).toEqual({
mode: "create",
options: { directory: ".", repository: directory },
});
expect(mockLogLine).not.toHaveBeenCalled();
});

it("returns creating in a child directory when the current directory is empty and the user selects create-child", async () => {
mockSelect.mockResolvedValueOnce("create-child");
const directory = "test-directory";

mockReaddir.mockResolvedValueOnce([]);
mockCwd.mockReturnValueOnce(`/path/to/${directory}`);

const actual = await promptForMode(undefined);

expect(actual).toEqual({
mode: "create",
});
expect(mockLogLine).not.toHaveBeenCalled();
});

it("returns the user selection when the current directory is a Git directory", async () => {
const mode = "initialize";
mockSelect.mockResolvedValueOnce(mode);

mockReaddir.mockResolvedValueOnce([".git"]);

const actual = await promptForMode(undefined);

expect(actual).toEqual({ mode });
expect(mockLogLine).not.toHaveBeenCalled();
});

it("returns create without prompting when the current directory contains children but is not a Git directory", async () => {
const mode = "create";

mockReaddir.mockResolvedValueOnce(["file"]);

const actual = await promptForMode(undefined);

expect(actual).toEqual({ mode });
expect(mockSelect).not.toHaveBeenCalled();
expect(mockLogLine).toHaveBeenCalledWith(
chalk.gray(
"Defaulting to --mode create because the directory contains children and isn't a Git repository.",
),
);
});
});
Loading

0 comments on commit 001882b

Please sign in to comment.