From 1d82f53cc2c6b1d4d79cada356ba1ed516834f8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Mon, 6 Jan 2025 16:19:26 -0500 Subject: [PATCH] feat: add rerun suggestions to the CLI (#121) * feat: add rerun suggestions to the CLI * Revert reorder --- .../importers/tryImportTemplatePreset.test.ts | 7 +- ...lize.test.ts => asCreationOptions.test.ts} | 16 ++- ...sForInitialize.ts => asCreationOptions.ts} | 6 +- .../initialize/createRepositoryOnGitHub.ts | 2 +- .../cli/initialize/createTrackingBranches.ts | 2 +- .../cli/initialize/runModeInitialize.test.ts | 98 +++++++++++++------ .../src/cli/initialize/runModeInitialize.ts | 15 ++- .../cli/loggers/logMigrateHelpText.test.ts | 7 +- .../cli/loggers/logRerunSuggestion.test.ts | 46 +++++++++ .../src/cli/loggers/logRerunSuggestion.ts | 43 ++++++++ .../src/cli/migrate/runModeMigrate.test.ts | 73 ++++++++++---- .../create/src/cli/migrate/runModeMigrate.ts | 14 ++- .../src/cli/parsers/applyArgsToSettings.ts | 3 +- .../cli/prompts/promptForBaseOptions.test.ts | 51 ++++++---- .../src/cli/prompts/promptForBaseOptions.ts | 53 +++++++--- .../cli/prompts/promptForDirectory.test.ts | 7 +- .../src/cli/prompts/promptForDirectory.ts | 5 +- packages/create/src/cli/utils.ts | 4 + 18 files changed, 326 insertions(+), 126 deletions(-) rename packages/create/src/cli/initialize/{assertOptionsForInitialize.test.ts => asCreationOptions.test.ts} (77%) rename packages/create/src/cli/initialize/{assertOptionsForInitialize.ts => asCreationOptions.ts} (75%) create mode 100644 packages/create/src/cli/loggers/logRerunSuggestion.test.ts create mode 100644 packages/create/src/cli/loggers/logRerunSuggestion.ts diff --git a/packages/create/src/cli/importers/tryImportTemplatePreset.test.ts b/packages/create/src/cli/importers/tryImportTemplatePreset.test.ts index add813aa..143015a0 100644 --- a/packages/create/src/cli/importers/tryImportTemplatePreset.test.ts +++ b/packages/create/src/cli/importers/tryImportTemplatePreset.test.ts @@ -2,13 +2,10 @@ import { describe, expect, it, vi } from "vitest"; import { tryImportTemplatePreset } from "./tryImportTemplatePreset.js"; -const mockCancel = Symbol(""); -const mockIsCancel = (value: unknown) => value === mockCancel; +const mockCancel = Symbol("cancel"); vi.mock("@clack/prompts", () => ({ - get isCancel() { - return mockIsCancel; - }, + isCancel: (value: unknown) => value === mockCancel, })); const mockPromptForPreset = vi.fn(); diff --git a/packages/create/src/cli/initialize/assertOptionsForInitialize.test.ts b/packages/create/src/cli/initialize/asCreationOptions.test.ts similarity index 77% rename from packages/create/src/cli/initialize/assertOptionsForInitialize.test.ts rename to packages/create/src/cli/initialize/asCreationOptions.test.ts index 0d1b4f0a..77dcacc1 100644 --- a/packages/create/src/cli/initialize/assertOptionsForInitialize.test.ts +++ b/packages/create/src/cli/initialize/asCreationOptions.test.ts @@ -1,16 +1,16 @@ import { describe, expect, it } from "vitest"; -import { assertOptionsForInitialize } from "./assertOptionsForInitialize.js"; +import { asCreationOptions } from "./asCreationOptions.js"; const owner = "TestOwner"; const repository = "test-repository"; -describe("assertOptionsForInitialize", () => { +describe("asCreationOptions", () => { it("throws an error when the options are missing owner", () => { const options = { repository }; expect(() => { - assertOptionsForInitialize(options); + asCreationOptions(options); }).toThrowError( `To run with --mode initialize, the Template must have a --owner Option of type string.`, ); @@ -20,7 +20,7 @@ describe("assertOptionsForInitialize", () => { const options = { owner: 123, repository }; expect(() => { - assertOptionsForInitialize(options); + asCreationOptions(options); }).toThrowError( `To run with --mode initialize, the Template must have a --owner Option of type string.`, ); @@ -30,7 +30,7 @@ describe("assertOptionsForInitialize", () => { const options = { owner }; expect(() => { - assertOptionsForInitialize(options); + asCreationOptions(options); }).toThrowError( `To run with --mode initialize, the Template must have a --repository Option of type string.`, ); @@ -40,7 +40,7 @@ describe("assertOptionsForInitialize", () => { const options = { owner, repository: 123 }; expect(() => { - assertOptionsForInitialize(options); + asCreationOptions(options); }).toThrowError( `To run with --mode initialize, the Template must have a --repository Option of type string.`, ); @@ -49,8 +49,6 @@ describe("assertOptionsForInitialize", () => { it("does not throw an error when the options have owner and repository", () => { const options = { owner, repository }; - expect(() => { - assertOptionsForInitialize(options); - }).not.toThrow(); + expect(asCreationOptions(options)).toBe(options); }); }); diff --git a/packages/create/src/cli/initialize/assertOptionsForInitialize.ts b/packages/create/src/cli/initialize/asCreationOptions.ts similarity index 75% rename from packages/create/src/cli/initialize/assertOptionsForInitialize.ts rename to packages/create/src/cli/initialize/asCreationOptions.ts index cdc6ee79..3622d60f 100644 --- a/packages/create/src/cli/initialize/assertOptionsForInitialize.ts +++ b/packages/create/src/cli/initialize/asCreationOptions.ts @@ -3,9 +3,7 @@ export interface CreationOptions { repository: string; } -export function assertOptionsForInitialize( - options: object, -): asserts options is CreationOptions { +export function asCreationOptions(options: object): CreationOptions { for (const key of ["owner", "repository"]) { if (typeof (options as Record)[key] !== "string") { throw new Error( @@ -13,4 +11,6 @@ export function assertOptionsForInitialize( ); } } + + return options as CreationOptions; } diff --git a/packages/create/src/cli/initialize/createRepositoryOnGitHub.ts b/packages/create/src/cli/initialize/createRepositoryOnGitHub.ts index 4159860e..d447dd82 100644 --- a/packages/create/src/cli/initialize/createRepositoryOnGitHub.ts +++ b/packages/create/src/cli/initialize/createRepositoryOnGitHub.ts @@ -1,7 +1,7 @@ import { Octokit } from "octokit"; import { RepositoryTemplate } from "../../types/bases.js"; -import { CreationOptions } from "./assertOptionsForInitialize.js"; +import { CreationOptions } from "./asCreationOptions.js"; export async function createRepositoryOnGitHub( { owner, repository }: CreationOptions, diff --git a/packages/create/src/cli/initialize/createTrackingBranches.ts b/packages/create/src/cli/initialize/createTrackingBranches.ts index d89fd5d6..1b45e3c0 100644 --- a/packages/create/src/cli/initialize/createTrackingBranches.ts +++ b/packages/create/src/cli/initialize/createTrackingBranches.ts @@ -1,5 +1,5 @@ import { SystemRunner } from "../../types/system.js"; -import { CreationOptions } from "./assertOptionsForInitialize.js"; +import { CreationOptions } from "./asCreationOptions.js"; export async function createTrackingBranches( { owner, repository }: CreationOptions, diff --git a/packages/create/src/cli/initialize/runModeInitialize.test.ts b/packages/create/src/cli/initialize/runModeInitialize.test.ts index f73d0e66..5c7a3a74 100644 --- a/packages/create/src/cli/initialize/runModeInitialize.test.ts +++ b/packages/create/src/cli/initialize/runModeInitialize.test.ts @@ -6,17 +6,14 @@ import { CLIMessage } from "../messages.js"; import { CLIStatus } from "../status.js"; import { runModeInitialize } from "./runModeInitialize.js"; -const mockCancel = Symbol(""); -const mockIsCancel = (value: unknown) => value === mockCancel; +const mockCancel = Symbol("cancel"); const mockLog = { error: vi.fn(), message: vi.fn(), }; vi.mock("@clack/prompts", () => ({ - get isCancel() { - return mockIsCancel; - }, + isCancel: (value: unknown) => value === mockCancel, get log() { return mockLog; }, @@ -32,6 +29,14 @@ vi.mock("../loggers/logInitializeHelpText.js", () => ({ }, })); +const mockLogRerunSuggestion = vi.fn(); + +vi.mock("../loggers/logRerunSuggestion.js", () => ({ + get logRerunSuggestion() { + return mockLogRerunSuggestion; + }, +})); + const mockRunPreset = vi.fn(); vi.mock("../../runners/runPreset.js", () => ({ @@ -122,6 +127,12 @@ const template = base.createTemplate({ presets: [], }); +const args = ["create-my-app"]; + +const promptedOptions = { + abc: "def", +}; + describe("runModeInitialize", () => { it("logs help text when from is undefined", async () => { const actual = await runModeInitialize({ @@ -154,7 +165,7 @@ describe("runModeInitialize", () => { mockTryImportTemplatePreset.mockResolvedValueOnce(new Error("Oh no!")); const actual = await runModeInitialize({ - args: ["node", "create", "my-app"], + args, display, from: "create-my-app", }); @@ -169,7 +180,7 @@ describe("runModeInitialize", () => { mockTryImportTemplatePreset.mockResolvedValueOnce(mockCancel); const actual = await runModeInitialize({ - args: ["node", "create", "my-app"], + args, display, from: "create-my-app", }); @@ -182,7 +193,7 @@ describe("runModeInitialize", () => { mockPromptForDirectory.mockResolvedValueOnce(mockCancel); const actual = await runModeInitialize({ - args: ["node", "create", "my-app"], + args, display, from: "create-my-app", }); @@ -193,15 +204,20 @@ describe("runModeInitialize", () => { it("returns the cancellation when promptForBaseOptions is cancelled", async () => { mockTryImportTemplatePreset.mockResolvedValueOnce({ preset, template }); mockPromptForDirectory.mockResolvedValueOnce("."); - mockPromptForBaseOptions.mockResolvedValueOnce(mockCancel); + + mockPromptForBaseOptions.mockResolvedValueOnce({ + cancelled: true, + prompted: promptedOptions, + }); const actual = await runModeInitialize({ - args: ["node", "create", "my-app"], + args, display, from: "create-my-app", }); expect(actual).toEqual({ status: CLIStatus.Cancelled }); + expect(mockLogRerunSuggestion).toHaveBeenCalledWith(args, promptedOptions); }); it("returns an error when applyArgsToSettings returns an error", async () => { @@ -210,13 +226,16 @@ describe("runModeInitialize", () => { mockTryImportTemplatePreset.mockResolvedValueOnce({ preset, template }); mockPromptForDirectory.mockResolvedValueOnce("."); mockPromptForBaseOptions.mockResolvedValueOnce({ - owner: "TestOwner", - repository: "test-repository", + completed: { + owner: "TestOwner", + repository: "test-repository", + }, + prompted: promptedOptions, }); mockApplyArgsToSettings.mockReturnValueOnce(new Error(message)); const actual = await runModeInitialize({ - args: ["node", "create", "my-app"], + args, display, from: "create-my-app", }); @@ -225,6 +244,8 @@ describe("runModeInitialize", () => { outro: message, status: CLIStatus.Error, }); + + expect(mockLogRerunSuggestion).toHaveBeenCalledWith(args, promptedOptions); }); it("runs createRepositoryOnGitHub when offline is falsy", async () => { @@ -234,15 +255,18 @@ describe("runModeInitialize", () => { mockTryImportTemplatePreset.mockResolvedValueOnce({ preset, template }); mockPromptForDirectory.mockResolvedValueOnce(directory); mockPromptForBaseOptions.mockResolvedValueOnce({ - owner: "TestOwner", - repository: "test-repository", + completed: { + owner: "TestOwner", + repository: "test-repository", + }, + prompted: promptedOptions, }); mockRunPreset.mockResolvedValueOnce({ suggestions, }); await runModeInitialize({ - args: ["node", "create", "my-app"], + args, display, from: "create-my-app", }); @@ -272,15 +296,18 @@ describe("runModeInitialize", () => { mockTryImportTemplatePreset.mockResolvedValueOnce({ preset, template }); mockPromptForDirectory.mockResolvedValueOnce(directory); mockPromptForBaseOptions.mockResolvedValueOnce({ - owner: "TestOwner", - repository: "test-repository", + completed: { + owner: "TestOwner", + repository: "test-repository", + }, + prompted: promptedOptions, }); mockRunPreset.mockResolvedValueOnce({ suggestions, }); await runModeInitialize({ - args: ["node", "create", "my-app"], + args, display, from: "create-my-app", offline: true, @@ -310,13 +337,16 @@ describe("runModeInitialize", () => { mockTryImportTemplatePreset.mockResolvedValueOnce({ preset, template }); mockPromptForDirectory.mockResolvedValueOnce(directory); mockPromptForBaseOptions.mockResolvedValueOnce({ - owner: "TestOwner", - repository: "test-repository", + completed: { + owner: "TestOwner", + repository: "test-repository", + }, + prompted: promptedOptions, }); mockRunPreset.mockRejectedValueOnce(new Error("Oh no!")); const actual = await runModeInitialize({ - args: ["node", "create", "my-app"], + args, display, from: "create-my-app", }); @@ -325,6 +355,8 @@ describe("runModeInitialize", () => { outro: `Leaving changes to the local directory on disk. 👋`, status: CLIStatus.Error, }); + + expect(mockLogRerunSuggestion).toHaveBeenCalledWith(args, promptedOptions); }); it("returns a CLI success and makes an absolute directory relative when importing and running the preset succeeds", async () => { @@ -334,15 +366,18 @@ describe("runModeInitialize", () => { mockTryImportTemplatePreset.mockResolvedValueOnce({ preset, template }); mockPromptForDirectory.mockResolvedValueOnce(directory); mockPromptForBaseOptions.mockResolvedValueOnce({ - owner: "TestOwner", - repository: "test-repository", + completed: { + owner: "TestOwner", + repository: "test-repository", + }, + prompted: promptedOptions, }); mockRunPreset.mockResolvedValueOnce({ suggestions, }); const actual = await runModeInitialize({ - args: ["node", "create", "my-app"], + args, display, from: "create-my-app", }); @@ -352,6 +387,8 @@ describe("runModeInitialize", () => { status: CLIStatus.Success, suggestions, }); + + expect(mockLogRerunSuggestion).toHaveBeenCalledWith(args, promptedOptions); expect(mockRunPreset).toHaveBeenCalledWith(preset, expect.any(Object)); }); @@ -362,15 +399,18 @@ describe("runModeInitialize", () => { mockTryImportTemplatePreset.mockResolvedValueOnce({ preset, template }); mockPromptForDirectory.mockResolvedValueOnce(directory); mockPromptForBaseOptions.mockResolvedValueOnce({ - owner: "TestOwner", - repository: "test-repository", + completed: { + owner: "TestOwner", + repository: "test-repository", + }, + prompted: promptedOptions, }); mockRunPreset.mockResolvedValueOnce({ suggestions, }); const actual = await runModeInitialize({ - args: ["node", "create", "my-app"], + args, display, from: "create-my-app", }); @@ -380,6 +420,8 @@ describe("runModeInitialize", () => { status: CLIStatus.Success, suggestions, }); + + expect(mockLogRerunSuggestion).toHaveBeenCalledWith(args, promptedOptions); expect(mockRunPreset).toHaveBeenCalledWith(preset, expect.any(Object)); }); }); diff --git a/packages/create/src/cli/initialize/runModeInitialize.ts b/packages/create/src/cli/initialize/runModeInitialize.ts index 09a055ad..de1e9052 100644 --- a/packages/create/src/cli/initialize/runModeInitialize.ts +++ b/packages/create/src/cli/initialize/runModeInitialize.ts @@ -9,6 +9,7 @@ import { ClackDisplay } from "../display/createClackDisplay.js"; import { runSpinnerTask } from "../display/runSpinnerTask.js"; import { tryImportTemplatePreset } from "../importers/tryImportTemplatePreset.js"; import { logInitializeHelpText } from "../loggers/logInitializeHelpText.js"; +import { logRerunSuggestion } from "../loggers/logRerunSuggestion.js"; import { logStartText } from "../loggers/logStartText.js"; import { CLIMessage } from "../messages.js"; import { applyArgsToSettings } from "../parsers/applyArgsToSettings.js"; @@ -18,7 +19,7 @@ import { promptForDirectory } from "../prompts/promptForDirectory.js"; import { CLIStatus } from "../status.js"; import { ModeResults } from "../types.js"; import { makeRelative } from "../utils.js"; -import { assertOptionsForInitialize } from "./assertOptionsForInitialize.js"; +import { asCreationOptions } from "./asCreationOptions.js"; import { createRepositoryOnGitHub } from "./createRepositoryOnGitHub.js"; import { createTrackingBranches } from "./createTrackingBranches.js"; @@ -77,8 +78,8 @@ export async function runModeInitialize({ offline, }); - const options = await promptForBaseOptions(preset.base, { - existingOptions: { + const baseOptions = await promptForBaseOptions(preset.base, { + existing: { directory, repository: repository ?? directory, ...parseZodArgs(args, preset.base.options), @@ -86,14 +87,16 @@ export async function runModeInitialize({ offline, system, }); - if (prompts.isCancel(options)) { + if (baseOptions.cancelled) { + logRerunSuggestion(args, baseOptions.prompted); return { status: CLIStatus.Cancelled }; } - assertOptionsForInitialize(options); + const options = asCreationOptions(baseOptions.completed); const settings = applyArgsToSettings(args, preset); if (settings instanceof Error) { + logRerunSuggestion(args, baseOptions.prompted); return { outro: settings.message, status: CLIStatus.Error }; } @@ -127,6 +130,7 @@ export async function runModeInitialize({ }), ); if (creation instanceof Error) { + logRerunSuggestion(args, baseOptions.prompted); return { outro: `Leaving changes to the local directory on disk. 👋`, status: CLIStatus.Error, @@ -144,6 +148,7 @@ export async function runModeInitialize({ }, ); + logRerunSuggestion(args, baseOptions.prompted); prompts.log.message( [ "Great, you've got a new repository ready to use in:", diff --git a/packages/create/src/cli/loggers/logMigrateHelpText.test.ts b/packages/create/src/cli/loggers/logMigrateHelpText.test.ts index 921fb08b..efe95304 100644 --- a/packages/create/src/cli/loggers/logMigrateHelpText.test.ts +++ b/packages/create/src/cli/loggers/logMigrateHelpText.test.ts @@ -7,17 +7,14 @@ import { MigrationSource } from "../migrate/parseMigrationSource.js"; import { CLIStatus } from "../status.js"; import { logMigrateHelpText } from "./logMigrateHelpText.js"; -const mockCancel = Symbol(""); -const mockIsCancel = (value: unknown) => value === mockCancel; +const mockCancel = Symbol("cancel"); const mockSpinner = { start: vi.fn(), stop: vi.fn(), }; vi.mock("@clack/prompts", () => ({ - get isCancel() { - return mockIsCancel; - }, + isCancel: (value: unknown) => value === mockCancel, spinner: () => mockSpinner, })); diff --git a/packages/create/src/cli/loggers/logRerunSuggestion.test.ts b/packages/create/src/cli/loggers/logRerunSuggestion.test.ts new file mode 100644 index 00000000..d582723d --- /dev/null +++ b/packages/create/src/cli/loggers/logRerunSuggestion.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, test, vi } from "vitest"; + +import { logRerunSuggestion } from "./logRerunSuggestion.js"; + +const mockLog = { + info: vi.fn(), +}; + +vi.mock("@clack/prompts", () => ({ + get log() { + return mockLog; + }, +})); + +describe("logRerunSuggestion", () => { + it("does not log when there are no prompted entries", () => { + logRerunSuggestion(["my-app"], {}); + + expect(mockLog.info).not.toHaveBeenCalled(); + }); + + it("logs when there are no prompted entries", () => { + logRerunSuggestion(["my-app"], { abc: "def" }); + + expect(mockLog.info).toHaveBeenCalled(); + }); + + test("value stringification", () => { + logRerunSuggestion(["my-app"], { + "is-false": false, + "is-true": true, + multiple: ["def", 456], + numeric: 123, + spaced: "a bb ccc", + stringy: "abc", + }); + + expect(mockLog.info.mock.calls).toMatchInlineSnapshot(` + [ + [ + "Tip: to run again with the same input values, use: npx create my-app --is-false false --is-true --multiple def --multiple 456 --numeric 123 --spaced "a bb ccc" --stringy abc", + ], + ] + `); + }); +}); diff --git a/packages/create/src/cli/loggers/logRerunSuggestion.ts b/packages/create/src/cli/loggers/logRerunSuggestion.ts new file mode 100644 index 00000000..d3ffa6bb --- /dev/null +++ b/packages/create/src/cli/loggers/logRerunSuggestion.ts @@ -0,0 +1,43 @@ +import * as prompts from "@clack/prompts"; +import chalk from "chalk"; + +export function logRerunSuggestion(args: string[], prompted: object) { + const promptedEntries = Object.entries(prompted); + if (!promptedEntries.length) { + return; + } + + prompts.log.info( + [ + chalk.italic(`Tip: to run again with the same input values, use:`), + chalk.blue( + [ + `npx create`, + ...args, + promptedEntries + + .map(([key, value]) => stringifyPair(key, value)) + .join(" "), + ].join(" "), + ), + ].join(" "), + ); +} + +function stringifyPair(key: string, value: unknown): string { + if (Array.isArray(value)) { + return value.map((element) => stringifyPair(key, element)).join(" "); + } + + const flag = `--${key}`; + + if (typeof value === "boolean" && value) { + return flag; + } + + const valueStringified = String(value); + + return valueStringified.includes(" ") + ? `${flag} "${valueStringified}"` + : `${flag} ${valueStringified}`; +} diff --git a/packages/create/src/cli/migrate/runModeMigrate.test.ts b/packages/create/src/cli/migrate/runModeMigrate.test.ts index f798ee6b..3d23758c 100644 --- a/packages/create/src/cli/migrate/runModeMigrate.test.ts +++ b/packages/create/src/cli/migrate/runModeMigrate.test.ts @@ -5,17 +5,15 @@ import { ClackDisplay } from "../display/createClackDisplay.js"; import { CLIStatus } from "../status.js"; import { runModeMigrate } from "./runModeMigrate.js"; -const mockCancel = Symbol(""); -const mockIsCancel = (value: unknown) => value === mockCancel; const mockLog = { error: vi.fn(), message: vi.fn(), }; +const mockCancel = Symbol("cancel"); + vi.mock("@clack/prompts", () => ({ - get isCancel() { - return mockIsCancel; - }, + isCancel: (value: unknown) => value === mockCancel, get log() { return mockLog; }, @@ -30,6 +28,14 @@ vi.mock("../loggers/logMigrateHelpText.js", () => ({ }, })); +const mockLogRerunSuggestion = vi.fn(); + +vi.mock("../loggers/logRerunSuggestion.js", () => ({ + get logRerunSuggestion() { + return mockLogRerunSuggestion; + }, +})); + const mockRunPreset = vi.fn(); vi.mock("../../runners/runPreset.js", () => ({ @@ -136,6 +142,8 @@ const preset = base.createPreset({ blocks: [], }); +const args = ["create-my-app"]; + const descriptor = "Test Source"; const type = "template"; @@ -145,12 +153,16 @@ const source = { type, }; +const promptedOptions = { + abc: "def", +}; + describe("runModeMigrate", () => { it("logs help text instead of running when help is true", async () => { mockParseMigrationSource.mockReturnValueOnce(source); await runModeMigrate({ - args: [], + args, configFile: undefined, display, help: true, @@ -166,7 +178,7 @@ describe("runModeMigrate", () => { mockParseMigrationSource.mockReturnValueOnce(error); const actual = await runModeMigrate({ - args: [], + args, configFile: undefined, display, }); @@ -185,7 +197,7 @@ describe("runModeMigrate", () => { }); const actual = await runModeMigrate({ - args: [], + args, configFile: undefined, display, }); @@ -202,7 +214,7 @@ describe("runModeMigrate", () => { }); const actual = await runModeMigrate({ - args: [], + args, configFile: undefined, display, }); @@ -216,10 +228,13 @@ describe("runModeMigrate", () => { mockParseMigrationSource.mockReturnValueOnce({ load: () => Promise.resolve({ preset }), }); - mockPromptForBaseOptions.mockResolvedValueOnce(mockCancel); + mockPromptForBaseOptions.mockResolvedValueOnce({ + cancelled: true, + prompted: promptedOptions, + }); const actual = await runModeMigrate({ - args: [], + args, configFile: undefined, display, }); @@ -227,6 +242,7 @@ describe("runModeMigrate", () => { expect(actual).toEqual({ status: CLIStatus.Cancelled, }); + expect(mockLogRerunSuggestion).toHaveBeenCalledWith(args, promptedOptions); }); it("returns the error when applyArgsToSettings returns an error", async () => { @@ -235,29 +251,34 @@ describe("runModeMigrate", () => { mockParseMigrationSource.mockReturnValueOnce({ load: () => Promise.resolve({ preset }), }); - mockPromptForBaseOptions.mockResolvedValueOnce({}); + mockPromptForBaseOptions.mockResolvedValueOnce({ + prompted: promptedOptions, + }); mockGetForkedTemplateLocator.mockResolvedValueOnce(undefined); mockApplyArgsToSettings.mockReturnValueOnce(new Error(message)); const actual = await runModeMigrate({ - args: [], + args, configFile: undefined, display, }); expect(actual).toEqual({ outro: message, status: CLIStatus.Error }); + expect(mockLogRerunSuggestion).toHaveBeenCalledWith(args, promptedOptions); }); it("returns the error when runPreset resolves with an error", async () => { mockParseMigrationSource.mockReturnValueOnce({ load: () => Promise.resolve({ preset }), }); - mockPromptForBaseOptions.mockResolvedValueOnce({}); + mockPromptForBaseOptions.mockResolvedValueOnce({ + prompted: promptedOptions, + }); mockGetForkedTemplateLocator.mockResolvedValueOnce(undefined); mockRunPreset.mockRejectedValueOnce(new Error("Oh no!")); const actual = await runModeMigrate({ - args: [], + args, configFile: undefined, display, }); @@ -266,17 +287,20 @@ describe("runModeMigrate", () => { outro: `Leaving changes to the local directory on disk. 👋`, status: CLIStatus.Error, }); + expect(mockLogRerunSuggestion).toHaveBeenCalledWith(args, promptedOptions); }); it("doesn't clear the existing repository when no forked template locator is available", async () => { mockParseMigrationSource.mockReturnValueOnce({ load: () => Promise.resolve({ preset }), }); - mockPromptForBaseOptions.mockResolvedValueOnce({}); + mockPromptForBaseOptions.mockResolvedValueOnce({ + prompted: promptedOptions, + }); mockGetForkedTemplateLocator.mockResolvedValueOnce(undefined); const actual = await runModeMigrate({ - args: [], + args, configFile: undefined, display, }); @@ -287,6 +311,7 @@ describe("runModeMigrate", () => { }); expect(mockClearTemplateFiles).not.toHaveBeenCalled(); expect(mockClearLocalGitTags).not.toHaveBeenCalled(); + expect(mockLogRerunSuggestion).toHaveBeenCalledWith(args, promptedOptions); }); it("clears the existing repository online when a forked template locator is available and offline is falsy", async () => { @@ -294,14 +319,16 @@ describe("runModeMigrate", () => { const type = "template"; mockParseMigrationSource.mockReturnValueOnce(source); - mockPromptForBaseOptions.mockResolvedValueOnce({}); + mockPromptForBaseOptions.mockResolvedValueOnce({ + prompted: promptedOptions, + }); mockGetForkedTemplateLocator.mockResolvedValueOnce({ owner: "", repository: "", }); const actual = await runModeMigrate({ - args: [], + args, configFile: undefined, display, }); @@ -322,6 +349,7 @@ describe("runModeMigrate", () => { amend: true, offline: undefined, }); + expect(mockLogRerunSuggestion).toHaveBeenCalledWith(args, promptedOptions); }); it("clears the existing repository offline when a forked template locator is available and offline is true", async () => { @@ -329,14 +357,16 @@ describe("runModeMigrate", () => { const type = "template"; mockParseMigrationSource.mockReturnValueOnce(source); - mockPromptForBaseOptions.mockResolvedValueOnce({}); + mockPromptForBaseOptions.mockResolvedValueOnce({ + prompted: promptedOptions, + }); mockGetForkedTemplateLocator.mockResolvedValueOnce({ owner: "", repository: "", }); const actual = await runModeMigrate({ - args: [], + args, configFile: undefined, display, offline: true, @@ -358,5 +388,6 @@ describe("runModeMigrate", () => { amend: true, offline: true, }); + expect(mockLogRerunSuggestion).toHaveBeenCalledWith(args, promptedOptions); }); }); diff --git a/packages/create/src/cli/migrate/runModeMigrate.ts b/packages/create/src/cli/migrate/runModeMigrate.ts index 3d893b06..7f8de0dd 100644 --- a/packages/create/src/cli/migrate/runModeMigrate.ts +++ b/packages/create/src/cli/migrate/runModeMigrate.ts @@ -7,6 +7,7 @@ import { createInitialCommit } from "../createInitialCommit.js"; import { ClackDisplay } from "../display/createClackDisplay.js"; import { runSpinnerTask } from "../display/runSpinnerTask.js"; import { logMigrateHelpText } from "../loggers/logMigrateHelpText.js"; +import { logRerunSuggestion } from "../loggers/logRerunSuggestion.js"; import { logStartText } from "../loggers/logStartText.js"; import { applyArgsToSettings } from "../parsers/applyArgsToSettings.js"; import { parseZodArgs } from "../parsers/parseZodArgs.js"; @@ -92,20 +93,22 @@ export async function runModeMigrate({ ); } - const options = await promptForBaseOptions(preset.base, { - existingOptions: { + const baseOptions = await promptForBaseOptions(preset.base, { + existing: { ...settings?.options, ...parseZodArgs(args, preset.base.options), }, offline, system, }); - if (prompts.isCancel(options)) { + if (baseOptions.cancelled) { + logRerunSuggestion(args, baseOptions.prompted); return { status: CLIStatus.Cancelled }; } const mergedSettings = applyArgsToSettings(args, preset, settings); if (mergedSettings instanceof Error) { + logRerunSuggestion(args, baseOptions.prompted); return { outro: mergedSettings.message, status: CLIStatus.Error }; } @@ -120,10 +123,11 @@ export async function runModeMigrate({ directory, mode: "migrate", offline, - options, + options: baseOptions.completed, }), ); if (creation instanceof Error) { + logRerunSuggestion(args, baseOptions.prompted); return { outro: `Leaving changes to the local directory on disk. 👋`, status: CLIStatus.Error, @@ -140,12 +144,14 @@ export async function runModeMigrate({ }, ); + logRerunSuggestion(args, baseOptions.prompted); return { outro: `Done. Enjoy your new repository! 💝`, status: CLIStatus.Success, }; } + logRerunSuggestion(args, baseOptions.prompted); return { outro: `Done. Enjoy your updated repository! 💝`, status: CLIStatus.Success, diff --git a/packages/create/src/cli/parsers/applyArgsToSettings.ts b/packages/create/src/cli/parsers/applyArgsToSettings.ts index 8b9561ae..003211e7 100644 --- a/packages/create/src/cli/parsers/applyArgsToSettings.ts +++ b/packages/create/src/cli/parsers/applyArgsToSettings.ts @@ -1,6 +1,7 @@ import { CreateConfigSettings } from "../../config/types.js"; import { AnyShape, InferredObject } from "../../options.js"; import { Preset } from "../../types/presets.js"; +import { slugify } from "../utils.js"; export function applyArgsToSettings( args: string[], @@ -14,7 +15,7 @@ export function applyArgsToSettings( .filter((block) => block.about?.name) .map((block) => [ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - block.about!.name!.toLowerCase().replaceAll(/\W+/gu, "-"), + slugify(block.about!.name!), block, ]), ); diff --git a/packages/create/src/cli/prompts/promptForBaseOptions.test.ts b/packages/create/src/cli/prompts/promptForBaseOptions.test.ts index 02f95470..697fe463 100644 --- a/packages/create/src/cli/prompts/promptForBaseOptions.test.ts +++ b/packages/create/src/cli/prompts/promptForBaseOptions.test.ts @@ -5,13 +5,10 @@ import { z } from "zod"; import { createBase } from "../../creators/createBase.js"; import { promptForBaseOptions } from "./promptForBaseOptions.js"; -const mockCancel = Symbol(""); -const mockIsCancel = (value: unknown) => value === mockCancel; +const mockCancel = Symbol("cancel"); vi.mock("@clack/prompts", () => ({ - get isCancel() { - return mockIsCancel; - }, + isCancel: (value: unknown) => value === mockCancel, })); const mockPromptForSchema = vi.fn(); @@ -53,14 +50,18 @@ describe("promptForBaseOptions", () => { }); const options = await promptForBaseOptions(base, { - existingOptions: { first: 1 }, + existing: { first: 1 }, system, }); expect(options).toEqual({ - directory: system.directory, - first: 1, - second: 1, + cancelled: false, + completed: { + directory: system.directory, + first: 1, + second: 1, + }, + prompted: {}, }); }); @@ -73,14 +74,17 @@ describe("promptForBaseOptions", () => { }); const options = await promptForBaseOptions(base, { - existingOptions: { first: 1 }, + existing: { first: 1 }, system, }); expect(options).toEqual({ - directory: system.directory, - first: 1, - second: undefined, + cancelled: false, + completed: { + directory: system.directory, + first: 1, + }, + prompted: {}, }); }); @@ -97,11 +101,14 @@ describe("promptForBaseOptions", () => { mockPromptForSchema.mockResolvedValueOnce(mockCancel); const options = await promptForBaseOptions(base, { - existingOptions: { first: 1 }, + existing: { first: 1 }, system, }); - expect(options).toEqual(mockCancel); + expect(options).toEqual({ + cancelled: true, + prompted: {}, + }); expect(mockPromptForSchema).toHaveBeenCalledWith("second", zSecond, -1); }); @@ -109,14 +116,20 @@ describe("promptForBaseOptions", () => { mockPromptForSchema.mockResolvedValueOnce(2); const options = await promptForBaseOptions(base, { - existingOptions: { first: 1 }, + existing: { first: 1 }, system, }); expect(options).toEqual({ - directory: system.directory, - first: 1, - second: 2, + cancelled: false, + completed: { + directory: system.directory, + first: 1, + second: 2, + }, + prompted: { + second: 2, + }, }); expect(mockPromptForSchema).toHaveBeenCalledWith("second", zSecond, -1); }); diff --git a/packages/create/src/cli/prompts/promptForBaseOptions.ts b/packages/create/src/cli/prompts/promptForBaseOptions.ts index 9ba47862..66934180 100644 --- a/packages/create/src/cli/prompts/promptForBaseOptions.ts +++ b/packages/create/src/cli/prompts/promptForBaseOptions.ts @@ -7,8 +7,23 @@ import { SystemContext } from "../../types/system.js"; import { getSchemaDefaultValue } from "../../utils/getSchemaDefaultValue.js"; import { promptForSchema } from "./promptForSchema.js"; +export type PromptedBaseOptions = + | PromptedBaseOptionsCancelled + | PromptedBaseOptionsProduced; + +export interface PromptedBaseOptionsCancelled { + cancelled: true; + prompted: Partial; +} + +export interface PromptedBaseOptionsProduced { + cancelled: false; + completed: Options; + prompted: Partial; +} + export interface PromptForBaseOptionsSettings { - existingOptions: Partial>; + existing: Partial>; offline?: boolean; system: SystemContext; } @@ -17,39 +32,45 @@ export async function promptForBaseOptions< OptionsShape extends AnyShape = AnyShape, >( base: Base, - { - existingOptions, - offline, - system, - }: PromptForBaseOptionsSettings, -) { + { existing, offline, system }: PromptForBaseOptionsSettings, +): Promise>> { + type Options = InferredObject; + const { directory } = system; - const options: InferredObject = { + const completed: InferredObject = { directory, - ...existingOptions, + ...existing, ...(await produceBase(base, { ...system, offline, - options: { ...existingOptions, directory }, + options: { ...existing, directory }, })), }; + const prompted: Partial = {}; for (const [key, schema] of Object.entries(base.options)) { const defaultValue = getSchemaDefaultValue(schema); if ( (schema.isOptional() && defaultValue === undefined) || - options[key] !== undefined + completed[key] !== undefined ) { continue; } - const prompted = await promptForSchema(key, schema, defaultValue); - if (prompts.isCancel(prompted)) { - return prompted; + const produced = await promptForSchema(key, schema, defaultValue); + if (prompts.isCancel(produced)) { + return { cancelled: true, prompted }; } - options[key] = prompted; + (prompted as typeof completed)[key] = produced; } - return options; + return { + cancelled: false, + completed: { + ...completed, + ...prompted, + } as Options, + prompted, + }; } diff --git a/packages/create/src/cli/prompts/promptForDirectory.test.ts b/packages/create/src/cli/prompts/promptForDirectory.test.ts index f4993b37..9938756f 100644 --- a/packages/create/src/cli/prompts/promptForDirectory.test.ts +++ b/packages/create/src/cli/prompts/promptForDirectory.test.ts @@ -3,15 +3,12 @@ import { describe, expect, it, vi } from "vitest"; import { createBase } from "../../creators/createBase.js"; import { promptForDirectory } from "./promptForDirectory.js"; -const mockCancel = Symbol(""); -const mockIsCancel = (value: unknown) => value === mockCancel; +const mockCancel = Symbol("cancel"); const mockText = vi.fn(); const mockWarn = vi.fn(); vi.mock("@clack/prompts", () => ({ - get isCancel() { - return mockIsCancel; - }, + isCancel: (value: unknown) => value === mockCancel, get log() { return { warn: mockWarn, diff --git a/packages/create/src/cli/prompts/promptForDirectory.ts b/packages/create/src/cli/prompts/promptForDirectory.ts index 5967b32d..e3d51ccf 100644 --- a/packages/create/src/cli/prompts/promptForDirectory.ts +++ b/packages/create/src/cli/prompts/promptForDirectory.ts @@ -2,6 +2,7 @@ import * as prompts from "@clack/prompts"; import * as fs from "node:fs/promises"; import { Template } from "../../types/templates.js"; +import { slugify } from "../utils.js"; import { validateNewDirectory } from "./validators.js"; export interface PromptForDirectorySettings { @@ -25,9 +26,7 @@ export async function promptForDirectory({ } const directory = await prompts.text({ - initialValue: - template.about?.name && - `my-${template.about.name.toLowerCase().replaceAll(" ", "-")}`, + initialValue: template.about?.name && `my-${slugify(template.about.name)}`, message: "What will the directory and name of the repository be? (--directory)", validate: validateNewDirectory, diff --git a/packages/create/src/cli/utils.ts b/packages/create/src/cli/utils.ts index 48d41a84..1ffdc070 100644 --- a/packages/create/src/cli/utils.ts +++ b/packages/create/src/cli/utils.ts @@ -5,3 +5,7 @@ export function isLocalPath(from: string) { export function makeRelative(item: string) { return item.startsWith(".") ? item : `./${item}`; } + +export function slugify(text: string) { + return text.toLowerCase().replaceAll(/\W+/gu, "-"); +}