Repository: JoshuaKGoldberg/bingo (ts)

Issue:
🐛 Bug: Errors loading templates are swallowed
### Bug Report Checklist

- [x] I have tried restarting my IDE and the issue persists.
- [x] I have pulled the latest `main` branch of the repository.
- [x] I have [searched for related issues](https://github.com/JoshuaKGoldberg/bingo/issues?q=is%3Aissue) and found none that matched my issue.

### Expected

If you `npx bingo` with some package that isn't properly set up, an informative error explaining it couldn't load should be logged.

### Actual

No such messaging is logged.

```plaintext
npx bingo typescript-app@beta

Need to install the following packages:
bingo@0.4.0
Ok to proceed? (y) y

┌  ✨ Welcome to Bingo: a delightful repository templating engine. ✨
│
│  Learn more about Bingo on:
│    https://create.bingo
│
│  Running with mode --setup using the template:
│    create-typescript-app@beta
│
◇  create-typescript-app@beta not found locally and --yes not specified. create-typescript-app@beta will need to be fetched with npx. 
│
◇  Ok to proceed?
│  Yes
│
◇  Loaded create-typescript-app@beta
│
└  Exiting - maybe another time? 👋
```

### Additional Info

💝 

Pull request description:
fix: always surface CLI errors
## PR Checklist

- [x] Addresses an existing open issue: fixes #207
- [x] That issue was marked as [`status: accepting prs`](https://github.com/JoshuaKGoldberg/bingo/issues?q=is%3Aopen+is%3Aissue+label%3A%22status%3A+accepting+prs%22)
- [x] Steps in [CONTRIBUTING.md](https://github.com/JoshuaKGoldberg/bingo/blob/main/.github/CONTRIBUTING.md) were taken

## Overview

Switches individual error logging to always happen in `runCli`.

```plaintext
┌  ✨ Welcome to Bingo: a delightful repository templating engine. ✨
│
│  Learn more about Bingo on:
│    https://create.bingo
│
│  Running with mode --setup using the template:
│    create-typescript-app@beta
│
◇  create-typescript-app@beta not found locally and --yes specified. create-typescript-app@beta will be fetched with npx. 
│
◇  Loaded create-typescript-app@beta
│
■  Error: The default export of create-typescript-app@beta should be a Template.
│
└  Exiting - maybe another time? 👋
```

I don't love that the error stacks still aren't logged. But there's no concept of "verbose" logging yet in the system. That can be added later.

💝 

Patch:
diff --git a/packages/bingo/src/cli/importers/tryImportAndInstallIfNecessary.ts b/packages/bingo/src/cli/importers/tryImportAndInstallIfNecessary.ts
index 168d87b..b8786bb 100644
--- a/packages/bingo/src/cli/importers/tryImportAndInstallIfNecessary.ts
+++ b/packages/bingo/src/cli/importers/tryImportAndInstallIfNecessary.ts
@@ -23,10 +23,7 @@ export async function tryImportAndInstallIfNecessary(
 
 	if (imported.kind === "failure") {
 		const template = isLocalPath(from) ? imported.local : imported.npx;
-		spinner.stop(
-			`Could not load ${chalk.blue(from)}: ${chalk.red(template.message)}`,
-			1,
-		);
+		spinner.stop(`Could not load ${chalk.blue(from)}.`, 1);
 		return template;
 	}
 
diff --git a/packages/bingo/src/cli/loggers/logSetupHelpText.ts b/packages/bingo/src/cli/loggers/logSetupHelpText.ts
index 1855340..29dbec2 100644
--- a/packages/bingo/src/cli/loggers/logSetupHelpText.ts
+++ b/packages/bingo/src/cli/loggers/logSetupHelpText.ts
@@ -4,6 +4,7 @@ import chalk from "chalk";
 import { tryImportTemplate } from "../importers/tryImportTemplate.js";
 import { CLIMessage } from "../messages.js";
 import { CLIStatus } from "../status.js";
+import { ModeResults } from "../types.js";
 import { logHelpText } from "./logHelpText.js";
 import { logSchemasHelpOptions } from "./logSchemasHelpOptions.js";
 
@@ -15,7 +16,7 @@ export interface SetupHelpTextOptions {
 export async function logSetupHelpText(
 	from: string | undefined,
 	{ help, yes }: SetupHelpTextOptions,
-) {
+): Promise<ModeResults> {
 	if (!from) {
 		if (help) {
 			logHelpText("setup");
@@ -44,7 +45,8 @@ export async function logSetupHelpText(
 	const template = await tryImportTemplate(from, yes);
 	if (template instanceof Error) {
 		return {
-			outro: chalk.red(CLIMessage.Exiting),
+			error: template,
+			outro: CLIMessage.Exiting,
 			status: CLIStatus.Error,
 		};
 	}
diff --git a/packages/bingo/src/cli/loggers/logTransitionHelpText.ts b/packages/bingo/src/cli/loggers/logTransitionHelpText.ts
index a116742..7dd70f7 100644
--- a/packages/bingo/src/cli/loggers/logTransitionHelpText.ts
+++ b/packages/bingo/src/cli/loggers/logTransitionHelpText.ts
@@ -4,13 +4,20 @@ import chalk from "chalk";
 import { CLIMessage } from "../messages.js";
 import { CLIStatus } from "../status.js";
 import { TransitionSource } from "../transition/parseTransitionSource.js";
+import { ModeResults } from "../types.js";
 import { logHelpText } from "./logHelpText.js";
 
-export async function logTransitionHelpText(source: Error | TransitionSource) {
+export async function logTransitionHelpText(
+	source: Error | TransitionSource,
+): Promise<ModeResults> {
 	logHelpText("transition", source);
 
 	if (source instanceof Error) {
-		return { outro: CLIMessage.Exiting, status: CLIStatus.Error };
+		return {
+			error: source,
+			outro: CLIMessage.Exiting,
+			status: CLIStatus.Error,
+		};
 	}
 
 	const spinner = prompts.spinner();
@@ -19,11 +26,12 @@ export async function logTransitionHelpText(source: Error | TransitionSource) {
 	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 };
+		spinner.stop(`Could not load ${chalk.blue(source.descriptor)}.`, 1);
+		return {
+			error: loaded,
+			outro: CLIMessage.Exiting,
+			status: CLIStatus.Error,
+		};
 	}
 
 	if (prompts.isCancel(loaded)) {
diff --git a/packages/bingo/src/cli/runCli.ts b/packages/bingo/src/cli/runCli.ts
index e8d07f8..6c7e7e1 100644
--- a/packages/bingo/src/cli/runCli.ts
+++ b/packages/bingo/src/cli/runCli.ts
@@ -7,6 +7,7 @@ import { packageData } from "../packageData.js";
 import { createClackDisplay } from "./display/createClackDisplay.js";
 import { findPositionalFrom } from "./findPositionalFrom.js";
 import { logOutro } from "./loggers/logOutro.js";
+import { CLIMessage } from "./messages.js";
 import { readProductionSettings } from "./readProductionSettings.js";
 import { runModeSetup } from "./setup/runModeSetup.js";
 import { CLIStatus } from "./status.js";
@@ -95,7 +96,7 @@ export async function runCli(args: string[]) {
 		from: validatedValues.from ?? findPositionalFrom(positionals),
 	};
 
-	const { outro, status, suggestions } =
+	const results =
 		productionSettings.mode === "setup"
 			? await runModeSetup(sharedSettings)
 			: await runModeTransition({
@@ -103,11 +104,14 @@ export async function runCli(args: string[]) {
 					configFile: productionSettings.configFile,
 				});
 
+	if (results.status === CLIStatus.Error) {
+		prompts.log.error(chalk.red(`Error: ${results.error.message}`));
+	}
+
 	logOutro(
-		outro ??
-			chalk.yellow("Operation cancelled. Exiting - maybe another time? 👋"),
-		{ items: display.dumpItems(), suggestions },
+		results.outro ?? chalk.yellow(`Operation cancelled. ${CLIMessage.Exiting}`),
+		{ items: display.dumpItems(), suggestions: results.suggestions },
 	);
 
-	return status;
+	return results.status;
 }
diff --git a/packages/bingo/src/cli/setup/runModeSetup.ts b/packages/bingo/src/cli/setup/runModeSetup.ts
index 393cb22..e3c75d5 100644
--- a/packages/bingo/src/cli/setup/runModeSetup.ts
+++ b/packages/bingo/src/cli/setup/runModeSetup.ts
@@ -53,7 +53,8 @@ export async function runModeSetup({
 	const template = await tryImportTemplate(from, yes);
 	if (template instanceof Error) {
 		return {
-			outro: chalk.red(CLIMessage.Exiting),
+			error: template,
+			outro: CLIMessage.Exiting,
 			status: CLIStatus.Error,
 		};
 	}
@@ -124,6 +125,7 @@ export async function runModeSetup({
 	if (creation instanceof Error) {
 		logRerunSuggestion(args, baseOptions.prompted);
 		return {
+			error: creation,
 			outro: `Leaving changes to the local directory on disk. 👋`,
 			status: CLIStatus.Error,
 		};
diff --git a/packages/bingo/src/cli/transition/runModeTransition.ts b/packages/bingo/src/cli/transition/runModeTransition.ts
index ff1a62d..4e191ec 100644
--- a/packages/bingo/src/cli/transition/runModeTransition.ts
+++ b/packages/bingo/src/cli/transition/runModeTransition.ts
@@ -51,6 +51,7 @@ export async function runModeTransition({
 
 	if (source instanceof Error) {
 		return {
+			error: source,
 			outro: source.message,
 			status: CLIStatus.Error,
 		};
@@ -61,6 +62,7 @@ export async function runModeTransition({
 	const template = await source.load();
 	if (template instanceof Error) {
 		return {
+			error: template,
 			outro: template.message,
 			status: CLIStatus.Error,
 		};
@@ -120,6 +122,7 @@ export async function runModeTransition({
 	if (creation instanceof Error) {
 		logRerunSuggestion(args, baseOptions.prompted);
 		return {
+			error: creation,
 			outro: `Leaving changes to the local directory on disk. 👋`,
 			status: CLIStatus.Error,
 		};
diff --git a/packages/bingo/src/cli/types.ts b/packages/bingo/src/cli/types.ts
index a8d1d82..524dc5f 100644
--- a/packages/bingo/src/cli/types.ts
+++ b/packages/bingo/src/cli/types.ts
@@ -1,9 +1,21 @@
-export interface ModeResults {
+import { CLIStatus } from "./status.js";
+
+export type ModeResults = ModeResultsError | ModeResultsNonError;
+
+export interface ModeResultsBase {
 	outro?: string;
-	status: number;
 	suggestions?: string[];
 }
 
+export interface ModeResultsError extends ModeResultsBase {
+	error: Error;
+	status: CLIStatus.Error;
+}
+
+export interface ModeResultsNonError extends ModeResultsBase {
+	status: CLIStatus.Cancelled | CLIStatus.Success;
+}
+
 export type ProductionSettings =
 	| ProductionSettingsSetup
 	| ProductionSettingsTransition;


Test patch:
diff --git a/packages/bingo/src/cli/importers/tryImportAndInstallIfNecessary.test.ts b/packages/bingo/src/cli/importers/tryImportAndInstallIfNecessary.test.ts
index 7bd5210..309137c 100644
--- a/packages/bingo/src/cli/importers/tryImportAndInstallIfNecessary.test.ts
+++ b/packages/bingo/src/cli/importers/tryImportAndInstallIfNecessary.test.ts
@@ -41,10 +41,7 @@ describe("tryImportAndInstallIfNecessary", () => {
 			[`Loading ${chalk.blue("../create-my-app")}`],
 		]);
 		expect(mockSpinner.stop.mock.calls).toEqual([
-			[
-				`Could not load ${chalk.blue("../create-my-app")}: ${chalk.red(errorLocal.message)}`,
-				1,
-			],
+			[`Could not load ${chalk.blue("../create-my-app")}.`, 1],
 		]);
 	});
 
@@ -62,10 +59,7 @@ describe("tryImportAndInstallIfNecessary", () => {
 			[`Loading ${chalk.blue("bingo-my-app")}`],
 		]);
 		expect(mockSpinner.stop.mock.calls).toEqual([
-			[
-				`Could not load ${chalk.blue("bingo-my-app")}: ${chalk.red(errorNpx.message)}`,
-				1,
-			],
+			[`Could not load ${chalk.blue("bingo-my-app")}.`, 1],
 		]);
 	});
 
diff --git a/packages/bingo/src/cli/loggers/logSetupHelpText.test.ts b/packages/bingo/src/cli/loggers/logSetupHelpText.test.ts
index b6fd7d9..8174244 100644
--- a/packages/bingo/src/cli/loggers/logSetupHelpText.test.ts
+++ b/packages/bingo/src/cli/loggers/logSetupHelpText.test.ts
@@ -73,10 +73,10 @@ describe("logSetupHelpText", () => {
 	});
 
 	it("returns the error when help is false and loading the template is an error", async () => {
-		const message = "Oh no!";
 		const from = "bingo-my-app";
+		const error = new Error("Oh no!");
 
-		mockTryImportTemplate.mockResolvedValueOnce(new Error(message));
+		mockTryImportTemplate.mockResolvedValueOnce(error);
 
 		const actual = await logSetupHelpText(from, {
 			help: false,
@@ -84,7 +84,8 @@ describe("logSetupHelpText", () => {
 		});
 
 		expect(actual).toEqual({
-			outro: chalk.red(CLIMessage.Exiting),
+			error,
+			outro: CLIMessage.Exiting,
 			status: CLIStatus.Error,
 		});
 	});
diff --git a/packages/bingo/src/cli/loggers/logTransitionHelpText.test.ts b/packages/bingo/src/cli/loggers/logTransitionHelpText.test.ts
index 86ba568..1dd2e2a 100644
--- a/packages/bingo/src/cli/loggers/logTransitionHelpText.test.ts
+++ b/packages/bingo/src/cli/loggers/logTransitionHelpText.test.ts
@@ -33,6 +33,7 @@ describe("logTransitionHelpText", () => {
 		const actual = await logTransitionHelpText(source);
 
 		expect(actual).toEqual({
+			error: source,
 			outro: CLIMessage.Exiting,
 			status: CLIStatus.Error,
 		});
@@ -41,22 +42,23 @@ describe("logTransitionHelpText", () => {
 
 	it("returns the error when source.load resolves with an error", async () => {
 		const descriptor = "bingo-test-app";
-		const message = "Oh no!";
+		const error = new Error("Oh no!");
 		const source: TransitionSource = {
 			descriptor,
-			load: () => Promise.resolve(new Error(message)),
+			load: () => Promise.resolve(error),
 			type: "template",
 		};
 
 		const actual = await logTransitionHelpText(source);
 
 		expect(actual).toEqual({
+			error,
 			outro: CLIMessage.Exiting,
 			status: CLIStatus.Error,
 		});
 		expect(mockLogHelpText).toHaveBeenCalledWith("transition", source);
 		expect(mockSpinner.stop).toHaveBeenCalledWith(
-			`Could not load ${chalk.blue(descriptor)}: ${chalk.red(message)}`,
+			`Could not load ${chalk.blue(descriptor)}.`,
 			1,
 		);
 	});
diff --git a/packages/bingo/src/cli/runCli.test.ts b/packages/bingo/src/cli/runCli.test.ts
index 0c41105..2818383 100644
--- a/packages/bingo/src/cli/runCli.test.ts
+++ b/packages/bingo/src/cli/runCli.test.ts
@@ -4,10 +4,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
 import { runCli } from "./runCli.js";
 import { CLIStatus } from "./status.js";
 
+const mockLog = {
+	error: vi.fn(),
+	message: vi.fn(),
+};
+
 vi.mock("@clack/prompts", () => ({
 	intro: vi.fn(),
-	log: {
-		message: vi.fn(),
+	get log() {
+		return mockLog;
 	},
 }));
 
@@ -71,11 +76,11 @@ vi.mock("./readProductionSettings.js", () => ({
 	},
 }));
 
-const mockLog = vi.fn();
+const mockConsoleLog = vi.fn();
 
 describe("readProductionSettings", () => {
 	beforeEach(() => {
-		console.log = mockLog;
+		console.log = mockConsoleLog;
 	});
 
 	it("logs version when --version is provided", async () => {
@@ -83,7 +88,7 @@ describe("readProductionSettings", () => {
 
 		expect(actual).toBe(CLIStatus.Success);
 		expect(mockLogHelpText).not.toHaveBeenCalled();
-		expect(mockLog).toHaveBeenCalledWith(mockPackageData.version);
+		expect(mockConsoleLog).toHaveBeenCalledWith(mockPackageData.version);
 	});
 
 	it("logs an error outro when readProductionSettings resolves with an error", async () => {
@@ -94,8 +99,8 @@ describe("readProductionSettings", () => {
 		const actual = await runCli([]);
 
 		expect(actual).toBe(CLIStatus.Error);
+		expect(mockConsoleLog).not.toHaveBeenCalled();
 		expect(mockLogHelpText).not.toHaveBeenCalled();
-		expect(mockLog).not.toHaveBeenCalled();
 		expect(mockLogOutro).toHaveBeenCalledWith(chalk.red(message));
 	});
 
@@ -176,6 +181,42 @@ describe("readProductionSettings", () => {
 		expect(mockRunModeSetup).not.toHaveBeenCalled();
 	});
 
+	it("logs a success outro when resolved by the mode runner", async () => {
+		const outro = "Goodbye.";
+		const suggestions = ["abc"];
+
+		mockReadProductionSettings.mockResolvedValueOnce({ mode: "setup" });
+		mockRunModeSetup.mockResolvedValueOnce({
+			outro,
+			status: CLIStatus.Success,
+			suggestions,
+		});
+
+		await runCli(["typescript-app"]);
+
+		expect(mockLogOutro).toHaveBeenCalledWith(outro, { suggestions });
+		expect(mockLog.error).not.toHaveBeenCalled();
+	});
+
+	it("logs an error outro when resolved by the mode runner", async () => {
+		const outro = "Goodbye.";
+		const suggestions = ["abc"];
+		const error = new Error("Oh no!");
+
+		mockReadProductionSettings.mockResolvedValueOnce({ mode: "setup" });
+		mockRunModeSetup.mockResolvedValueOnce({
+			error,
+			outro,
+			status: CLIStatus.Error,
+			suggestions,
+		});
+
+		await runCli(["typescript-app"]);
+
+		expect(mockLogOutro).toHaveBeenCalledWith(outro, { suggestions });
+		expect(mockLog.error).toHaveBeenCalledWith("Error: Oh no!");
+	});
+
 	it("logs the outro when resolved by the mode runner", async () => {
 		const outro = "Goodbye.";
 		const suggestions = ["abc"];
@@ -188,7 +229,7 @@ describe("readProductionSettings", () => {
 		expect(mockLogOutro).toHaveBeenCalledWith(outro, { suggestions });
 	});
 
-	it("logs a cancellation outro when one is not resolved by the mode runner", async () => {
+	it("logs a cancellation outro when one is not resolved by the mode runner", async () => {
 		const suggestions = ["abc"];
 
 		mockReadProductionSettings.mockResolvedValueOnce({ mode: "setup" });
diff --git a/packages/bingo/src/cli/transition/runModeTransition.test.ts b/packages/bingo/src/cli/transition/runModeTransition.test.ts
index ae24df4..7afe5dc 100644
--- a/packages/bingo/src/cli/transition/runModeTransition.test.ts
+++ b/packages/bingo/src/cli/transition/runModeTransition.test.ts
@@ -181,6 +181,7 @@ describe("runModeTransition", () => {
 		});
 
 		expect(actual).toEqual({
+			error,
 			outro: error.message,
 			status: CLIStatus.Error,
 		});
@@ -200,6 +201,7 @@ describe("runModeTransition", () => {
 		});
 
 		expect(actual).toEqual({
+			error,
 			outro: error.message,
 			status: CLIStatus.Error,
 		});
@@ -241,11 +243,13 @@ describe("runModeTransition", () => {
 	});
 
 	it("returns the error when runTemplate resolves with an error", async () => {
+		const error = new Error("Oh no!");
+
 		mockParseTransitionSource.mockReturnValueOnce(source);
 		mockPromptForOptions.mockResolvedValueOnce({
 			prompted: promptedOptions,
 		});
-		mockRunTemplate.mockRejectedValueOnce(new Error("Oh no!"));
+		mockRunTemplate.mockRejectedValueOnce(error);
 
 		const actual = await runModeTransition({
 			args,
@@ -254,6 +258,7 @@ describe("runModeTransition", () => {
 		});
 
 		expect(actual).toEqual({
+			error,
 			outro: `Leaving changes to the local directory on disk. 👋`,
 			status: CLIStatus.Error,
 		});