From 224268cb84dac4d2f6c4f2aebf152767979318e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Mon, 6 Jan 2025 12:22:03 -0500 Subject: [PATCH] feat: fleshed out --help with template options (#117) * test: fill in missing tryImportConfig tests The one root-level red line in coverage reports was bugging me. * feat: fleshed out --help with template options * Fix up linting and tests * Deduplicated spinner logic into tryImportTemplate * witho --- .../create/src/cli/findPositionalFrom.test.ts | 19 +--- packages/create/src/cli/findPositionalFrom.ts | 7 +- .../tryImportAndInstallIfNecessary.test.ts | 25 ----- .../tryImportAndInstallIfNecessary.ts | 7 -- .../cli/importers/tryImportTemplate.test.ts | 58 ++++++++++ .../src/cli/importers/tryImportTemplate.ts | 30 +++++ .../importers/tryImportTemplatePreset.test.ts | 33 +++--- .../cli/importers/tryImportTemplatePreset.ts | 11 +- .../cli/initialize/runModeInitialize.test.ts | 67 ++++++++--- .../src/cli/initialize/runModeInitialize.ts | 38 ++----- packages/create/src/cli/loggers/formatFlag.ts | 11 ++ .../src/cli/loggers/logHelpOptions.test.ts | 71 ++++++++++++ .../create/src/cli/loggers/logHelpOptions.ts | 32 ++++++ .../src/cli/loggers/logHelpText.test.ts | 47 ++++++++ .../create/src/cli/loggers/logHelpText.ts | 83 ++++++++++++-- .../cli/loggers/logInitializeHelpText.test.ts | 102 +++++++++++++++++ .../src/cli/loggers/logInitializeHelpText.ts | 51 +++++++++ .../cli/loggers/logMigrateHelpText.test.ts | 104 ++++++++++++++++++ .../src/cli/loggers/logMigrateHelpText.ts | 38 +++++++ .../cli/loggers/logSchemasHelpOptions.test.ts | 70 ++++++++++++ .../src/cli/loggers/logSchemasHelpOptions.ts | 21 ++++ .../src/cli/loggers/logStartText.test.ts | 48 ++++++++ .../create/src/cli/loggers/logStartText.ts | 24 ++++ packages/create/src/cli/messages.ts | 4 + .../cli/migrate/parseMigrationSource.test.ts | 4 +- .../src/cli/migrate/parseMigrationSource.ts | 5 +- .../src/cli/migrate/runModeMigrate.test.ts | 92 ++++++++++------ .../create/src/cli/migrate/runModeMigrate.ts | 26 ++--- .../src/cli/prompts/promptForBaseOptions.ts | 6 +- .../cli/prompts/promptForDirectory.test.ts | 10 +- .../src/cli/prompts/promptForPreset.test.ts | 3 +- .../create/src/cli/readProductionSettings.ts | 4 +- packages/create/src/cli/runCli.test.ts | 24 +--- packages/create/src/cli/runCli.ts | 25 +---- packages/create/src/cli/status.ts | 10 +- .../create/src/cli/tryImportWithPredicate.ts | 6 +- packages/create/src/cli/types.ts | 2 - .../create/src/config/tryImportConfig.test.ts | 80 ++++++++++++++ packages/create/src/creators/createBase.ts | 11 ++ .../src/creators/createTemplate.test.ts | 35 ------ .../create/src/creators/createTemplate.ts | 8 -- packages/create/src/index.ts | 1 - .../create/src/predicates/isTemplate.test.ts | 8 +- packages/create/src/types/bases.ts | 6 + packages/create/src/types/templates.ts | 6 +- .../create/src/utils/getSchemaDefaultValue.ts | 5 + .../create/src/utils/getSchemaTypeName.ts | 45 ++++++++ packages/create/src/utils/tryCatch.ts | 15 +++ packages/create/src/utils/tryCatchAsync.ts | 7 -- packages/site/src/content/docs/cli.mdx | 2 +- .../src/content/docs/engine/apis/creators.mdx | 19 ++-- 51 files changed, 1162 insertions(+), 304 deletions(-) create mode 100644 packages/create/src/cli/importers/tryImportTemplate.test.ts create mode 100644 packages/create/src/cli/importers/tryImportTemplate.ts create mode 100644 packages/create/src/cli/loggers/formatFlag.ts create mode 100644 packages/create/src/cli/loggers/logHelpOptions.test.ts create mode 100644 packages/create/src/cli/loggers/logHelpOptions.ts create mode 100644 packages/create/src/cli/loggers/logHelpText.test.ts create mode 100644 packages/create/src/cli/loggers/logInitializeHelpText.test.ts create mode 100644 packages/create/src/cli/loggers/logInitializeHelpText.ts create mode 100644 packages/create/src/cli/loggers/logMigrateHelpText.test.ts create mode 100644 packages/create/src/cli/loggers/logMigrateHelpText.ts create mode 100644 packages/create/src/cli/loggers/logSchemasHelpOptions.test.ts create mode 100644 packages/create/src/cli/loggers/logSchemasHelpOptions.ts create mode 100644 packages/create/src/cli/loggers/logStartText.test.ts create mode 100644 packages/create/src/cli/loggers/logStartText.ts create mode 100644 packages/create/src/cli/messages.ts create mode 100644 packages/create/src/config/tryImportConfig.test.ts delete mode 100644 packages/create/src/creators/createTemplate.test.ts delete mode 100644 packages/create/src/creators/createTemplate.ts create mode 100644 packages/create/src/utils/getSchemaDefaultValue.ts create mode 100644 packages/create/src/utils/getSchemaTypeName.ts create mode 100644 packages/create/src/utils/tryCatch.ts delete mode 100644 packages/create/src/utils/tryCatchAsync.ts diff --git a/packages/create/src/cli/findPositionalFrom.test.ts b/packages/create/src/cli/findPositionalFrom.test.ts index 2a9c8615..cdc3f514 100644 --- a/packages/create/src/cli/findPositionalFrom.test.ts +++ b/packages/create/src/cli/findPositionalFrom.test.ts @@ -4,19 +4,12 @@ import { findPositionalFrom } from "./findPositionalFrom.js"; describe("findPositionalFrom", () => { test.each([ - [["my-app"], "create-my-app"], - [["create-my-app"], "create-my-app"], - [["/bin/node", "create"], undefined], - [["/bin/node", "create", "create-my-app"], "create-my-app"], - [["/bin/node", "create", "my-app"], "create-my-app"], - [["/bin/node", "create", "/create-my-app"], "/create-my-app"], - [["/bin/node", "create", "/my-app"], "/my-app"], - [["/bin/node", "create", "./create-my-app"], "./create-my-app"], - [["/bin/node", "create", "./my-app"], "./my-app"], - // npx create /repos/create-my-app - [["/repos/create-my-app"], "/repos/create-my-app"], - // npx create /repos/create-my-app --preset common - [["/repos/create-my-app", "--preset", "common"], "/repos/create-my-app"], + [[], undefined], + [["./create-typescript-app"], "./create-typescript-app"], + [["./create-typescript-app", "other"], "./create-typescript-app"], + [["./typescript-app"], "./typescript-app"], + [["create-typescript-app"], "create-typescript-app"], + [["typescript-app"], "create-typescript-app"], ])("%j", (input, expected) => { expect(findPositionalFrom(input)).toBe(expected); }); diff --git a/packages/create/src/cli/findPositionalFrom.ts b/packages/create/src/cli/findPositionalFrom.ts index 291e6ec0..a013fd07 100644 --- a/packages/create/src/cli/findPositionalFrom.ts +++ b/packages/create/src/cli/findPositionalFrom.ts @@ -1,12 +1,7 @@ import { isLocalPath } from "./utils.js"; export function findPositionalFrom(positionals: string[]) { - const indexOfDashes = positionals.findIndex((positional) => - positional.startsWith("--"), - ); - const potentials = - indexOfDashes === -1 ? positionals : positionals.slice(0, indexOfDashes); - const from = potentials.at(potentials.length >= 2 ? 2 : -1); + const from = positionals.find((positional) => !positional.startsWith("-")); return from && !isLocalPath(from) && !from.startsWith("create-") ? `create-${from}` diff --git a/packages/create/src/cli/importers/tryImportAndInstallIfNecessary.test.ts b/packages/create/src/cli/importers/tryImportAndInstallIfNecessary.test.ts index 91d5b841..36d991b1 100644 --- a/packages/create/src/cli/importers/tryImportAndInstallIfNecessary.test.ts +++ b/packages/create/src/cli/importers/tryImportAndInstallIfNecessary.test.ts @@ -2,15 +2,6 @@ import { describe, expect, it, vi } from "vitest"; import { tryImportAndInstallIfNecessary } from "./tryImportAndInstallIfNecessary.js"; -const mockSpinner = { - start: vi.fn(), - stop: vi.fn(), -}; - -vi.mock("@clack/prompts", () => ({ - spinner: () => mockSpinner, -})); - const mockImportLocalOrNpx = vi.fn(); vi.mock("import-local-or-npx", () => ({ @@ -33,12 +24,6 @@ describe("tryImportAndInstallIfNecessary", () => { const actual = await tryImportAndInstallIfNecessary("../create-my-app"); expect(actual).toBe(errorLocal); - expect(mockSpinner.start.mock.calls).toEqual([ - ["Retrieving ../create-my-app"], - ]); - expect(mockSpinner.stop.mock.calls).toEqual([ - ["Could not retrieve ../create-my-app"], - ]); }); it("returns the npx error when importLocalOrNpx resolves with a failure for a package name", async () => { @@ -51,12 +36,6 @@ describe("tryImportAndInstallIfNecessary", () => { const actual = await tryImportAndInstallIfNecessary("create-my-app"); expect(actual).toBe(errorNpx); - expect(mockSpinner.start.mock.calls).toEqual([ - ["Retrieving create-my-app"], - ]); - expect(mockSpinner.stop.mock.calls).toEqual([ - ["Could not retrieve create-my-app"], - ]); }); it("returns the package when importLocalOrNpx resolves a package", async () => { @@ -70,9 +49,5 @@ describe("tryImportAndInstallIfNecessary", () => { const actual = await tryImportAndInstallIfNecessary("create-my-app"); expect(actual).toBe(resolved); - expect(mockSpinner.start.mock.calls).toEqual([ - ["Retrieving create-my-app"], - ]); - expect(mockSpinner.stop.mock.calls).toEqual([["Retrieved create-my-app"]]); }); }); diff --git a/packages/create/src/cli/importers/tryImportAndInstallIfNecessary.ts b/packages/create/src/cli/importers/tryImportAndInstallIfNecessary.ts index 0777d236..95dd0e7a 100644 --- a/packages/create/src/cli/importers/tryImportAndInstallIfNecessary.ts +++ b/packages/create/src/cli/importers/tryImportAndInstallIfNecessary.ts @@ -1,4 +1,3 @@ -import * as prompts from "@clack/prompts"; import { importLocalOrNpx } from "import-local-or-npx"; import { isLocalPath } from "../utils.js"; @@ -6,9 +5,6 @@ import { isLocalPath } from "../utils.js"; export async function tryImportAndInstallIfNecessary( from: string, ): Promise { - const spinner = prompts.spinner(); - spinner.start(`Retrieving ${from}`); - const imported = await importLocalOrNpx(from, { // We ignore logs because we don't want to clutter CLI output // eslint-disable-next-line @typescript-eslint/no-empty-function @@ -16,11 +12,8 @@ export async function tryImportAndInstallIfNecessary( }); if (imported.kind === "failure") { - spinner.stop(`Could not retrieve ${from}`); return isLocalPath(from) ? imported.local : imported.npx; } - spinner.stop(`Retrieved ${from}`); - return imported.resolved; } diff --git a/packages/create/src/cli/importers/tryImportTemplate.test.ts b/packages/create/src/cli/importers/tryImportTemplate.test.ts new file mode 100644 index 00000000..a7340ac7 --- /dev/null +++ b/packages/create/src/cli/importers/tryImportTemplate.test.ts @@ -0,0 +1,58 @@ +import chalk from "chalk"; +import { describe, expect, it, vi } from "vitest"; + +import { tryImportTemplate } from "./tryImportTemplate.js"; + +const mockSpinner = { + start: vi.fn(), + stop: vi.fn(), +}; + +vi.mock("@clack/prompts", () => ({ + spinner: () => mockSpinner, +})); + +const mockTryImportWithPredicate = vi.fn(); + +vi.mock("../tryImportWithPredicate.js", () => ({ + get tryImportWithPredicate() { + return mockTryImportWithPredicate; + }, +})); + +describe("tryImportTemplate", () => { + it("returns the error when tryImportWithPredicate resolves with an error", async () => { + const error = new Error("Oh no!"); + + mockTryImportWithPredicate.mockResolvedValueOnce(error); + + const actual = await tryImportTemplate("create-my-app"); + + expect(actual).toEqual(error); + expect(mockSpinner.start.mock.calls).toEqual([ + [`Loading ${chalk.blue("create-my-app")}`], + ]); + expect(mockSpinner.stop.mock.calls).toEqual([ + [ + `Could not load ${chalk.blue("create-my-app")}: ${chalk.red(error.message)}`, + 1, + ], + ]); + }); + + it("returns the template when tryImportWithPredicate resolves with a preset", async () => { + const template = { isTemplate: true }; + + mockTryImportWithPredicate.mockResolvedValueOnce(template); + + const actual = await tryImportTemplate("create-my-app"); + + expect(actual).toEqual(template); + expect(mockSpinner.start.mock.calls).toEqual([ + [`Loading ${chalk.blue("create-my-app")}`], + ]); + expect(mockSpinner.stop.mock.calls).toEqual([ + [`Loaded ${chalk.blue("create-my-app")}`], + ]); + }); +}); diff --git a/packages/create/src/cli/importers/tryImportTemplate.ts b/packages/create/src/cli/importers/tryImportTemplate.ts new file mode 100644 index 00000000..2555b0fb --- /dev/null +++ b/packages/create/src/cli/importers/tryImportTemplate.ts @@ -0,0 +1,30 @@ +import * as prompts from "@clack/prompts"; +import chalk from "chalk"; + +import { isTemplate } from "../../predicates/isTemplate.js"; +import { tryImportWithPredicate } from "../tryImportWithPredicate.js"; +import { tryImportAndInstallIfNecessary } from "./tryImportAndInstallIfNecessary.js"; + +export async function tryImportTemplate(from: string) { + const spinner = prompts.spinner(); + spinner.start(`Loading ${chalk.blue(from)}`); + + const template = await tryImportWithPredicate( + tryImportAndInstallIfNecessary, + from, + isTemplate, + "Template", + ); + + if (template instanceof Error) { + spinner.stop( + `Could not load ${chalk.blue(from)}: ${chalk.red(template.message)}`, + 1, + ); + return template; + } + + spinner.stop(`Loaded ${chalk.blue(from)}`); + + return template; +} diff --git a/packages/create/src/cli/importers/tryImportTemplatePreset.test.ts b/packages/create/src/cli/importers/tryImportTemplatePreset.test.ts index d6a4f084..add813aa 100644 --- a/packages/create/src/cli/importers/tryImportTemplatePreset.test.ts +++ b/packages/create/src/cli/importers/tryImportTemplatePreset.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it, vi } from "vitest"; import { tryImportTemplatePreset } from "./tryImportTemplatePreset.js"; -const mockIsCancel = vi.fn(); +const mockCancel = Symbol(""); +const mockIsCancel = (value: unknown) => value === mockCancel; vi.mock("@clack/prompts", () => ({ get isCancel() { @@ -18,47 +19,43 @@ vi.mock("../prompts/promptForPreset.js", () => ({ }, })); -const mockTryImportWithPredicate = vi.fn(); +const mockTryImportTemplate = vi.fn(); -vi.mock("../tryImportWithPredicate.js", () => ({ - get tryImportWithPredicate() { - return mockTryImportWithPredicate; +vi.mock("./tryImportTemplate.js", () => ({ + get tryImportTemplate() { + return mockTryImportTemplate; }, })); describe("tryImportTemplatePreset", () => { - it("returns the error when tryImportWithPredicate resolves with an error", async () => { + it("returns the error when tryImportTemplate resolves with an error", async () => { const error = new Error("Oh no!"); - mockTryImportWithPredicate.mockResolvedValueOnce(error); + mockTryImportTemplate.mockResolvedValueOnce(error); - const actual = await tryImportTemplatePreset("my-app"); + const actual = await tryImportTemplatePreset("create-my-app"); expect(actual).toEqual(error); expect(mockPromptForPreset).not.toHaveBeenCalled(); }); it("returns the cancellation when promptForPreset is cancelled", async () => { - const preset = Symbol.for("cancel"); + mockTryImportTemplate.mockResolvedValueOnce({}); + mockPromptForPreset.mockResolvedValueOnce(mockCancel); - mockTryImportWithPredicate.mockResolvedValueOnce({}); - mockPromptForPreset.mockResolvedValueOnce(preset); - mockIsCancel.mockReturnValueOnce(true); - - const actual = await tryImportTemplatePreset("my-app"); + const actual = await tryImportTemplatePreset("create-my-app"); - expect(actual).toBe(preset); + expect(actual).toBe(mockCancel); }); it("returns the template and preset when promptForPreset resolves with a preset", async () => { const template = { isTemplate: true }; const preset = { isPreset: true }; - mockTryImportWithPredicate.mockResolvedValueOnce(template); + mockTryImportTemplate.mockResolvedValueOnce(template); mockPromptForPreset.mockResolvedValueOnce(preset); - mockIsCancel.mockReturnValueOnce(false); - const actual = await tryImportTemplatePreset("my-app"); + const actual = await tryImportTemplatePreset("create-my-app"); expect(actual).toEqual({ preset, template }); }); diff --git a/packages/create/src/cli/importers/tryImportTemplatePreset.ts b/packages/create/src/cli/importers/tryImportTemplatePreset.ts index f0eb5e21..6ebd6655 100644 --- a/packages/create/src/cli/importers/tryImportTemplatePreset.ts +++ b/packages/create/src/cli/importers/tryImportTemplatePreset.ts @@ -1,20 +1,13 @@ import * as prompts from "@clack/prompts"; -import { isTemplate } from "../../predicates/isTemplate.js"; import { promptForPreset } from "../prompts/promptForPreset.js"; -import { tryImportWithPredicate } from "../tryImportWithPredicate.js"; -import { tryImportAndInstallIfNecessary } from "./tryImportAndInstallIfNecessary.js"; +import { tryImportTemplate } from "./tryImportTemplate.js"; export async function tryImportTemplatePreset( from: string, requestedPreset?: string, ) { - const template = await tryImportWithPredicate( - tryImportAndInstallIfNecessary, - from, - isTemplate, - "Template", - ); + const template = await tryImportTemplate(from); if (template instanceof Error) { return template; } diff --git a/packages/create/src/cli/initialize/runModeInitialize.test.ts b/packages/create/src/cli/initialize/runModeInitialize.test.ts index 8424f4c1..eccf76e5 100644 --- a/packages/create/src/cli/initialize/runModeInitialize.test.ts +++ b/packages/create/src/cli/initialize/runModeInitialize.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, vi } from "vitest"; import { createBase } from "../../creators/createBase.js"; -import { createTemplate } from "../../creators/createTemplate.js"; import { ClackDisplay } from "../display/createClackDisplay.js"; +import { CLIMessage } from "../messages.js"; import { CLIStatus } from "../status.js"; import { runModeInitialize } from "./runModeInitialize.js"; @@ -22,6 +22,15 @@ vi.mock("@clack/prompts", () => ({ spinner: vi.fn(), })); +const mockHelpTextReturn = { outro: CLIMessage.Ok, status: CLIStatus.Success }; +const mockLogInitializeHelpText = vi.fn().mockResolvedValue(mockHelpTextReturn); + +vi.mock("../loggers/logInitializeHelpText.js", () => ({ + get logInitializeHelpText() { + return mockLogInitializeHelpText; + }, +})); + const mockRunPreset = vi.fn(); vi.mock("../../runners/runPreset.js", () => ({ @@ -108,35 +117,49 @@ const preset = base.createPreset({ blocks: [], }); -const template = createTemplate({ +const template = base.createTemplate({ presets: [], }); describe("runModeInitialize", () => { - it("returns an error when there is no from", async () => { + it("logs help text when from is undefined", async () => { const actual = await runModeInitialize({ args: ["node", "create"], display, }); - expect(actual).toEqual({ - outro: "Please specify a package to create from.", - status: CLIStatus.Error, + expect(mockLogInitializeHelpText).toHaveBeenCalled(); + + expect(actual).toBe(mockHelpTextReturn); + }); + + it("logs help text when from help is true", async () => { + const actual = await runModeInitialize({ + args: ["node", "create"], + display, + from: "create-typescript-app", + help: true, }); + + expect(mockLogInitializeHelpText).toHaveBeenCalledWith( + "create-typescript-app", + true, + ); + + expect(actual).toBe(mockHelpTextReturn); }); it("returns the error when importing tryImportTemplatePreset resolves with an error", async () => { - const message = "Oh no!"; - - mockTryImportTemplatePreset.mockResolvedValueOnce(new Error(message)); + mockTryImportTemplatePreset.mockResolvedValueOnce(new Error("Oh no!")); const actual = await runModeInitialize({ args: ["node", "create", "my-app"], display, + from: "create-my-app", }); expect(actual).toEqual({ - outro: message, + outro: CLIMessage.Exiting, status: CLIStatus.Error, }); }); @@ -147,6 +170,7 @@ describe("runModeInitialize", () => { const actual = await runModeInitialize({ args: ["node", "create", "my-app"], display, + from: "create-my-app", }); expect(actual).toEqual({ status: CLIStatus.Cancelled }); @@ -159,6 +183,7 @@ describe("runModeInitialize", () => { const actual = await runModeInitialize({ args: ["node", "create", "my-app"], display, + from: "create-my-app", }); expect(actual).toEqual({ status: CLIStatus.Cancelled }); @@ -172,12 +197,13 @@ describe("runModeInitialize", () => { const actual = await runModeInitialize({ args: ["node", "create", "my-app"], display, + from: "create-my-app", }); expect(actual).toEqual({ status: CLIStatus.Cancelled }); }); - it("returns the error when applyArgsToSettings returns an error", async () => { + it("returns an error when applyArgsToSettings returns an error", async () => { const message = "Oh no!"; mockTryImportTemplatePreset.mockResolvedValueOnce({ preset, template }); @@ -191,9 +217,13 @@ describe("runModeInitialize", () => { const actual = await runModeInitialize({ args: ["node", "create", "my-app"], display, + from: "create-my-app", }); - expect(actual).toEqual({ outro: message, status: CLIStatus.Error }); + expect(actual).toEqual({ + outro: message, + status: CLIStatus.Error, + }); }); it("runs createRepositoryOnGitHub when offline is falsy", async () => { @@ -210,13 +240,17 @@ describe("runModeInitialize", () => { suggestions, }); - await runModeInitialize({ args: ["node", "create", "my-app"], display }); + await runModeInitialize({ + args: ["node", "create", "my-app"], + display, + from: "create-my-app", + }); expect(mockCreateRepositoryOnGitHub).toHaveBeenCalled(); expect(mockMessage.mock.calls).toMatchInlineSnapshot(` [ [ - "Running with mode --create for a new repository using the template: + "Running with mode --initialize using the template: create-my-app", ], [ @@ -247,6 +281,7 @@ describe("runModeInitialize", () => { await runModeInitialize({ args: ["node", "create", "my-app"], display, + from: "create-my-app", offline: true, }); @@ -254,7 +289,7 @@ describe("runModeInitialize", () => { expect(mockMessage.mock.calls).toMatchInlineSnapshot(` [ [ - "Running with mode --create for a new repository using the template: + "Running with mode --initialize using the template: create-my-app", ], [ @@ -285,6 +320,7 @@ describe("runModeInitialize", () => { const actual = await runModeInitialize({ args: ["node", "create", "my-app"], display, + from: "create-my-app", }); expect(actual).toEqual({ @@ -312,6 +348,7 @@ describe("runModeInitialize", () => { const actual = await runModeInitialize({ args: ["node", "create", "my-app"], display, + from: "create-my-app", }); expect(actual).toEqual({ diff --git a/packages/create/src/cli/initialize/runModeInitialize.ts b/packages/create/src/cli/initialize/runModeInitialize.ts index 914dd845..bf2cec56 100644 --- a/packages/create/src/cli/initialize/runModeInitialize.ts +++ b/packages/create/src/cli/initialize/runModeInitialize.ts @@ -7,8 +7,10 @@ import { clearLocalGitTags } from "../clearLocalGitTags.js"; import { createInitialCommit } from "../createInitialCommit.js"; import { ClackDisplay } from "../display/createClackDisplay.js"; import { runSpinnerTask } from "../display/runSpinnerTask.js"; -import { findPositionalFrom } from "../findPositionalFrom.js"; import { tryImportTemplatePreset } from "../importers/tryImportTemplatePreset.js"; +import { logInitializeHelpText } from "../loggers/logInitializeHelpText.js"; +import { logStartText } from "../loggers/logStartText.js"; +import { CLIMessage } from "../messages.js"; import { applyArgsToSettings } from "../parsers/applyArgsToSettings.js"; import { parseZodArgs } from "../parsers/parseZodArgs.js"; import { promptForBaseOptions } from "../prompts/promptForBaseOptions.js"; @@ -25,6 +27,7 @@ export interface RunModeInitializeSettings { directory?: string; display: ClackDisplay; from?: string; + help?: boolean; offline?: boolean; owner?: string; preset?: string; @@ -36,41 +39,26 @@ export async function runModeInitialize({ repository, directory: requestedDirectory = repository, display, - from = findPositionalFrom(args), + from, + help, offline, preset: requestedPreset, }: RunModeInitializeSettings): Promise { - if (!from) { - return { - outro: "Please specify a package to create from.", - status: CLIStatus.Error, - }; + if (!from || help) { + return await logInitializeHelpText(from, help); } - prompts.log.message( - [ - "Running with mode --create for a new repository using the template:", - ` ${chalk.green(from)}`, - ].join("\n"), - ); - - if (offline) { - prompts.log.message( - "--offline enabled. You'll need to git push any changes manually.", - ); - } + logStartText("initialize", from, "template", offline); const loaded = await tryImportTemplatePreset(from, requestedPreset); if (loaded instanceof Error) { return { - outro: loaded.message, + outro: chalk.red(CLIMessage.Exiting), status: CLIStatus.Error, }; } if (prompts.isCancel(loaded)) { - return { - status: CLIStatus.Cancelled, - }; + return { status: CLIStatus.Cancelled }; } const { preset, template } = loaded; @@ -80,9 +68,7 @@ export async function runModeInitialize({ template, }); if (prompts.isCancel(directory)) { - return { - status: CLIStatus.Cancelled, - }; + return { status: CLIStatus.Cancelled }; } const system = await createSystemContextWithAuth({ diff --git a/packages/create/src/cli/loggers/formatFlag.ts b/packages/create/src/cli/loggers/formatFlag.ts new file mode 100644 index 00000000..da98ca69 --- /dev/null +++ b/packages/create/src/cli/loggers/formatFlag.ts @@ -0,0 +1,11 @@ +import chalk from "chalk"; + +export function formatFlag(flag: string, type: string) { + return [ + chalk.green("--"), + chalk.bold.green(flag), + " ", + chalk.green(`(${type})`), + chalk.blue(": "), + ].join(""); +} diff --git a/packages/create/src/cli/loggers/logHelpOptions.test.ts b/packages/create/src/cli/loggers/logHelpOptions.test.ts new file mode 100644 index 00000000..c508a71a --- /dev/null +++ b/packages/create/src/cli/loggers/logHelpOptions.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test, vi } from "vitest"; + +import { logHelpOptions } from "./logHelpOptions.js"; + +const mockMessage = vi.fn(); + +vi.mock("@clack/prompts", () => ({ + log: { + get message() { + return mockMessage; + }, + }, +})); + +describe("logHelpOptions", () => { + test("output without examples or text", () => { + logHelpOptions("category", [ + { + flag: "--abc", + type: "number", + }, + { + flag: "--def", + type: "string", + }, + ]); + + expect(mockMessage.mock.calls).toMatchInlineSnapshot(` + [ + [ + "category options: + + ----abc (number): + ----def (string): ", + ], + ] + `); + }); + + test("output without examples and text", () => { + logHelpOptions("category", [ + { + examples: ["a", "b"], + flag: "--abc", + type: "number", + }, + { + examples: ["c", "d"], + flag: "--def", + type: "string", + }, + ]); + + expect(mockMessage.mock.calls).toMatchInlineSnapshot(` + [ + [ + "category options: + + ----abc (number): + npx create a + npx create b + + ----def (string): + npx create c + npx create d + ", + ], + ] + `); + }); +}); diff --git a/packages/create/src/cli/loggers/logHelpOptions.ts b/packages/create/src/cli/loggers/logHelpOptions.ts new file mode 100644 index 00000000..7cf37b61 --- /dev/null +++ b/packages/create/src/cli/loggers/logHelpOptions.ts @@ -0,0 +1,32 @@ +import * as prompts from "@clack/prompts"; +import chalk from "chalk"; + +import { formatFlag } from "./formatFlag.js"; + +export interface HelpOption { + examples?: string[]; + flag: string; + text?: string; + type: string; +} + +export function logHelpOptions(category: string, options: HelpOption[]) { + prompts.log.message( + [ + `${chalk.bgGreenBright.black(category)} options:`, + "", + ...options.map((option) => { + const text = option.text ? chalk.blue(option.text) : ""; + return [ + ` ${formatFlag(option.flag, option.type)}${chalk.blue(text)}`, + option.examples?.length && + `\n${option.examples + .map((example) => chalk.blue(` npx create ${example}\n`)) + .join("")}`, + ] + .filter(Boolean) + .join(""); + }), + ].join("\n"), + ); +} diff --git a/packages/create/src/cli/loggers/logHelpText.test.ts b/packages/create/src/cli/loggers/logHelpText.test.ts new file mode 100644 index 00000000..b6da8f76 --- /dev/null +++ b/packages/create/src/cli/loggers/logHelpText.test.ts @@ -0,0 +1,47 @@ +import chalk from "chalk"; +import { describe, expect, it, vi } from "vitest"; + +import { logHelpText } from "./logHelpText.js"; + +const mockError = vi.fn(); +const mockInfo = vi.fn(); +const mockMessage = vi.fn(); + +vi.mock("@clack/prompts", () => ({ + get log() { + return { + error: mockError, + info: mockInfo, + message: mockMessage, + }; + }, +})); + +describe("logHelpText", () => { + it("logs the error when source is an error", () => { + const message = "Oh no!"; + + logHelpText("migrate", new Error(message)); + + expect(mockError).toHaveBeenCalledWith(message); + expect(mockInfo).not.toHaveBeenCalled(); + }); + + it("logs an info message when source is an object", () => { + const descriptor = "place"; + const type = "thing"; + + logHelpText("migrate", { descriptor, type }); + + expect(mockError).not.toHaveBeenCalled(); + expect(mockInfo).toHaveBeenCalledWith( + [ + chalk.green(`--mode migrate`), + ` detected with the `, + chalk.blue(descriptor), + " ", + type, + ].join(""), + ); + }); +}); diff --git a/packages/create/src/cli/loggers/logHelpText.ts b/packages/create/src/cli/loggers/logHelpText.ts index 3d2c9586..4d71ef0b 100644 --- a/packages/create/src/cli/loggers/logHelpText.ts +++ b/packages/create/src/cli/loggers/logHelpText.ts @@ -1,9 +1,76 @@ -import { Logger } from "../types.js"; - -export function logHelpText(logger: Logger) { - logger.log("Thanks for trying the create CLI!"); - logger.log( - "A full --help display isn't yet available, as the CLI is still being worked on.", - ); - logger.log("See http://create.bingo in the meantime."); +import * as prompts from "@clack/prompts"; +import chalk from "chalk"; + +import { logHelpOptions } from "./logHelpOptions.js"; + +export interface HelpSource { + descriptor: string; + type: string; +} + +export function logHelpText(mode: string, source?: Error | HelpSource) { + logHelpOptions("create", [ + { + examples: ["typescript-app --directory my-fancy-project"], + flag: "directory", + text: "What local directory path to run under", + type: "string", + }, + { + examples: [ + "typescript-app --from @example/my-fancy-template", + "typescript-app --from ../create-typescript-app", + ], + flag: "from", + text: "An explicit package or path to import a template from.", + type: "string", + }, + { + examples: ["--help"], + flag: "help", + text: "Prints help text.", + type: "string", + }, + { + examples: [ + "typescript-app --mode initialize", + "typescript-app --mode migrate", + ], + flag: "mode", + text: "Which mode to run in.", + type: '"initialize" | "migrate"', + }, + { + examples: ["typescript-app --offline"], + flag: "offline", + text: 'Whether to run in an "offline" mode that skips network requests.', + type: "boolean", + }, + { + examples: ["typescript-app --preset common"], + flag: "preset", + text: "Which preset to use from the template.", + type: "string", + }, + { + examples: ["--version"], + flag: "version", + text: "Prints the create package version.", + type: "boolean", + }, + ]); + + if (source instanceof Error) { + prompts.log.error(source.message); + } else if (source) { + prompts.log.info( + [ + chalk.green(`--mode ${mode}`), + ` detected with the `, + chalk.blue(source.descriptor), + " ", + source.type, + ].join(""), + ); + } } diff --git a/packages/create/src/cli/loggers/logInitializeHelpText.test.ts b/packages/create/src/cli/loggers/logInitializeHelpText.test.ts new file mode 100644 index 00000000..762dc190 --- /dev/null +++ b/packages/create/src/cli/loggers/logInitializeHelpText.test.ts @@ -0,0 +1,102 @@ +import chalk from "chalk"; +import { describe, expect, it, vi } from "vitest"; + +import { createBase } from "../../creators/createBase.js"; +import { CLIMessage } from "../messages.js"; +import { CLIStatus } from "../status.js"; +import { logInitializeHelpText } from "./logInitializeHelpText.js"; + +const mockMessage = vi.fn(); + +vi.mock("@clack/prompts", () => ({ + get log() { + return { message: mockMessage }; + }, +})); + +const mockTryImportTemplate = vi.fn(); + +vi.mock("../importers/tryImportTemplate.js", () => ({ + get tryImportTemplate() { + return mockTryImportTemplate; + }, +})); + +const mockLogHelpText = vi.fn(); + +vi.mock("./logHelpText.js", () => ({ + get logHelpText() { + return mockLogHelpText; + }, +})); + +const mockLogSchemasHelpOptions = vi.fn(); + +vi.mock("./logSchemasHelpOptions.js", () => ({ + get logSchemasHelpOptions() { + return mockLogSchemasHelpOptions; + }, +})); + +describe("logInitializeHelpText", () => { + it("logs a straightforward message without loading when from is undefined and help is falsy", async () => { + const actual = await logInitializeHelpText(undefined, false); + + expect(actual).toEqual({ + outro: CLIMessage.Ok, + status: CLIStatus.Success, + }); + expect(mockMessage).toHaveBeenCalledWith( + [ + `Try it out with:`, + ` ${chalk.green("npx create typescript-app")}`, + ].join("\n"), + ); + expect(mockLogHelpText).not.toHaveBeenCalled(); + }); + + it("logs general help text without loading when from is undefined and help is true", async () => { + const actual = await logInitializeHelpText(undefined, true); + + expect(actual).toEqual({ + outro: CLIMessage.Ok, + status: CLIStatus.Success, + }); + expect(mockLogHelpText).toHaveBeenCalledWith("initialize"); + }); + + it("returns the error when help is falsy and loading the template is an error", async () => { + const message = "Oh no!"; + const from = "create-my-app"; + + mockTryImportTemplate.mockResolvedValueOnce(new Error(message)); + + const actual = await logInitializeHelpText(from, false); + + expect(actual).toEqual({ + outro: chalk.red(CLIMessage.Exiting), + status: CLIStatus.Error, + }); + }); + + it("returns a success when help is falsy and loading the template succeeds", async () => { + const from = "create-my-app"; + const base = createBase({ options: {} }); + const template = base.createTemplate({ + presets: [], + }); + + mockTryImportTemplate.mockResolvedValueOnce(template); + + const actual = await logInitializeHelpText(from, false); + + expect(actual).toEqual({ + outro: CLIMessage.Ok, + status: CLIStatus.Success, + }); + expect(mockLogSchemasHelpOptions).toHaveBeenCalledWith( + from, + template.options, + ); + }); +}); diff --git a/packages/create/src/cli/loggers/logInitializeHelpText.ts b/packages/create/src/cli/loggers/logInitializeHelpText.ts new file mode 100644 index 00000000..47b1d0a6 --- /dev/null +++ b/packages/create/src/cli/loggers/logInitializeHelpText.ts @@ -0,0 +1,51 @@ +import * as prompts from "@clack/prompts"; +import chalk from "chalk"; + +import { tryImportTemplate } from "../importers/tryImportTemplate.js"; +import { CLIMessage } from "../messages.js"; +import { CLIStatus } from "../status.js"; +import { logHelpText } from "./logHelpText.js"; +import { logSchemasHelpOptions } from "./logSchemasHelpOptions.js"; + +export async function logInitializeHelpText( + from: string | undefined, + help: boolean | undefined, +) { + if (!from) { + if (help) { + logHelpText("initialize"); + } else { + prompts.log.message( + [ + `Try it out with:`, + ` ${chalk.green("npx create typescript-app")}`, + ].join("\n"), + ); + } + + return { + outro: CLIMessage.Ok, + status: CLIStatus.Success, + }; + } + + logHelpText("initialize", { + descriptor: from, + type: "template", + }); + + const template = await tryImportTemplate(from); + if (template instanceof Error) { + return { + outro: chalk.red(CLIMessage.Exiting), + status: CLIStatus.Error, + }; + } + + logSchemasHelpOptions(from, template.options); + + return { + outro: CLIMessage.Ok, + status: CLIStatus.Success, + }; +} diff --git a/packages/create/src/cli/loggers/logMigrateHelpText.test.ts b/packages/create/src/cli/loggers/logMigrateHelpText.test.ts new file mode 100644 index 00000000..921fb08b --- /dev/null +++ b/packages/create/src/cli/loggers/logMigrateHelpText.test.ts @@ -0,0 +1,104 @@ +import chalk from "chalk"; +import { describe, expect, it, vi } from "vitest"; + +import { createBase } from "../../creators/createBase.js"; +import { CLIMessage } from "../messages.js"; +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 mockSpinner = { + start: vi.fn(), + stop: vi.fn(), +}; + +vi.mock("@clack/prompts", () => ({ + get isCancel() { + return mockIsCancel; + }, + spinner: () => mockSpinner, +})); + +const mockLogHelpText = vi.fn(); + +vi.mock("./logHelpText.js", () => ({ + get logHelpText() { + return mockLogHelpText; + }, +})); + +describe("logMigrateHelpText", () => { + it("returns a CLI error without spinning when source is an error", async () => { + const source = new Error("Oh no!"); + + const actual = await logMigrateHelpText(source); + + expect(actual).toEqual({ + outro: CLIMessage.Exiting, + status: CLIStatus.Error, + }); + expect(mockLogHelpText).toHaveBeenCalledWith("migrate", source); + }); + + it("returns the error when source.load resolves with an error", async () => { + const descriptor = "create-test-app"; + const message = "Oh no!"; + const source: MigrationSource = { + descriptor, + load: () => Promise.resolve(new Error(message)), + type: "template", + }; + + const actual = await logMigrateHelpText(source); + + expect(actual).toEqual({ + outro: CLIMessage.Exiting, + status: CLIStatus.Error, + }); + expect(mockLogHelpText).toHaveBeenCalledWith("migrate", source); + expect(mockSpinner.stop).toHaveBeenCalledWith( + `Could not load ${chalk.blue(descriptor)}: ${chalk.red(message)}`, + 1, + ); + }); + + it("returns the cancellation when source.load is cancelled", async () => { + const source: MigrationSource = { + descriptor: "create-test-app", + load: () => Promise.resolve(mockCancel), + type: "template", + }; + + const actual = await logMigrateHelpText(source); + + expect(actual).toEqual({ status: CLIStatus.Cancelled }); + expect(mockSpinner.stop).not.toHaveBeenCalled(); + }); + + it("returns a success when source.load resolves with a config", async () => { + const base = createBase({ options: {} }); + const name = "Test Preset"; + const preset = base.createPreset({ + about: { name }, + blocks: [], + }); + const descriptor = "create-test-app"; + const source: MigrationSource = { + descriptor, + load: () => Promise.resolve({ preset }), + type: "template", + }; + + const actual = await logMigrateHelpText(source); + + expect(actual).toEqual({ + outro: CLIMessage.Ok, + status: CLIStatus.Success, + }); + expect(mockSpinner.stop).toHaveBeenCalledWith( + `Loaded ${chalk.blue(descriptor)}, which utilizes the ${chalk.red(name)} preset`, + ); + }); +}); diff --git a/packages/create/src/cli/loggers/logMigrateHelpText.ts b/packages/create/src/cli/loggers/logMigrateHelpText.ts new file mode 100644 index 00000000..9fde2831 --- /dev/null +++ b/packages/create/src/cli/loggers/logMigrateHelpText.ts @@ -0,0 +1,38 @@ +import * as prompts from "@clack/prompts"; +import chalk from "chalk"; + +import { CLIMessage } from "../messages.js"; +import { MigrationSource } from "../migrate/parseMigrationSource.js"; +import { CLIStatus } from "../status.js"; +import { logHelpText } from "./logHelpText.js"; + +export async function logMigrateHelpText(source: Error | MigrationSource) { + logHelpText("migrate", source); + + if (source instanceof Error) { + return { outro: CLIMessage.Exiting, status: CLIStatus.Error }; + } + + const spinner = prompts.spinner(); + spinner.start(`Loading ${source.descriptor}`); + + const loaded = await source.load(); + + if (loaded instanceof Error) { + spinner.stop( + `Could not load ${chalk.blue(source.descriptor)}: ${chalk.red(loaded.message)}`, + 1, + ); + return { outro: CLIMessage.Exiting, status: CLIStatus.Error }; + } + + if (prompts.isCancel(loaded)) { + return { status: CLIStatus.Cancelled }; + } + + spinner.stop( + `Loaded ${chalk.blue(source.descriptor)}, which utilizes the ${chalk.blue(loaded.preset.about.name)} preset`, + ); + + return { outro: CLIMessage.Ok, status: CLIStatus.Success }; +} diff --git a/packages/create/src/cli/loggers/logSchemasHelpOptions.test.ts b/packages/create/src/cli/loggers/logSchemasHelpOptions.test.ts new file mode 100644 index 00000000..92ade1b3 --- /dev/null +++ b/packages/create/src/cli/loggers/logSchemasHelpOptions.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test, vi } from "vitest"; +import { z } from "zod"; + +import { logSchemasHelpOptions } from "./logSchemasHelpOptions.js"; + +const mockLogHelpOptions = vi.fn(); + +vi.mock("./logHelpOptions.js", () => ({ + get logHelpOptions() { + return mockLogHelpOptions; + }, +})); + +describe("logSchemasHelpOptions", () => { + test("schemas", () => { + logSchemasHelpOptions("create-my-app", { + many: z.array(z.number()).describe("many values"), + numeric: z.number().describe("a numeric value"), + stringy: z.number().describe("a string value").optional(), + transformed: z + .union([ + z.string(), + z.object({ + first: z.string(), + second: z.string(), + }), + ]) + .transform(vi.fn()) + .describe( + "email address to be listed as the point of contact in docs and packages", + ), + union: z.union([z.literal("abc"), z.literal(123)]), + }); + + expect(mockLogHelpOptions.mock.calls).toMatchInlineSnapshot(` + [ + [ + "create-my-app", + [ + { + "flag": "many", + "text": "Many values.", + "type": "number[]", + }, + { + "flag": "numeric", + "text": "A numeric value.", + "type": "number", + }, + { + "flag": "stringy", + "text": "A string value.", + "type": "number", + }, + { + "flag": "transformed", + "text": "Email address to be listed as the point of contact in docs and packages.", + "type": "string", + }, + { + "flag": "union", + "text": undefined, + "type": ""abc" | 123", + }, + ], + ], + ] + `); + }); +}); diff --git a/packages/create/src/cli/loggers/logSchemasHelpOptions.ts b/packages/create/src/cli/loggers/logSchemasHelpOptions.ts new file mode 100644 index 00000000..6295d469 --- /dev/null +++ b/packages/create/src/cli/loggers/logSchemasHelpOptions.ts @@ -0,0 +1,21 @@ +import { AnyShape } from "../../options.js"; +import { getSchemaTypeName } from "../../utils/getSchemaTypeName.js"; +import { logHelpOptions } from "./logHelpOptions.js"; + +export function logSchemasHelpOptions(from: string, schemas: AnyShape) { + logHelpOptions( + from, + Object.entries(schemas) + .map(([flag, schema]) => ({ + flag, + text: asSentence(schema.description), + type: getSchemaTypeName(schema), + })) + // TODO: Once a Zod-to-args conversion is made, reuse that here... + .filter((entry) => !entry.type.startsWith("object")), + ); +} + +function asSentence(text: string | undefined) { + return text && text[0].toUpperCase() + text.slice(1) + "."; +} diff --git a/packages/create/src/cli/loggers/logStartText.test.ts b/packages/create/src/cli/loggers/logStartText.test.ts new file mode 100644 index 00000000..fcfc0338 --- /dev/null +++ b/packages/create/src/cli/loggers/logStartText.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from "vitest"; + +import { logStartText } from "./logStartText.js"; + +const mockError = vi.fn(); +const mockInfo = vi.fn(); +const mockMessage = vi.fn(); + +vi.mock("@clack/prompts", () => ({ + get log() { + return { + error: mockError, + info: mockInfo, + message: mockMessage, + }; + }, +})); + +describe("logStartText", () => { + it("only logs an initial message when offline is falsy", () => { + logStartText("migrate", "from", "type", false); + + expect(mockMessage.mock.calls).toMatchInlineSnapshot(` + [ + [ + "Running with mode --migrate using the type: + from", + ], + ] + `); + }); + + it("additionally logs an offline when offline is true", () => { + logStartText("migrate", "from", "type", true); + + expect(mockMessage.mock.calls).toMatchInlineSnapshot(` + [ + [ + "Running with mode --migrate using the type: + from", + ], + [ + "--offline enabled. You'll need to git push any changes manually.", + ], + ] + `); + }); +}); diff --git a/packages/create/src/cli/loggers/logStartText.ts b/packages/create/src/cli/loggers/logStartText.ts new file mode 100644 index 00000000..4b2e636f --- /dev/null +++ b/packages/create/src/cli/loggers/logStartText.ts @@ -0,0 +1,24 @@ +import * as prompts from "@clack/prompts"; +import chalk from "chalk"; + +import { ProductionMode } from "../../types/modes.js"; + +export function logStartText( + mode: ProductionMode, + from: string, + type: string, + offline: boolean | undefined, +) { + prompts.log.message( + [ + `Running with mode --${mode} using the ${type}:`, + ` ${chalk.green(from)}`, + ].join("\n"), + ); + + if (offline) { + prompts.log.message( + "--offline enabled. You'll need to git push any changes manually.", + ); + } +} diff --git a/packages/create/src/cli/messages.ts b/packages/create/src/cli/messages.ts new file mode 100644 index 00000000..2f976d71 --- /dev/null +++ b/packages/create/src/cli/messages.ts @@ -0,0 +1,4 @@ +export enum CLIMessage { + Exiting = "Exiting - maybe another time? 👋", + Ok = "Cheers! 💝", +} diff --git a/packages/create/src/cli/migrate/parseMigrationSource.test.ts b/packages/create/src/cli/migrate/parseMigrationSource.test.ts index 801a0e1c..c72614ba 100644 --- a/packages/create/src/cli/migrate/parseMigrationSource.test.ts +++ b/packages/create/src/cli/migrate/parseMigrationSource.test.ts @@ -38,7 +38,7 @@ describe("parseMigrationSource", () => { expect(actual).toEqual( new Error( - "--mode migrate requires either a config file exist or a template be specified on the CLI.", + "Existing repository detected. To migrate an existing repository, either create a create.config file or provide the name or path of a template.", ), ); }); @@ -52,7 +52,7 @@ describe("parseMigrationSource", () => { expect(actual).toEqual( new Error( - "--mode migrate requires either a config file or a specified template, but not both.", + "--mode migrate cannot combine an existing config file (create.config.js) with an explicit --from (my-app).", ), ); }); diff --git a/packages/create/src/cli/migrate/parseMigrationSource.ts b/packages/create/src/cli/migrate/parseMigrationSource.ts index 874daa30..c0db652f 100644 --- a/packages/create/src/cli/migrate/parseMigrationSource.ts +++ b/packages/create/src/cli/migrate/parseMigrationSource.ts @@ -1,3 +1,4 @@ +import chalk from "chalk"; import path from "node:path"; import { tryImportConfig } from "../../config/tryImportConfig.js"; @@ -25,7 +26,7 @@ export function parseMigrationSource({ }: RequestedMigrationSource): Error | MigrationSource { if (configFile && from) { return new Error( - "--mode migrate requires either a config file or a specified template, but not both.", + `${chalk.green("--mode migrate")} cannot combine an existing config file (${chalk.blue(configFile)}) with an explicit ${chalk.blue("--from")} (${chalk.blue(from)}).`, ); } @@ -47,6 +48,6 @@ export function parseMigrationSource({ } return new Error( - "--mode migrate requires either a config file exist or a template be specified on the CLI.", + `Existing repository detected. To migrate an existing repository, either create a create.config file or provide the name or path of a template.`, ); } diff --git a/packages/create/src/cli/migrate/runModeMigrate.test.ts b/packages/create/src/cli/migrate/runModeMigrate.test.ts index 624faf45..b1c9dd54 100644 --- a/packages/create/src/cli/migrate/runModeMigrate.test.ts +++ b/packages/create/src/cli/migrate/runModeMigrate.test.ts @@ -21,6 +21,14 @@ vi.mock("@clack/prompts", () => ({ spinner: vi.fn(), })); +const mockLogMigrateHelpText = vi.fn(); + +vi.mock("../loggers/logMigrateHelpText.js", () => ({ + get logMigrateHelpText() { + return mockLogMigrateHelpText; + }, +})); + const mockRunPreset = vi.fn(); vi.mock("../../runners/runPreset.js", () => ({ @@ -63,6 +71,14 @@ vi.mock("../createInitialCommit.js", () => ({ }, })); +const mockLogStartText = vi.fn(); + +vi.mock("../loggers/logStartText", () => ({ + get logStartText() { + return mockLogStartText; + }, +})); + const mockApplyArgsToSettings = vi.fn(); vi.mock("../parsers/applyArgsToSettings", () => ({ @@ -119,7 +135,30 @@ const preset = base.createPreset({ blocks: [], }); +const descriptor = "Test Source"; +const type = "template"; + +const source = { + descriptor, + load: () => Promise.resolve({ preset }), + type, +}; + describe("runModeMigrate", () => { + it("logs help text instead of running when help is true", async () => { + mockParseMigrationSource.mockReturnValueOnce(source); + + await runModeMigrate({ + args: [], + configFile: undefined, + display, + help: true, + }); + + expect(mockLogMigrateHelpText).toHaveBeenCalledWith(source); + expect(mockLogStartText).not.toHaveBeenCalled(); + }); + it("returns the error when parseMigrationSource returns an error", async () => { const error = new Error("Oh no!"); @@ -229,12 +268,11 @@ describe("runModeMigrate", () => { expect(mockClearLocalGitTags).not.toHaveBeenCalled(); }); - it("clears the existing repository online when a forked template locator is available and online is falsy", async () => { - mockParseMigrationSource.mockReturnValueOnce({ - descriptor: "Test Source", - load: () => Promise.resolve({ preset }), - type: "template", - }); + it("clears the existing repository online when a forked template locator is available and offline is falsy", async () => { + const descriptor = "Test Source"; + const type = "template"; + + mockParseMigrationSource.mockReturnValueOnce(source); mockPromptForBaseOptions.mockResolvedValueOnce({}); mockGetForkedTemplateLocator.mockResolvedValueOnce({ owner: "", @@ -251,14 +289,12 @@ describe("runModeMigrate", () => { outro: "Done. Enjoy your new repository! 💝", status: CLIStatus.Success, }); - expect(mockMessage.mock.calls).toMatchInlineSnapshot(` - [ - [ - "Running with --mode migrate for an existing repository using the template: - Test Source", - ], - ] - `); + expect(mockLogStartText).toHaveBeenCalledWith( + "migrate", + descriptor, + type, + undefined, + ); expect(mockClearTemplateFiles).toHaveBeenCalled(); expect(mockClearLocalGitTags).toHaveBeenCalled(); expect(mockCreateInitialCommit).toHaveBeenCalledWith(mockSystem.runner, { @@ -267,12 +303,11 @@ describe("runModeMigrate", () => { }); }); - it("clears the existing repository offline when a forked template locator is available and online is true", async () => { - mockParseMigrationSource.mockReturnValueOnce({ - descriptor: "Test Source", - load: () => Promise.resolve({ preset }), - type: "template", - }); + it("clears the existing repository offline when a forked template locator is available and offline is true", async () => { + const descriptor = "Test Source"; + const type = "template"; + + mockParseMigrationSource.mockReturnValueOnce(source); mockPromptForBaseOptions.mockResolvedValueOnce({}); mockGetForkedTemplateLocator.mockResolvedValueOnce({ owner: "", @@ -290,17 +325,12 @@ describe("runModeMigrate", () => { outro: "Done. Enjoy your new repository! 💝", status: CLIStatus.Success, }); - expect(mockMessage.mock.calls).toMatchInlineSnapshot(` - [ - [ - "Running with --mode migrate for an existing repository using the template: - Test Source", - ], - [ - "--offline enabled. You'll need to git push any changes manually.", - ], - ] - `); + expect(mockLogStartText).toHaveBeenCalledWith( + "migrate", + descriptor, + type, + true, + ); expect(mockClearTemplateFiles).toHaveBeenCalled(); expect(mockClearLocalGitTags).toHaveBeenCalled(); expect(mockCreateInitialCommit).toHaveBeenCalledWith(mockSystem.runner, { diff --git a/packages/create/src/cli/migrate/runModeMigrate.ts b/packages/create/src/cli/migrate/runModeMigrate.ts index a40c9a92..5917588c 100644 --- a/packages/create/src/cli/migrate/runModeMigrate.ts +++ b/packages/create/src/cli/migrate/runModeMigrate.ts @@ -1,5 +1,4 @@ import * as prompts from "@clack/prompts"; -import chalk from "chalk"; import { runPreset } from "../../runners/runPreset.js"; import { createSystemContextWithAuth } from "../../system/createSystemContextWithAuth.js"; @@ -7,7 +6,8 @@ import { clearLocalGitTags } from "../clearLocalGitTags.js"; import { createInitialCommit } from "../createInitialCommit.js"; import { ClackDisplay } from "../display/createClackDisplay.js"; import { runSpinnerTask } from "../display/runSpinnerTask.js"; -import { findPositionalFrom } from "../findPositionalFrom.js"; +import { logMigrateHelpText } from "../loggers/logMigrateHelpText.js"; +import { logStartText } from "../loggers/logStartText.js"; import { applyArgsToSettings } from "../parsers/applyArgsToSettings.js"; import { parseZodArgs } from "../parsers/parseZodArgs.js"; import { promptForBaseOptions } from "../prompts/promptForBaseOptions.js"; @@ -23,6 +23,7 @@ export interface RunModeMigrateSettings { directory?: string; display: ClackDisplay; from?: string; + help?: boolean; offline?: boolean; preset?: string | undefined; } @@ -32,7 +33,8 @@ export async function runModeMigrate({ configFile, directory = ".", display, - from = findPositionalFrom(args), + from, + help, offline, preset: requestedPreset, }: RunModeMigrateSettings): Promise { @@ -42,6 +44,11 @@ export async function runModeMigrate({ from, requestedPreset, }); + + if (help) { + return await logMigrateHelpText(source); + } + if (source instanceof Error) { return { outro: source.message, @@ -49,18 +56,7 @@ export async function runModeMigrate({ }; } - prompts.log.message( - [ - `Running with --mode migrate for an existing repository using the ${source.type}:`, - ` ${chalk.green(source.descriptor)}`, - ].join("\n"), - ); - - if (offline) { - prompts.log.message( - "--offline enabled. You'll need to git push any changes manually.", - ); - } + logStartText("migrate", source.descriptor, source.type, offline); const loaded = await source.load(); if (loaded instanceof Error) { diff --git a/packages/create/src/cli/prompts/promptForBaseOptions.ts b/packages/create/src/cli/prompts/promptForBaseOptions.ts index d750a53f..9ba47862 100644 --- a/packages/create/src/cli/prompts/promptForBaseOptions.ts +++ b/packages/create/src/cli/prompts/promptForBaseOptions.ts @@ -1,10 +1,10 @@ import * as prompts from "@clack/prompts"; -import { z } from "zod"; import { AnyShape, InferredObject } from "../../options.js"; import { produceBase } from "../../producers/produceBase.js"; import { Base } from "../../types/bases.js"; import { SystemContext } from "../../types/system.js"; +import { getSchemaDefaultValue } from "../../utils/getSchemaDefaultValue.js"; import { promptForSchema } from "./promptForSchema.js"; export interface PromptForBaseOptionsSettings { @@ -53,7 +53,3 @@ export async function promptForBaseOptions< return options; } - -function getSchemaDefaultValue(schema: z.ZodTypeAny) { - return (schema._def as Partial).defaultValue?.() as unknown; -} diff --git a/packages/create/src/cli/prompts/promptForDirectory.test.ts b/packages/create/src/cli/prompts/promptForDirectory.test.ts index afe3a792..f4993b37 100644 --- a/packages/create/src/cli/prompts/promptForDirectory.test.ts +++ b/packages/create/src/cli/prompts/promptForDirectory.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { createTemplate } from "../../creators/createTemplate.js"; +import { createBase } from "../../creators/createBase.js"; import { promptForDirectory } from "./promptForDirectory.js"; const mockCancel = Symbol(""); @@ -30,11 +30,15 @@ vi.mock("node:fs/promises", () => ({ }, })); -const templateWithoutAbout = createTemplate({ +const base = createBase({ + options: {}, +}); + +const templateWithoutAbout = base.createTemplate({ presets: [], }); -const templateWithName = createTemplate({ +const templateWithName = base.createTemplate({ about: { name: "Test Template" }, presets: [], }); diff --git a/packages/create/src/cli/prompts/promptForPreset.test.ts b/packages/create/src/cli/prompts/promptForPreset.test.ts index 864ceb49..ac1b8ffe 100644 --- a/packages/create/src/cli/prompts/promptForPreset.test.ts +++ b/packages/create/src/cli/prompts/promptForPreset.test.ts @@ -2,7 +2,6 @@ import chalk from "chalk"; import { describe, expect, it, vi } from "vitest"; import { createBase } from "../../creators/createBase.js"; -import { createTemplate } from "../../creators/createTemplate.js"; import { promptForPreset } from "./promptForPreset.js"; const base = createBase({ @@ -19,7 +18,7 @@ const presetB = base.createPreset({ blocks: [], }); -const template = createTemplate({ +const template = base.createTemplate({ about: { name: "Test" }, presets: [presetA, presetB], suggested: presetA, diff --git a/packages/create/src/cli/readProductionSettings.ts b/packages/create/src/cli/readProductionSettings.ts index 425d31f2..ce81c1ed 100644 --- a/packages/create/src/cli/readProductionSettings.ts +++ b/packages/create/src/cli/readProductionSettings.ts @@ -2,7 +2,7 @@ import * as fs from "node:fs/promises"; import path from "node:path"; import { ProductionMode } from "../types/modes.js"; -import { tryCatchAsync } from "../utils/tryCatchAsync.js"; +import { tryCatchSafe } from "../utils/tryCatch.js"; import { ProductionSettings } from "./types.js"; export interface ReadProductionSettingsOptions { @@ -14,7 +14,7 @@ export async function readProductionSettings({ directory = ".", mode, }: ReadProductionSettingsOptions = {}): Promise { - const items = await tryCatchAsync(fs.readdir(directory)); + const items = await tryCatchSafe(fs.readdir(directory)); let defaultMode: ProductionMode = mode ?? "initialize"; if (!items) { diff --git a/packages/create/src/cli/runCli.test.ts b/packages/create/src/cli/runCli.test.ts index a90ae162..dfd6dfde 100644 --- a/packages/create/src/cli/runCli.test.ts +++ b/packages/create/src/cli/runCli.test.ts @@ -78,14 +78,6 @@ describe("readProductionSettings", () => { console.log = mockLog; }); - it("logs help text when --help is provided", async () => { - const actual = await runCli(["--help"]); - - expect(actual).toBe(CLIStatus.Success); - expect(mockLogHelpText).toHaveBeenCalled(); - expect(mockLog).not.toHaveBeenCalled(); - }); - it("logs version when --version is provided", async () => { const actual = await runCli(["--version"]); @@ -107,18 +99,6 @@ describe("readProductionSettings", () => { expect(mockLogOutro).toHaveBeenCalledWith(chalk.red(message)); }); - it("logs a starting prompt when readProductionSettings resolves with mode: initialize and there are no args", async () => { - mockReadProductionSettings.mockResolvedValueOnce({ mode: "initialize" }); - - const actual = await runCli([]); - - expect(actual).toBe(CLIStatus.Success); - expect(mockLogHelpText).not.toHaveBeenCalled(); - expect(mockLog).not.toHaveBeenCalled(); - expect(mockRunModeInitialize).not.toHaveBeenCalled(); - expect(mockLogOutro).toHaveBeenCalledWith("Cheers! 💝"); - }); - it("runs initialize mode when readProductionSettings resolves with mode: initialize", async () => { const args = ["typescript-app"]; const status = CLIStatus.Success; @@ -132,12 +112,13 @@ describe("readProductionSettings", () => { expect(mockRunModeInitialize).toHaveBeenCalledWith({ args, display: mockDisplay, + from: "create-typescript-app", }); expect(mockRunModeMigrate).not.toHaveBeenCalled(); }); it("runs migration mode when readProductionSettings resolves with mode: migrate", async () => { - const args = ["typescript-app"]; + const args: string[] = []; const configFile = "create.config.js"; const status = CLIStatus.Success; @@ -154,6 +135,7 @@ describe("readProductionSettings", () => { args, configFile, display: mockDisplay, + from: undefined, }); expect(mockRunModeInitialize).not.toHaveBeenCalled(); }); diff --git a/packages/create/src/cli/runCli.ts b/packages/create/src/cli/runCli.ts index b2f71604..aba414b6 100644 --- a/packages/create/src/cli/runCli.ts +++ b/packages/create/src/cli/runCli.ts @@ -5,8 +5,8 @@ import { z } from "zod"; import { packageData } from "../packageData.js"; import { createClackDisplay } from "./display/createClackDisplay.js"; +import { findPositionalFrom } from "./findPositionalFrom.js"; import { runModeInitialize } from "./initialize/runModeInitialize.js"; -import { logHelpText } from "./loggers/logHelpText.js"; import { logOutro } from "./loggers/logOutro.js"; import { runModeMigrate } from "./migrate/runModeMigrate.js"; import { readProductionSettings } from "./readProductionSettings.js"; @@ -15,6 +15,7 @@ import { CLIStatus } from "./status.js"; const valuesSchema = z.object({ directory: z.string().optional(), from: z.string().optional(), + help: z.boolean().optional(), mode: z.union([z.literal("initialize"), z.literal("migrate")]).optional(), offline: z.boolean().optional(), owner: z.string().optional(), @@ -23,7 +24,7 @@ const valuesSchema = z.object({ }); export async function runCli(args: string[]) { - const { values } = parseArgs({ + const { positionals, values } = parseArgs({ args, options: { directory: { @@ -57,11 +58,6 @@ export async function runCli(args: string[]) { strict: false, }); - if (values.help) { - logHelpText(console); - return CLIStatus.Success; - } - if (values.version) { console.log(packageData.version); return CLIStatus.Success; @@ -80,7 +76,7 @@ export async function runCli(args: string[]) { `Welcome to ${chalk.bgGreenBright.black("create")}: a delightful repository templating engine.`, "", "Learn more about create on:", - ` ${chalk.green("https://create.bingo")}`, + ` ${chalk.green("https://")}${chalk.green.bold("create.bingo")}`, ].join("\n"), ); @@ -91,23 +87,12 @@ export async function runCli(args: string[]) { return CLIStatus.Error; } - if (productionSettings.mode === "initialize" && args.length === 0) { - prompts.log.message( - [ - `Try it out with:`, - ` ${chalk.green("npx create typescript-app")}`, - ].join("\n"), - ); - logOutro("Cheers! 💝"); - return CLIStatus.Success; - } - const display = createClackDisplay(); - console.log({ display }); const sharedSettings = { ...validatedValues, args, display, + from: findPositionalFrom(positionals), }; const { outro, status, suggestions } = diff --git a/packages/create/src/cli/status.ts b/packages/create/src/cli/status.ts index abbb87e6..921f0462 100644 --- a/packages/create/src/cli/status.ts +++ b/packages/create/src/cli/status.ts @@ -1,5 +1,5 @@ -export const CLIStatus = { - Cancelled: 2, - Error: 1, - Success: 0, -} as const; +export enum CLIStatus { + Cancelled = 2, + Error = 1, + Success = 0, +} diff --git a/packages/create/src/cli/tryImportWithPredicate.ts b/packages/create/src/cli/tryImportWithPredicate.ts index bf7fceb0..6c67acb5 100644 --- a/packages/create/src/cli/tryImportWithPredicate.ts +++ b/packages/create/src/cli/tryImportWithPredicate.ts @@ -1,10 +1,14 @@ +import { tryCatchError } from "../utils/tryCatch.js"; + export async function tryImportWithPredicate( importer: (moduleName: string) => Promise, moduleName: string, predicate: (value: unknown) => value is T, typeName: string, ): Promise { - const templateModule = await importer(moduleName); + const templateModule = (await tryCatchError(importer(moduleName))) as + | Error + | object; if (templateModule instanceof Error) { return templateModule; } diff --git a/packages/create/src/cli/types.ts b/packages/create/src/cli/types.ts index 799c6b6e..aefbd5cc 100644 --- a/packages/create/src/cli/types.ts +++ b/packages/create/src/cli/types.ts @@ -1,5 +1,3 @@ -export type Logger = Pick; - export interface ModeResults { outro?: string; status: number; diff --git a/packages/create/src/config/tryImportConfig.test.ts b/packages/create/src/config/tryImportConfig.test.ts new file mode 100644 index 00000000..8a05de65 --- /dev/null +++ b/packages/create/src/config/tryImportConfig.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createBase } from "../creators/createBase.js"; +import { createConfig } from "./createConfig.js"; +import { tryImportConfig } from "./tryImportConfig.js"; + +const base = createBase({ + options: {}, +}); + +const preset = base.createPreset({ + about: { name: "Test Preset" }, + blocks: [], +}); + +describe("tryImportConfig", () => { + it("returns an error when the module cannot be imported", async () => { + const actual = await tryImportConfig("does-not-exist"); + + expect(actual).toMatchInlineSnapshot( + `[Error: Failed to load url does-not-exist (resolved id: does-not-exist). Does the file exist?]`, + ); + }); + + it("returns an error when the module is imported but is not a config", async () => { + vi.mock("not-a-config", () => ({ + default: {}, + })); + + const actual = await tryImportConfig("not-a-config"); + + expect(actual).toMatchInlineSnapshot( + ` + { + "preset": { + "about": { + "name": "Test Preset", + }, + "base": { + "createBlock": [Function], + "createPreset": [Function], + "createTemplate": [Function], + "options": {}, + }, + "blocks": [], + }, + "settings": undefined, + } + `, + ); + }); + + it("returns the config when the module is imported and is a config", async () => { + vi.mock("not-a-config", () => ({ + default: createConfig(preset), + })); + + const actual = await tryImportConfig("not-a-config"); + + expect(actual).toMatchInlineSnapshot( + ` + { + "preset": { + "about": { + "name": "Test Preset", + }, + "base": { + "createBlock": [Function], + "createPreset": [Function], + "createTemplate": [Function], + "options": {}, + }, + "blocks": [], + }, + "settings": undefined, + } + `, + ); + }); +}); diff --git a/packages/create/src/creators/createBase.ts b/packages/create/src/creators/createBase.ts index 63112680..b72574f5 100644 --- a/packages/create/src/creators/createBase.ts +++ b/packages/create/src/creators/createBase.ts @@ -8,6 +8,7 @@ import { BlockWithoutAddons, } from "../types/blocks.js"; import { Preset, PresetDefinition } from "../types/presets.js"; +import { Template, TemplateDefinition } from "../types/templates.js"; import { assertNoDuplicateBlocks } from "./assertNoDuplicateBlocks.js"; import { applyZodDefaults, isDefinitionWithAddons } from "./utils.js"; @@ -65,10 +66,20 @@ export function createBase( }; } + function createTemplate( + templateDefinition: TemplateDefinition, + ): Template { + return { + ...templateDefinition, + options: base.options, + }; + } + const base = { ...baseDefinition, createBlock, createPreset, + createTemplate, }; return base; diff --git a/packages/create/src/creators/createTemplate.test.ts b/packages/create/src/creators/createTemplate.test.ts deleted file mode 100644 index 349aa8f8..00000000 --- a/packages/create/src/creators/createTemplate.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, test } from "vitest"; -import { z } from "zod"; - -import { createBase } from "./createBase.js"; -import { createTemplate } from "./createTemplate.js"; - -const base = createBase({ - options: { - value: z.string(), - }, -}); - -const presetFirst = base.createPreset({ - about: { name: "First" }, - blocks: [], -}); - -const presetSecond = base.createPreset({ - about: { name: "Second" }, - blocks: [], -}); - -describe("createTemplate", () => { - // We're just testing types, since the template definition === the template - // eslint-disable-next-line vitest/expect-expect - test("production with the same Base", () => { - createTemplate({ - about: { - name: "TypeScript App", - }, - presets: [presetFirst, presetSecond], - suggested: presetFirst, - }); - }); -}); diff --git a/packages/create/src/creators/createTemplate.ts b/packages/create/src/creators/createTemplate.ts deleted file mode 100644 index e915ed6f..00000000 --- a/packages/create/src/creators/createTemplate.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { AnyShape } from "../options.js"; -import { Template, TemplateDefinition } from "../types/templates.js"; - -export const createTemplate = ( - templateDefinition: TemplateDefinition, -): Template => { - return templateDefinition; -}; diff --git a/packages/create/src/index.ts b/packages/create/src/index.ts index 9b964ac8..35076a9e 100644 --- a/packages/create/src/index.ts +++ b/packages/create/src/index.ts @@ -8,7 +8,6 @@ export type * from "./config/types.js"; // Creators export * from "./creators/createBase.js"; export * from "./creators/createInput.js"; -export * from "./creators/createTemplate.js"; // Producers export * from "./producers/produceBase.js"; diff --git a/packages/create/src/predicates/isTemplate.test.ts b/packages/create/src/predicates/isTemplate.test.ts index e6df8579..776a8a9a 100644 --- a/packages/create/src/predicates/isTemplate.test.ts +++ b/packages/create/src/predicates/isTemplate.test.ts @@ -1,9 +1,13 @@ import { describe, expect, test } from "vitest"; -import { createTemplate } from "../creators/createTemplate.js"; +import { createBase } from "../creators/createBase.js"; import { isTemplate } from "./isTemplate.js"; -const template = createTemplate({ +const base = createBase({ + options: {}, +}); + +const template = base.createTemplate({ presets: [], }); diff --git a/packages/create/src/types/bases.ts b/packages/create/src/types/bases.ts index 02682408..26d30cc4 100644 --- a/packages/create/src/types/bases.ts +++ b/packages/create/src/types/bases.ts @@ -8,10 +8,12 @@ import { import { TakeContext } from "./context.js"; import { TakeInput } from "./inputs.js"; import { Preset, PresetDefinition } from "./presets.js"; +import { Template, TemplateDefinition } from "./templates.js"; export interface Base { createBlock: CreateBlock>; createPreset: CreatePreset; + createTemplate: CreateTemplate; options: OptionsShape; produce?: BaseProducer>; template?: RepositoryTemplate; @@ -52,6 +54,10 @@ export type CreatePreset = ( presetDefinition: PresetDefinition>, ) => Preset; +export type CreateTemplate = ( + templateDefinition: TemplateDefinition, +) => Template; + export type LazyOptionalOption = | (() => Promise) | (() => T | undefined) diff --git a/packages/create/src/types/templates.ts b/packages/create/src/types/templates.ts index c08bdbde..3f6cce60 100644 --- a/packages/create/src/types/templates.ts +++ b/packages/create/src/types/templates.ts @@ -2,8 +2,10 @@ import { AnyShape } from "../options.js"; import { AboutBase } from "./about.js"; import { Preset } from "./presets.js"; -export type Template = - TemplateDefinition; +export interface Template + extends TemplateDefinition { + options: OptionsShape; +} export interface TemplateDefinition { about?: AboutBase; diff --git a/packages/create/src/utils/getSchemaDefaultValue.ts b/packages/create/src/utils/getSchemaDefaultValue.ts new file mode 100644 index 00000000..9128e44f --- /dev/null +++ b/packages/create/src/utils/getSchemaDefaultValue.ts @@ -0,0 +1,5 @@ +import { z } from "zod"; + +export function getSchemaDefaultValue(schema: z.ZodTypeAny) { + return (schema._def as Partial).defaultValue?.() as unknown; +} diff --git a/packages/create/src/utils/getSchemaTypeName.ts b/packages/create/src/utils/getSchemaTypeName.ts new file mode 100644 index 00000000..29fb8ac8 --- /dev/null +++ b/packages/create/src/utils/getSchemaTypeName.ts @@ -0,0 +1,45 @@ +// TODO: Split Zod generation out into standalone package +/* eslint-disable @eslint-community/eslint-comments/disable-enable-pair */ + +/* eslint-disable @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access */ + +import { z } from "zod"; + +export function getSchemaTypeName(schema: z.ZodTypeAny): string { + const schemaInner = getSchemaInner(schema); + + if (schemaInner._def.typeName === z.ZodFirstPartyTypeKind.ZodArray) { + return `${getSchemaTypeName(schemaInner._def.type)}[]`; + } + + if (schemaInner._def.typeName === z.ZodFirstPartyTypeKind.ZodLiteral) { + return JSON.stringify((schemaInner._def as z.ZodLiteralDef).value); + } + + if (schemaInner._def.typeName === z.ZodFirstPartyTypeKind.ZodUnion) { + return ( + (schemaInner._def as z.ZodUnionDef).options + .map((constituent) => getSchemaTypeName(constituent)) + // TODO: Once these can be parsed as args, reuse that here... + .filter((typeName) => typeName !== "object") + .join(" | ") + ); + } + + // n.b. it's not necessary an object, that's just one of many with a typeName + return (schemaInner._def as z.ZodObjectDef).typeName + .replace("Zod", "") + .toLowerCase(); +} + +function getSchemaInner(schema: z.ZodTypeAny): z.ZodTypeAny { + if (schema.isOptional()) { + return getSchemaInner((schema._def as z.ZodOptionalDef).innerType); + } + + if (schema._def.typeName === z.ZodFirstPartyTypeKind.ZodEffects) { + return getSchemaInner((schema._def as z.ZodEffectsDef).schema); + } + + return schema; +} diff --git a/packages/create/src/utils/tryCatch.ts b/packages/create/src/utils/tryCatch.ts new file mode 100644 index 00000000..3475985a --- /dev/null +++ b/packages/create/src/utils/tryCatch.ts @@ -0,0 +1,15 @@ +export async function tryCatchError(promise: Promise) { + try { + return await promise; + } catch (error) { + return error; + } +} + +export async function tryCatchSafe(promise: Promise) { + try { + return await promise; + } catch { + return undefined; + } +} diff --git a/packages/create/src/utils/tryCatchAsync.ts b/packages/create/src/utils/tryCatchAsync.ts deleted file mode 100644 index f5bc6e5e..00000000 --- a/packages/create/src/utils/tryCatchAsync.ts +++ /dev/null @@ -1,7 +0,0 @@ -export async function tryCatchAsync(promise: Promise) { - try { - return await promise; - } catch { - return undefined; - } -} diff --git a/packages/site/src/content/docs/cli.mdx b/packages/site/src/content/docs/cli.mdx index 46ee40d5..1924708b 100644 --- a/packages/site/src/content/docs/cli.mdx +++ b/packages/site/src/content/docs/cli.mdx @@ -94,7 +94,7 @@ For example, creating a new repository in a subdirectory: > Type: `string` -An explicit path to import a template from. +An explicit package or path to import a template from. This can be either: diff --git a/packages/site/src/content/docs/engine/apis/creators.mdx b/packages/site/src/content/docs/engine/apis/creators.mdx index 59885fa9..0062d629 100644 --- a/packages/site/src/content/docs/engine/apis/creators.mdx +++ b/packages/site/src/content/docs/engine/apis/creators.mdx @@ -7,7 +7,7 @@ The main driver of `create` is a set of APIs that set up the generators for repo - [`createBase`](#createbase): creates a new [Base](../concepts/bases) - [`createBlock`](#createblock): creates a new [Block](../concepts/blocks) for the Base - [`createPreset`](#createpreset): creates a new [Preset](../concepts/presets) for the Base -- [`createTemplate`](#createtemplate): creates a new [Template](../concepts/templates) + - [`createTemplate`](#createtemplate): creates a new [Template](../concepts/templates) for the Base - [`createInput`](#createinput): creates a new [Input](../runtime/inputs) ## `createBase` @@ -375,7 +375,8 @@ base.createPreset({ ## `createTemplate` -Given a _Template Definition_, creates a _Template_. +[Templates](../concepts/templates) can be created by the `createTemplate()` method of a [Base](../concepts/bases). +`createTemplate()` takes in a _Template Definition_ and returns a _Template_. A Template Definition is an object containing: @@ -395,9 +396,9 @@ This is an object containing any of: For example, this Template describes itself as a solution for TypeScript repositories: ```ts -import { createTemplate } from "create"; +import { base } from "./base"; -createTemplate({ +base.createTemplate({ about: { description: "One-stop shop for the latest and greatest TypeScript tooling.", @@ -418,12 +419,11 @@ This should be the same string as one of the `labels` under [`presets`](#presets For example, this Template defaults to the `"Common"` Preset: ```ts -import { createTemplate } from "create"; - +import { base } from "./base"; import { presetCommon } from "./presetCommon"; import { presetEverything } from "./presetEverything"; -export const templateTypeScriptApp = createTemplate({ +export const templateTypeScriptApp = base.createTemplate({ default: "Common", presets: [ { label: "Common", preset: presetCommon }, @@ -444,12 +444,11 @@ Each element in the array is an object containing: For example, this Template allows choosing between two Presets for TypeScript apps: ```ts -import { createTemplate } from "create"; - +import { base } from "./base"; import { presetCommon } from "./presetCommon"; import { presetEverything } from "./presetEverything"; -export const templateTypeScriptApp = createTemplate({ +export const templateTypeScriptApp = base.createTemplate({ about: { name: "TypeScript App", },