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

Issue:
🐛 Bug: Hydration should preserve previously existing package dependencies
### 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.

### Expected

Running `npx template-typescript-node-package` (the hydration script) on a repository should preserve any `"dependencies"` entries in the repository's `package.json`.

### Actual

I ran `npx template-typescript-node-package` on https://github.com/JoshuaKGoldberg/mock-react-redux/tree/8048b5c8f3c7bbaaf875387022588e95f46050de. It mostly succeeded. But the resultant `package.json` had no `dependencies`. Which isn't good - the package right now has some `dependencies`!

https://github.com/JoshuaKGoldberg/mock-react-redux/blob/8048b5c8f3c7bbaaf875387022588e95f46050de/package.json#L67

### Additional Info

Hydration is explained here: https://github.com/JoshuaKGoldberg/template-typescript-node-package/tree/6a0d42d7f07e3a70c7b0dd239c7431c58f43bd69#repository-hydration

Pull request:
fix: respect existing package.json contents in hydration
## PR Checklist

- [x] Addresses an existing open issue: fixes #503
- [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

Splits the creation of new `package.json` contents into a new `writePackageJson` file. It reads the existing `package.json` as JSON and uses all previously existing fields by default.

Note that this PR also standardizes from `JSON` to `Json` in file names.

Patch:
diff --git a/src/hydrate/steps/writing/creation/index.ts b/src/hydrate/steps/writing/creation/index.ts
index eaac782..619d97c 100644
--- a/src/hydrate/steps/writing/creation/index.ts
+++ b/src/hydrate/steps/writing/creation/index.ts
@@ -5,11 +5,13 @@ import { createDotHusky } from "./dotHusky.js";
 import { createDotVSCode } from "./dotVSCode.js";
 import { createRootFiles } from "./rootFiles.js";
 
-export function createStructure(values: HydrationInputValues): Structure {
+export async function createStructure(
+	values: HydrationInputValues
+): Promise<Structure> {
 	return {
 		".github": createDotGitHub(values),
 		".husky": createDotHusky(),
 		".vscode": createDotVSCode(),
-		...createRootFiles(values),
+		...(await createRootFiles(values)),
 	};
 }
diff --git a/src/hydrate/steps/writing/creation/rootFiles.ts b/src/hydrate/steps/writing/creation/rootFiles.ts
index 4724695..0048d6e 100644
--- a/src/hydrate/steps/writing/creation/rootFiles.ts
+++ b/src/hydrate/steps/writing/creation/rootFiles.ts
@@ -1,16 +1,9 @@
 import { HydrationInputValues } from "../../../values/types.js";
 import { formatIgnoreFile } from "./formatters/formatIgnoreFile.js";
 import { formatJson } from "./formatters/formatJson.js";
+import { writePackageJson } from "./writePackageJson.js";
 
-export function createRootFiles({
-	author,
-	description,
-	email,
-	owner,
-	releases,
-	repository,
-	unitTests,
-}: HydrationInputValues) {
+export async function createRootFiles(values: HydrationInputValues) {
 	return {
 		".all-contributorsrc": formatJson({
 			badgeTemplate:
@@ -22,14 +15,14 @@ export function createRootFiles({
 			contributorsSortAlphabetically: true,
 			files: ["README.md"],
 			imageSize: 100,
-			projectName: repository,
-			projectOwner: owner,
+			projectName: values.repository,
+			projectOwner: values.owner,
 			repoHost: "https://github.com",
 			repoType: "github",
 		}),
 		".eslintignore": formatIgnoreFile([
 			"!.*",
-			...(unitTests ? ["coverage"] : []),
+			...(values.unitTests ? ["coverage"] : []),
 			"lib",
 			"node_modules",
 			"pnpm-lock.yaml",
@@ -106,7 +99,7 @@ module.exports = {
 			},
 			extends: ["plugin:jsonc/recommended-with-json"],
 		},${
-			unitTests
+			values.unitTests
 				? `\n{
 			files: "**/*.test.ts",
 			rules: {
@@ -145,17 +138,17 @@ module.exports = {
 		"@typescript-eslint",
 		"deprecation",
 		"import",
-		"jsdoc",${unitTests ? `\n"no-only-tests",` : ""}
+		"jsdoc",${values.unitTests ? `\n"no-only-tests",` : ""}
 		"regexp",
 		"simple-import-sort",
-		"typescript-sort-keys",${unitTests ? `\n"vitest",` : ""}
+		"typescript-sort-keys",${values.unitTests ? `\n"vitest",` : ""}
 	],
 	root: true,
 	rules: {
 		// These off/less-strict-by-default rules work well for this repo and we like them on.
 		"@typescript-eslint/no-unused-vars": ["error", { caughtErrors: "all" }],
 		"import/extensions": ["error", "ignorePackages"],${
-			unitTests ? `\n"no-only-tests/no-only-tests": "error",` : ""
+			values.unitTests ? `\n"no-only-tests/no-only-tests": "error",` : ""
 		}
 		"simple-import-sort/exports": "error",
 		"simple-import-sort/imports": "error",
@@ -175,7 +168,7 @@ module.exports = {
 };
 `,
 		".gitignore": formatIgnoreFile([
-			...(unitTests ? ["coverage/"] : []),
+			...(values.unitTests ? ["coverage/"] : []),
 			"lib/",
 			"node_modules/",
 		]),
@@ -199,7 +192,7 @@ module.exports = {
 		}),
 		".nvmrc": `18.16.0\n`,
 		".prettierignore": formatIgnoreFile([
-			...(unitTests ? ["coverage/"] : []),
+			...(values.unitTests ? ["coverage/"] : []),
 			"lib/",
 			"pnpm-lock.yaml",
 			"",
@@ -258,7 +251,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 			ignorePaths: [
 				".github",
 				"CHANGELOG.md",
-				...(unitTests ? ["coverage"] : []),
+				...(values.unitTests ? ["coverage"] : []),
 				"lib",
 				"node_modules",
 				"pnpm-lock.yaml",
@@ -287,46 +280,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 			ignoreBinaries: ["dedupe", "gh"],
 			project: ["src/**/*.ts!", "script/**/*.js"],
 		}),
-		"package.json": formatJson({
-			name: repository,
-			description,
-			repository: {
-				type: "git",
-				url: `https://github.com/${owner}/${repository}`,
-			},
-			license: "MIT",
-			author: { email, name: author },
-			type: "module",
-			main: "./lib/index.js",
-			files: ["lib/", "package.json", "LICENSE.md", "README.md"],
-			scripts: {
-				build: "tsc",
-				format: 'prettier "**/*" --ignore-unknown',
-				"format:write": "pnpm format --write",
-				lint: "eslint . --max-warnings 0 --report-unused-disable-directives",
-				"lint:knip": "knip",
-				"lint:md":
-					'markdownlint "**/*.md" ".github/**/*.md" --rules sentences-per-line',
-				"lint:package": "npmPkgJsonLint .",
-				"lint:packages": "pnpm dedupe --check",
-				"lint:spelling": 'cspell "**" ".github/**/*"',
-				prepare: "husky install",
-				...(releases && {
-					"should-semantic-release": "should-semantic-release --verbose",
-				}),
-				...(unitTests && { test: "vitest" }),
-			},
-			"lint-staged": {
-				"*": "prettier --ignore-unknown --write",
-			},
-			packageManager: "pnpm@8.5.0",
-			engines: {
-				node: ">=18",
-			},
-			publishConfig: {
-				provenance: true,
-			},
-		}),
+		"package.json": await writePackageJson(values),
 		"tsconfig.eslint.json": formatJson({
 			extends: "./tsconfig.json",
 			include: ["."],
@@ -346,7 +300,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 			},
 			include: ["src"],
 		}),
-		...(unitTests && {
+		...(values.unitTests && {
 			"vitest.config.ts": `import { defineConfig } from "vitest/config";
 
 export default defineConfig({
diff --git a/src/hydrate/steps/writing/creation/writePackageJson.ts b/src/hydrate/steps/writing/creation/writePackageJson.ts
new file mode 100644
index 0000000..4eba58c
--- /dev/null
+++ b/src/hydrate/steps/writing/creation/writePackageJson.ts
@@ -0,0 +1,73 @@
+import { readFileAsJson } from "../../../../shared/readFileAsJson.js";
+import { HydrationInputValues } from "../../../values/types.js";
+import { formatJson } from "./formatters/formatJson.js";
+
+export async function writePackageJson({
+	author,
+	description,
+	email,
+	owner,
+	releases,
+	repository,
+	unitTests,
+}: Pick<
+	HydrationInputValues,
+	| "author"
+	| "description"
+	| "email"
+	| "owner"
+	| "releases"
+	| "repository"
+	| "unitTests"
+>) {
+	return formatJson({
+		// To start, copy over all existing package fields (e.g. "dependencies")
+		...((await readFileAsJson("./package.json")) as object),
+
+		// Remove fields we know we don't want, such as old or redundant configs
+		eslintConfig: undefined,
+		husky: undefined,
+		prettierConfig: undefined,
+		types: undefined,
+
+		// The rest of the fields are ones we know from our template
+		name: repository,
+		description,
+		repository: {
+			type: "git",
+			url: `https://github.com/${owner}/${repository}`,
+		},
+		license: "MIT",
+		author: { email, name: author },
+		type: "module",
+		main: "./lib/index.js",
+		files: ["lib/", "package.json", "LICENSE.md", "README.md"],
+		scripts: {
+			build: "tsc",
+			format: 'prettier "**/*" --ignore-unknown',
+			"format:write": "pnpm format --write",
+			lint: "eslint . --max-warnings 0 --report-unused-disable-directives",
+			"lint:knip": "knip",
+			"lint:md":
+				'markdownlint "**/*.md" ".github/**/*.md" --rules sentences-per-line',
+			"lint:package": "npmPkgJsonLint .",
+			"lint:packages": "pnpm dedupe --check",
+			"lint:spelling": 'cspell "**" ".github/**/*"',
+			prepare: "husky install",
+			...(releases && {
+				"should-semantic-release": "should-semantic-release --verbose",
+			}),
+			...(unitTests && { test: "vitest" }),
+		},
+		"lint-staged": {
+			"*": "prettier --ignore-unknown --write",
+		},
+		packageManager: "pnpm@8.5.0",
+		engines: {
+			node: ">=18",
+		},
+		publishConfig: {
+			provenance: true,
+		},
+	});
+}
diff --git a/src/setup/settings/addOwnerAsAllContributor.ts b/src/setup/settings/addOwnerAsAllContributor.ts
index 36486c9..05bc73f 100644
--- a/src/setup/settings/addOwnerAsAllContributor.ts
+++ b/src/setup/settings/addOwnerAsAllContributor.ts
@@ -4,7 +4,7 @@ import chalk from "chalk";
 import { $ } from "execa";
 import prettier from "prettier";
 
-import { readFileAsJSON } from "../readFileAsJSON.js";
+import { readFileAsJson } from "../../shared/readFileAsJson.js";
 
 interface GhUserOutput {
 	login: string;
@@ -34,7 +34,7 @@ export async function addOwnerAsAllContributor(owner: string) {
 		"tool",
 	].join(",")}`;
 
-	const existingContributors = (await readFileAsJSON(
+	const existingContributors = (await readFileAsJson(
 		"./.all-contributorsrc"
 	)) as AllContributorsData;
 	if (!isValidAllContributorsData(existingContributors)) {
diff --git a/src/setup/steps/labels/hydrateRepositoryLabels.ts b/src/setup/steps/labels/hydrateRepositoryLabels.ts
index fa6be25..8e9cbc7 100644
--- a/src/setup/steps/labels/hydrateRepositoryLabels.ts
+++ b/src/setup/steps/labels/hydrateRepositoryLabels.ts
@@ -1,6 +1,6 @@
 import { $ } from "execa";
 
-import { readFileAsJSON } from "../../readFileAsJSON.js";
+import { readFileAsJson } from "../../../shared/readFileAsJson.js";
 import { getExistingEquivalentLabel } from "./getExistingEquivalentLabel.js";
 
 interface GhLabelData {
@@ -22,7 +22,7 @@ export async function hydrateRepositoryLabels() {
 		) as GhLabelData[]
 	).map(getLabelName);
 
-	const outcomeLabels = (await readFileAsJSON(
+	const outcomeLabels = (await readFileAsJson(
 		"./src/setup/labels.json"
 	)) as FileLabelData[];
 
diff --git a/src/setup/steps/updateLocalFiles.ts b/src/setup/steps/updateLocalFiles.ts
index eaa8d51..a95fdca 100644
--- a/src/setup/steps/updateLocalFiles.ts
+++ b/src/setup/steps/updateLocalFiles.ts
@@ -1,6 +1,6 @@
 import replaceInFile from "replace-in-file";
 
-import { readFileAsJSON } from "../readFileAsJSON.js";
+import { readFileAsJson } from "../../shared/readFileAsJson.js";
 
 interface UpdateLocalFilesOptions {
 	description: string;
@@ -22,7 +22,7 @@ export async function updateLocalFiles({
 	repository,
 	title,
 }: UpdateLocalFilesOptions) {
-	const existingPackage = (await readFileAsJSON(
+	const existingPackage = (await readFileAsJson(
 		"./package.json"
 	)) as ExistingPackageData;
 
diff --git a/src/setup/readFileAsJSON.ts b/src/shared/readFileAsJson.ts
similarity index 72%
rename from src/setup/readFileAsJSON.ts
rename to src/shared/readFileAsJson.ts
index e20f579..f49083c 100644
--- a/src/setup/readFileAsJSON.ts
+++ b/src/shared/readFileAsJson.ts
@@ -1,6 +1,6 @@
-import { promises as fs } from "fs";
+import * as fs from "node:fs/promises";
 
-export async function readFileAsJSON(filePath: string) {
+export async function readFileAsJson(filePath: string) {
 	try {
 		return JSON.parse((await fs.readFile(filePath)).toString()) as unknown;
 	} catch (error) {


Test patch:
diff --git a/src/hydrate/steps/writing/creation/writePackageJson.test.ts b/src/hydrate/steps/writing/creation/writePackageJson.test.ts
new file mode 100644
index 0000000..429029c
--- /dev/null
+++ b/src/hydrate/steps/writing/creation/writePackageJson.test.ts
@@ -0,0 +1,68 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { writePackageJson } from "./writePackageJson.js";
+
+const mockReadFileAsJson = vi.fn();
+
+vi.mock("../../../../shared/readFileAsJson.js", () => ({
+	get readFileAsJson() {
+		return mockReadFileAsJson;
+	},
+}));
+
+const values = {
+	author: "test-author",
+	description: "test-description",
+	email: "test-email",
+	owner: "test-owner",
+	releases: false,
+	repository: "test-repository",
+	unitTests: false,
+};
+
+describe("writePackageJson", () => {
+	it("preserves existing dependencies when they exist", async () => {
+		const dependencies = { abc: "1.2.3" };
+		mockReadFileAsJson.mockResolvedValue({ dependencies });
+
+		const packageJson = await writePackageJson(values);
+
+		expect(JSON.parse(packageJson)).toEqual(
+			expect.objectContaining({ dependencies })
+		);
+	});
+
+	it("includes a release script when releases is true", async () => {
+		mockReadFileAsJson.mockResolvedValue({});
+
+		const packageJson = await writePackageJson({
+			...values,
+			releases: true,
+		});
+
+		expect(JSON.parse(packageJson)).toEqual(
+			expect.objectContaining({
+				scripts: expect.objectContaining({
+					"should-semantic-release": "should-semantic-release --verbose",
+				}),
+			})
+		);
+	});
+
+	it("includes a test script when unitTests is true", async () => {
+		mockReadFileAsJson.mockResolvedValue({});
+
+		const packageJson = await writePackageJson({
+			...values,
+			unitTests: true,
+		});
+
+		expect(JSON.parse(packageJson)).toEqual(
+			expect.objectContaining({
+				scripts: expect.objectContaining({
+					test: "vitest",
+				}),
+			})
+		);
+	});
+});
diff --git a/src/hydrate/steps/writing/writeStructure.ts b/src/hydrate/steps/writing/writeStructure.ts
index 94c9a8b..34c5604 100644
--- a/src/hydrate/steps/writing/writeStructure.ts
+++ b/src/hydrate/steps/writing/writeStructure.ts
@@ -3,5 +3,5 @@ import { createStructure } from "./creation/index.js";
 import { writeStructureWorker } from "./writeStructureWorker.js";
 
 export async function writeStructure(values: HydrationInputValues) {
-	return await writeStructureWorker(createStructure(values), ".");
+	return await writeStructureWorker(await createStructure(values), ".");
 }
diff --git a/src/shared/readFileAsJson.test.ts b/src/shared/readFileAsJson.test.ts
new file mode 100644
index 0000000..3049e70
--- /dev/null
+++ b/src/shared/readFileAsJson.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { readFileAsJson } from "./readFileAsJson.js";
+
+const mockReadFile = vi.fn();
+
+vi.mock("node:fs/promises", () => ({
+	get readFile() {
+		return mockReadFile;
+	},
+}));
+
+describe("readFileAsJson", () => {
+	it("returns the file's parsed contents when it exists", async () => {
+		const data = { abc: 123 };
+
+		mockReadFile.mockResolvedValue(JSON.stringify(data));
+
+		const actual = await readFileAsJson("filePath.json");
+
+		expect(actual).toEqual(data);
+	});
+
+	it("throws an error when the file doesn't exist", async () => {
+		const error = new Error("Oh no!");
+
+		mockReadFile.mockRejectedValue(error);
+
+		await expect(() => readFileAsJson("filePath.json")).rejects.toEqual(
+			new Error(
+				`Could not read file from filePath.json as JSON. Please ensure the file exists and is valid JSON.`,
+				{ cause: error }
+			)
+		);
+	});
+});