Repository: JoshuaKGoldberg/template-typescript-node-package (ts)

Issue:
🚀 Feature: Hydration should prompt for missing hydration-specific values
### 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/template-typescript-node-package/issues?q=is%3Aissue) and found none that matched my issue.

### Overview

Right now, when running the _hydration_ script, a set of fields must be provided by the user or inferable from the existing package/repository data. See `hydrate.ts`:

1. `getHydrationDefaults` determines default values from the existing package/repository data
2. `getInputValuesAndOctokit`, as called by `runOrRestore`, prompts for input values that are shared by _hydration_ and _setup_
3. `ensureHydrationInputValues` throws an error if any values aren't provided

Inconvenience: `getInputValuesAndOctokit` doesn't prompt for missing values that are used only in _hydration_. `author` and `email` aren't prompted for if they don't exist.

### Additional Info

Hydration is explained here: https://github.com/JoshuaKGoldberg/template-typescript-node-package/tree/6a0d42d7f07e3a70c7b0dd239c7431c58f43bd69#repository-hydration

Pull request description:
feat: prompt for values in hydration too
## PR Checklist

- [x] Addresses an existing open issue: fixes #500
- [x] That issue was marked as [`status: accepting prs`](https://github.com/JoshuaKGoldberg/template-typescript-node-package/issues?q=is%3Aopen+is%3Aissue+label%3A%22status%3A+accepting+prs%22)
- [x] Steps in [CONTRIBUTING.md](https://github.com/JoshuaKGoldberg/template-typescript-node-package/blob/main/.github/CONTRIBUTING.md) were taken

## Overview

Extracts the prompting logic from _setup_ out to a `PrefillPrompter` class, and re-uses that class in _hydration_. 

There was some defaults-checking (`owner`, `title`) that actually wasn't necessary anymore.

Patch:
diff --git a/src/hydrate/hydrate.ts b/src/hydrate/hydrate.ts
index 62f7548..0268bcb 100644
--- a/src/hydrate/hydrate.ts
+++ b/src/hydrate/hydrate.ts
@@ -12,10 +12,10 @@ import { finalizeDependencies as finalizeDependencies } from "./steps/finalizeDe
 import { writeReadme } from "./steps/writeReadme.js";
 import { writeStructure } from "./steps/writing/writeStructure.js";
 import { getHydrationDefaults } from "./values/getHydrationDefaults.js";
-import { ensureHydrationInputValues } from "./values/hydrationInputValues.js";
+import { augmentWithHydrationValues } from "./values/hydrationInputValues.js";
 
 export async function hydrate(args: string[]) {
-	const { values: hydrationValues } = parseArgs({
+	const { values: hydrationSkips } = parseArgs({
 		args,
 		options: {
 			"skip-install": { type: "boolean" },
@@ -30,27 +30,30 @@ export async function hydrate(args: string[]) {
 		defaults: await getHydrationDefaults(),
 		label: "hydration",
 		run: async ({ octokit, values }) => {
-			ensureHydrationInputValues(values);
+			const hydrationValues = await augmentWithHydrationValues(values);
 
 			await withSpinner(clearUnnecessaryFiles, "clearing unnecessary files");
 
 			await withSpinner(
-				() => writeStructure(values),
+				() => writeStructure(hydrationValues),
 				"writing new repository structure"
 			);
 
-			await withSpinner(() => writeReadme(values), "writing README.md");
+			await withSpinner(
+				() => writeReadme(hydrationValues),
+				"writing README.md"
+			);
 
-			if (hydrationValues["skip-install"]) {
+			if (hydrationSkips["skip-install"]) {
 				skipSpinnerBlock(`Skipping package installations.`);
 			} else {
 				await withSpinner(
-					() => finalizeDependencies(values),
+					() => finalizeDependencies(hydrationValues),
 					"finalizing dependencies"
 				);
 			}
 
-			if (hydrationValues["skip-setup"]) {
+			if (hydrationSkips["skip-setup"]) {
 				skipSpinnerBlock(`Done hydrating, and skipping setup command.`);
 			} else {
 				successSpinnerBlock("Done hydrating. Starting setup command...");
@@ -58,7 +61,7 @@ export async function hydrate(args: string[]) {
 				await setupWithInformation({
 					octokit,
 					values: {
-						...values,
+						...hydrationValues,
 						skipUninstalls: true,
 					},
 				});
diff --git a/src/hydrate/values/getHydrationDefaults.ts b/src/hydrate/values/getHydrationDefaults.ts
index dc85601..691d30f 100644
--- a/src/hydrate/values/getHydrationDefaults.ts
+++ b/src/hydrate/values/getHydrationDefaults.ts
@@ -11,6 +11,7 @@ interface PartialPackageData {
 	name?: string;
 	repository?: string;
 }
+
 export async function getHydrationDefaults() {
 	const existingReadme = await readFileSafe("./README.md", "");
 	const existingPackage = JSON.parse(
diff --git a/src/hydrate/values/hydrationInputValues.ts b/src/hydrate/values/hydrationInputValues.ts
index 45cbaf0..016a233 100644
--- a/src/hydrate/values/hydrationInputValues.ts
+++ b/src/hydrate/values/hydrationInputValues.ts
@@ -1,39 +1,20 @@
 import { InputValues } from "../../shared/inputs.js";
-import { HydrationInputValues } from "./types.js";
+import { PrefillPrompter } from "../../shared/PrefillPrompter.js";
 
-type NullableProperties<T> = {
-	[K in keyof T]: T[K] | undefined;
-};
+export async function augmentWithHydrationValues(values: InputValues) {
+	const prompter = new PrefillPrompter();
 
-export function ensureHydrationInputValues(
-	values: NullableProperties<InputValues>
-): asserts values is HydrationInputValues {
-	const complaints: string[] = [];
-
-	for (const [key, suggestion] of [
-		["description", "add a 'description' to package.json"],
-		["repository", "add a 'name' to package.json"],
-		[
+	return {
+		...values,
+		author: await prompter.getPrefillOrPromptedValue(
 			"author",
-			"add an 'author' to package.json like \"Your Name <your@email.com>\" or an { email, name } object",
-		],
-		[
+			values.author,
+			"What author will be used for the owner?"
+		),
+		email: await prompter.getPrefillOrPromptedValue(
 			"email",
-			"add an 'author' to package.json like \"Your Name <your@email.com>\" or an { email, name } object",
-		],
-		["owner", "add an 'origin' git remote"],
-		["title", "add an h1 to the README.md"],
-	] as const) {
-		if (!values[key]) {
-			complaints.push(
-				`- Could not determine ${key}. Please ${suggestion}, or provide with --${key}.`
-			);
-		}
-	}
-
-	if (complaints.length) {
-		throw new Error(
-			`Failed to determine repository settings:\n${complaints.join("\n")}`
-		);
-	}
+			values.email,
+			"What email will be used for the owner?"
+		),
+	};
 }
diff --git a/src/shared/PrefillPrompter.ts b/src/shared/PrefillPrompter.ts
new file mode 100644
index 0000000..045789a
--- /dev/null
+++ b/src/shared/PrefillPrompter.ts
@@ -0,0 +1,44 @@
+import * as prompts from "@clack/prompts";
+import chalk from "chalk";
+
+import { logLine } from "./cli/lines.js";
+import { handlePromptCancel } from "./prompts.js";
+
+export class PrefillPrompter {
+	#shouldLogLineBeforePrefill = true;
+
+	async getPrefillOrPromptedValue(
+		key: string,
+		existingValue: string | undefined,
+		message: string,
+		placeholder?: string
+	) {
+		if (existingValue) {
+			if (this.#shouldLogLineBeforePrefill) {
+				logLine();
+				this.#shouldLogLineBeforePrefill = false;
+			}
+
+			logLine(chalk.gray(`Pre-filling ${key} to ${existingValue}.`));
+			return existingValue;
+		}
+
+		const value = await prompts.text({
+			message,
+			placeholder,
+			validate: (val) => {
+				if (val.length === 0) {
+					return "Please enter a value.";
+				}
+			},
+		});
+
+		handlePromptCancel(value);
+
+		return value;
+	}
+
+	reset() {
+		this.#shouldLogLineBeforePrefill = true;
+	}
+}
diff --git a/src/shared/inputs.ts b/src/shared/inputs.ts
index 14a718f..4209955 100644
--- a/src/shared/inputs.ts
+++ b/src/shared/inputs.ts
@@ -1,16 +1,13 @@
 import { parseArgs } from "node:util";
 
-import * as prompts from "@clack/prompts";
-import chalk from "chalk";
 import { Octokit } from "octokit";
 import { titleCase } from "title-case";
 
-import { logLine } from "./cli/lines.js";
 import { getDefaultSettings } from "./defaults.js";
 import { ensureRepositoryExists } from "./ensureRepositoryExists.js";
 import { getOctokit } from "./getOctokit.js";
 import { optionalDefault } from "./optionalDefault.js";
-import { handlePromptCancel } from "./prompts.js";
+import { PrefillPrompter } from "./PrefillPrompter.js";
 
 export type GetterDefaultInputValues = {
 	[K in keyof DefaultInputValues]:
@@ -18,12 +15,6 @@ export type GetterDefaultInputValues = {
 		| (() => Promise<DefaultInputValues[K]>);
 };
 
-type StringInputValues = {
-	[K in keyof InputValues as InputValues[K] extends string
-		? K
-		: never]: InputValues[K];
-};
-
 export interface DefaultInputValues {
 	author: string | undefined;
 	description: string;
@@ -53,8 +44,6 @@ export async function getInputValuesAndOctokit(
 	args: string[],
 	defaults: Partial<GetterDefaultInputValues> = {}
 ): Promise<InputValuesAndOctokit> {
-	let shouldLogLineBeforePrefill = true;
-
 	const { defaultOwner, defaultRepository } = await getDefaultSettings();
 	const { values } = parseArgs({
 		args,
@@ -76,35 +65,40 @@ export async function getInputValuesAndOctokit(
 		tokens: true,
 		strict: false,
 	});
+	const prompter = new PrefillPrompter();
 
-	const owner = await getPrefillOrPromptedValue(
+	const owner = await prompter.getPrefillOrPromptedValue(
 		"owner",
+		values.owner as string | undefined,
 		"What owner or user will the repository be under?",
 		defaultOwner
 	);
 
 	const octokit = await getOctokit(!!values["skip-api"]);
 
-	shouldLogLineBeforePrefill = true;
+	prompter.reset();
 
 	const repository = await ensureRepositoryExists(
 		octokit,
 		owner,
-		await getPrefillOrPromptedValue(
+		await prompter.getPrefillOrPromptedValue(
 			"repository",
+			values.repository as string | undefined,
 			"What will the kebab-case name of the repository be?",
 			defaultRepository
 		)
 	);
 
-	const title = await getPrefillOrPromptedValue(
+	const title = await prompter.getPrefillOrPromptedValue(
 		"title",
+		values.title as string | undefined,
 		"What will the Title Case title of the repository be?",
 		titleCase(repository).replaceAll("-", " ")
 	);
 
-	const description = await getPrefillOrPromptedValue(
+	const description = await prompter.getPrefillOrPromptedValue(
 		"description",
+		values.description as string | undefined,
 		"How would you describe the new package?",
 		"A very lovely package. Hooray!"
 	);
@@ -142,35 +136,4 @@ export async function getInputValuesAndOctokit(
 			title,
 		},
 	};
-
-	async function getPrefillOrPromptedValue<Key extends keyof StringInputValues>(
-		key: Key,
-		message: string,
-		placeholder: string
-	): Promise<StringInputValues[Key]> {
-		const existing = values[key] as string | undefined;
-		if (existing) {
-			if (shouldLogLineBeforePrefill) {
-				logLine();
-				shouldLogLineBeforePrefill = false;
-			}
-
-			logLine(chalk.gray(`Pre-filling ${key} to ${existing}.`));
-			return existing;
-		}
-
-		const value = await prompts.text({
-			message,
-			placeholder,
-			validate: (val) => {
-				if (val.length === 0) {
-					return "Please enter a value.";
-				}
-			},
-		});
-
-		handlePromptCancel(value);
-
-		return value;
-	}
 }


Test patch:
diff --git a/src/hydrate/values/hydrationInputValues.test.ts b/src/hydrate/values/hydrationInputValues.test.ts
deleted file mode 100644
index 90c10c3..0000000
--- a/src/hydrate/values/hydrationInputValues.test.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import { describe, expect, it } from "vitest";
-
-import { ensureHydrationInputValues } from "./hydrationInputValues.js";
-
-describe("ensureHydrationInputValues", () => {
-	it("throws an error when no settings are filled out", () => {
-		expect(() =>
-			ensureHydrationInputValues({
-				author: undefined,
-				description: undefined,
-				email: undefined,
-				funding: undefined,
-				owner: undefined,
-				releases: undefined,
-				repository: undefined,
-				title: undefined,
-				unitTests: undefined,
-				skipApi: undefined,
-				skipRemoval: undefined,
-				skipRestore: undefined,
-				skipUninstalls: undefined,
-			})
-		).toThrow(`Failed to determine repository settings:
-- Could not determine description. Please add a 'description' to package.json, or provide with --description.
-- Could not determine repository. Please add a 'name' to package.json, or provide with --repository.
-- Could not determine author. Please add an 'author' to package.json like "Your Name <your@email.com>" or an { email, name } object, or provide with --author.
-- Could not determine email. Please add an 'author' to package.json like "Your Name <your@email.com>" or an { email, name } object, or provide with --email.
-- Could not determine owner. Please add an 'origin' git remote, or provide with --owner.
-- Could not determine title. Please add an h1 to the README.md, or provide with --title.`);
-	});
-
-	it("does not throw an error when all required settings are filled out", () => {
-		expect(() =>
-			ensureHydrationInputValues({
-				author: "Test Author",
-				description: "test description",
-				email: "test@email.com",
-				funding: "TestFunding",
-				owner: "TestOwner",
-				releases: true,
-				repository: "test-repository",
-				title: "Test Title",
-				unitTests: true,
-				skipApi: undefined,
-				skipRemoval: undefined,
-				skipRestore: undefined,
-				skipUninstalls: undefined,
-			})
-		).not.toThrow();
-	});
-});
diff --git a/src/shared/PrefillPrompter.test.ts b/src/shared/PrefillPrompter.test.ts
new file mode 100644
index 0000000..faf2b3b
--- /dev/null
+++ b/src/shared/PrefillPrompter.test.ts
@@ -0,0 +1,75 @@
+import chalk from "chalk";
+import { describe, expect, it, vi } from "vitest";
+
+import { PrefillPrompter } from "./PrefillPrompter.js";
+
+const mockText = vi.fn();
+
+vi.mock("@clack/prompts", () => ({
+	isCancel: () => false,
+	get text() {
+		return mockText;
+	},
+}));
+
+const mockLogLine = vi.fn();
+
+vi.mock("./cli/lines.js", () => ({
+	get logLine() {
+		return mockLogLine;
+	},
+}));
+
+describe("PrefillPrompter", () => {
+	it("logs a line and a pre-fill message when a first value already exists", async () => {
+		const prompter = new PrefillPrompter();
+		const existing = "existing value";
+
+		const actual = await prompter.getPrefillOrPromptedValue(
+			"key",
+			existing,
+			""
+		);
+
+		expect(actual).toEqual(existing);
+		expect(mockLogLine.mock.calls).toEqual([
+			[],
+			[chalk.gray(`Pre-filling key to existing value.`)],
+		]);
+	});
+
+	it("logs only a pre-fill message when a second value already exists", async () => {
+		const prompter = new PrefillPrompter();
+
+		await prompter.getPrefillOrPromptedValue("key 1", "existing 1", "");
+
+		const actual = await prompter.getPrefillOrPromptedValue(
+			"key 2",
+			"existing 2",
+			""
+		);
+
+		expect(actual).toEqual("existing 2");
+		expect(mockLogLine.mock.calls).toEqual([
+			[],
+			[chalk.gray(`Pre-filling key 1 to existing 1.`)],
+			[chalk.gray(`Pre-filling key 2 to existing 2.`)],
+		]);
+	});
+
+	it("prompts for a new value when the value doesn't already exist", async () => {
+		const prompter = new PrefillPrompter();
+		const expected = "expected value";
+
+		mockText.mockResolvedValue(expected);
+
+		const actual = await prompter.getPrefillOrPromptedValue(
+			"key",
+			undefined,
+			""
+		);
+
+		expect(actual).toEqual(expected);
+		expect(mockLogLine).not.toHaveBeenCalled();
+	});
+});