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

Issue:
🚀 Feature: updateLocalFiles should allow a blank package.json
### 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, `updateLocalFiles` calls to `readFileAsJson`. But on a similar line to #669, I want to make sure it's resilient to a blank `package.json`. So let's go with `readFileSafeAsJson` instead. 

### Additional Info

Mostly filing this issue to track my wanting to add unit test coverage to `updateLocalFiles.ts`. 😄 

Pull request description:
feat: allow blank package.json in readFileSafeAsJson
## PR Checklist

- [x] Addresses an existing open issue: fixes #678
- [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

Adds unit test coverage too.

Patch:
diff --git a/package.json b/package.json
index 60c8c2f..9dba8f6 100644
--- a/package.json
+++ b/package.json
@@ -45,7 +45,7 @@
 		"@clack/prompts": "^0.7.0",
 		"all-contributors-for-repository": "^0.0.10",
 		"chalk": "^5.3.0",
-		"execa": "^7.2.0",
+		"execa": "^8.0.0",
 		"git-remote-origin-url": "^4.0.0",
 		"git-url-parse": "^13.1.0",
 		"js-yaml": "^4.1.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index cc4aa57..a10c03a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -15,8 +15,8 @@ dependencies:
     specifier: ^5.3.0
     version: 5.3.0
   execa:
-    specifier: ^7.2.0
-    version: 7.2.0
+    specifier: ^8.0.0
+    version: 8.0.0
   git-remote-origin-url:
     specifier: ^4.0.0
     version: 4.0.0
@@ -4024,6 +4024,22 @@ packages:
       onetime: 6.0.0
       signal-exit: 3.0.7
       strip-final-newline: 3.0.0
+    dev: true
+
+  /execa@8.0.0:
+    resolution: {integrity: sha512-CTNS0BcKBcoOsawKBlpcKNmK4Kjuyz5jVLhf+PUsHGMqiKMVTa4cN3U7r7bRY8KTpfOGpXMo27fdy0dYVg2pqA==}
+    engines: {node: '>=16.17'}
+    dependencies:
+      cross-spawn: 7.0.3
+      get-stream: 8.0.1
+      human-signals: 5.0.0
+      is-stream: 3.0.0
+      merge-stream: 2.0.0
+      npm-run-path: 5.1.0
+      onetime: 6.0.0
+      signal-exit: 4.1.0
+      strip-final-newline: 3.0.0
+    dev: false
 
   /external-editor@3.1.0:
     resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==}
@@ -4172,7 +4188,7 @@ packages:
     engines: {node: '>=14'}
     dependencies:
       cross-spawn: 7.0.3
-      signal-exit: 4.0.1
+      signal-exit: 4.1.0
     dev: true
 
   /form-data-encoder@2.1.4:
@@ -4263,6 +4279,11 @@ packages:
     resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==}
     engines: {node: '>=10'}
 
+  /get-stream@8.0.1:
+    resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==}
+    engines: {node: '>=16'}
+    dev: false
+
   /get-symbol-description@1.0.0:
     resolution: {integrity: sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==}
     engines: {node: '>= 0.4'}
@@ -4610,6 +4631,12 @@ packages:
   /human-signals@4.3.1:
     resolution: {integrity: sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==}
     engines: {node: '>=14.18.0'}
+    dev: true
+
+  /human-signals@5.0.0:
+    resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==}
+    engines: {node: '>=16.17.0'}
+    dev: false
 
   /husky@8.0.3:
     resolution: {integrity: sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg==}
@@ -6973,11 +7000,11 @@ packages:
 
   /signal-exit@3.0.7:
     resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
+    dev: true
 
-  /signal-exit@4.0.1:
-    resolution: {integrity: sha512-uUWsN4aOxJAS8KOuf3QMyFtgm1pkb6I+KRZbRF/ghdf5T7sM+B1lLLzPDxswUjkmHyxQAVzEgG35E3NzDM9GVw==}
+  /signal-exit@4.1.0:
+    resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
     engines: {node: '>=14'}
-    dev: true
 
   /sisteransi@1.0.5:
     resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
diff --git a/src/initialize/steps/updateLocalFiles.ts b/src/initialize/steps/updateLocalFiles.ts
index d38381a..51045ff 100644
--- a/src/initialize/steps/updateLocalFiles.ts
+++ b/src/initialize/steps/updateLocalFiles.ts
@@ -1,6 +1,6 @@
 import replaceInFile from "replace-in-file";
 
-import { readFileAsJson } from "../../shared/readFileAsJson.js";
+import { readFileSafeAsJson } from "../../shared/readFileSafeAsJson.js";
 
 interface UpdateLocalFilesOptions {
 	description: string;
@@ -22,11 +22,11 @@ export async function updateLocalFiles({
 	repository,
 	title,
 }: UpdateLocalFilesOptions) {
-	const existingPackage = (await readFileAsJson(
+	const existingPackage = (await readFileSafeAsJson(
 		"./package.json",
 	)) as ExistingPackageData;
 
-	const replacements: [RegExp | string, string, string?][] = [
+	const replacements = [
 		[/Template TypeScript Node Package/g, title],
 		[/JoshuaKGoldberg/g, owner],
 		[/template-typescript-node-package/g, repository],


Test patch:
diff --git a/src/initialize/steps/updateLocalFiles.test.ts b/src/initialize/steps/updateLocalFiles.test.ts
new file mode 100644
index 0000000..154c750
--- /dev/null
+++ b/src/initialize/steps/updateLocalFiles.test.ts
@@ -0,0 +1,318 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { updateLocalFiles } from "./updateLocalFiles.js";
+
+const mockReplaceInFile = vi.fn();
+
+vi.mock("replace-in-file", () => ({
+	get default() {
+		return mockReplaceInFile;
+	},
+}));
+
+const mockReadFileSafeAsJson = vi.fn();
+
+vi.mock("../../shared/readFileSafeAsJson.js", () => ({
+	get readFileSafeAsJson() {
+		return mockReadFileSafeAsJson;
+	},
+}));
+
+const stubOptions = {
+	description: "Stub description.",
+	npmAuthor: "stub-npm-author",
+	owner: "StubOwner",
+	repository: "stub-repository",
+	title: "Stub Title",
+};
+
+describe("updateLocalFiles", () => {
+	it("throws a wrapping error when replaceInFiles rejects", async () => {
+		const error = new Error("Oh no!");
+
+		mockReadFileSafeAsJson.mockResolvedValue({});
+		mockReplaceInFile.mockRejectedValue(error);
+
+		await expect(async () => {
+			await updateLocalFiles(stubOptions);
+		}).rejects.toThrowErrorMatchingInlineSnapshot(
+			'"Failed to replace /Template TypeScript Node Package/g with Stub Title in ./.github/**/*,./*.*"',
+		);
+	});
+
+	it("replaces using the common replacements when the existing package data is empty", async () => {
+		mockReadFileSafeAsJson.mockResolvedValue({});
+		mockReplaceInFile.mockResolvedValue([]);
+
+		await updateLocalFiles(stubOptions);
+
+		expect(mockReplaceInFile.mock.calls).toMatchInlineSnapshot(`
+			[
+			  [
+			    {
+			      "files": [
+			        "./.github/**/*",
+			        "./*.*",
+			      ],
+			      "from": /Template TypeScript Node Package/g,
+			      "to": "Stub Title",
+			    },
+			  ],
+			  [
+			    {
+			      "files": [
+			        "./.github/**/*",
+			        "./*.*",
+			      ],
+			      "from": /JoshuaKGoldberg/g,
+			      "to": "StubOwner",
+			    },
+			  ],
+			  [
+			    {
+			      "files": [
+			        "./.github/**/*",
+			        "./*.*",
+			      ],
+			      "from": /template-typescript-node-package/g,
+			      "to": "stub-repository",
+			    },
+			  ],
+			  [
+			    {
+			      "files": ".eslintrc.cjs",
+			      "from": /\\\\/\\\\\\*\\\\n\\.\\+\\\\\\*\\\\/\\\\n\\\\n/gs,
+			      "to": "",
+			    },
+			  ],
+			  [
+			    {
+			      "files": "./package.json",
+			      "from": /"author": "\\.\\+"/g,
+			      "to": "\\"author\\": \\"stub-npm-author\\"",
+			    },
+			  ],
+			  [
+			    {
+			      "files": "./package.json",
+			      "from": /"bin": "\\.\\+\\\\n/g,
+			      "to": "",
+			    },
+			  ],
+			  [
+			    {
+			      "files": "./package.json",
+			      "from": /"initialize:test": "\\.\\*/g,
+			      "to": "",
+			    },
+			  ],
+			  [
+			    {
+			      "files": "./package.json",
+			      "from": /"initialize": "\\.\\*/g,
+			      "to": "",
+			    },
+			  ],
+			  [
+			    {
+			      "files": "./package.json",
+			      "from": /"migrate:test": "\\.\\+\\\\n/g,
+			      "to": "",
+			    },
+			  ],
+			  [
+			    {
+			      "files": "./README.md",
+			      "from": /## Getting Started\\.\\*## Development/gs,
+			      "to": "## Development",
+			    },
+			  ],
+			  [
+			    {
+			      "files": "./.github/DEVELOPMENT.md",
+			      "from": /\\\\n## Setup Scripts\\.\\*\\$/gs,
+			      "to": "",
+			    },
+			  ],
+			  [
+			    {
+			      "files": "./knip.jsonc",
+			      "from": "		\\"src/initialize/index.ts\\",
+			",
+			      "to": "",
+			    },
+			  ],
+			  [
+			    {
+			      "files": "./knip.jsonc",
+			      "from": "		\\"src/migrate/index.ts\\",
+			",
+			      "to": "",
+			    },
+			  ],
+			  [
+			    {
+			      "files": "./knip.jsonc",
+			      "from": "[\\"src/index.ts!\\", \\"script/initialize*.js\\"]",
+			      "to": "\\"src/index.ts!\\"",
+			    },
+			  ],
+			  [
+			    {
+			      "files": "./knip.jsonc",
+			      "from": "[\\"src/**/*.ts!\\", \\"script/**/*.js\\"]",
+			      "to": "\\"src/**/*.ts!\\"",
+			    },
+			  ],
+			]
+		`);
+	});
+
+	it("replaces using the extra replacements when the existing package data is full", async () => {
+		mockReadFileSafeAsJson.mockResolvedValue({
+			description: "Existing description",
+			version: "1.2.3",
+		});
+		mockReplaceInFile.mockResolvedValue([]);
+
+		await updateLocalFiles(stubOptions);
+
+		expect(mockReplaceInFile.mock.calls).toMatchInlineSnapshot(`
+			[
+			  [
+			    {
+			      "files": [
+			        "./.github/**/*",
+			        "./*.*",
+			      ],
+			      "from": /Template TypeScript Node Package/g,
+			      "to": "Stub Title",
+			    },
+			  ],
+			  [
+			    {
+			      "files": [
+			        "./.github/**/*",
+			        "./*.*",
+			      ],
+			      "from": /JoshuaKGoldberg/g,
+			      "to": "StubOwner",
+			    },
+			  ],
+			  [
+			    {
+			      "files": [
+			        "./.github/**/*",
+			        "./*.*",
+			      ],
+			      "from": /template-typescript-node-package/g,
+			      "to": "stub-repository",
+			    },
+			  ],
+			  [
+			    {
+			      "files": ".eslintrc.cjs",
+			      "from": /\\\\/\\\\\\*\\\\n\\.\\+\\\\\\*\\\\/\\\\n\\\\n/gs,
+			      "to": "",
+			    },
+			  ],
+			  [
+			    {
+			      "files": "./package.json",
+			      "from": /"author": "\\.\\+"/g,
+			      "to": "\\"author\\": \\"stub-npm-author\\"",
+			    },
+			  ],
+			  [
+			    {
+			      "files": "./package.json",
+			      "from": /"bin": "\\.\\+\\\\n/g,
+			      "to": "",
+			    },
+			  ],
+			  [
+			    {
+			      "files": "./package.json",
+			      "from": /"initialize:test": "\\.\\*/g,
+			      "to": "",
+			    },
+			  ],
+			  [
+			    {
+			      "files": "./package.json",
+			      "from": /"initialize": "\\.\\*/g,
+			      "to": "",
+			    },
+			  ],
+			  [
+			    {
+			      "files": "./package.json",
+			      "from": /"migrate:test": "\\.\\+\\\\n/g,
+			      "to": "",
+			    },
+			  ],
+			  [
+			    {
+			      "files": "./README.md",
+			      "from": /## Getting Started\\.\\*## Development/gs,
+			      "to": "## Development",
+			    },
+			  ],
+			  [
+			    {
+			      "files": "./.github/DEVELOPMENT.md",
+			      "from": /\\\\n## Setup Scripts\\.\\*\\$/gs,
+			      "to": "",
+			    },
+			  ],
+			  [
+			    {
+			      "files": "./knip.jsonc",
+			      "from": "		\\"src/initialize/index.ts\\",
+			",
+			      "to": "",
+			    },
+			  ],
+			  [
+			    {
+			      "files": "./knip.jsonc",
+			      "from": "		\\"src/migrate/index.ts\\",
+			",
+			      "to": "",
+			    },
+			  ],
+			  [
+			    {
+			      "files": "./knip.jsonc",
+			      "from": "[\\"src/index.ts!\\", \\"script/initialize*.js\\"]",
+			      "to": "\\"src/index.ts!\\"",
+			    },
+			  ],
+			  [
+			    {
+			      "files": "./knip.jsonc",
+			      "from": "[\\"src/**/*.ts!\\", \\"script/**/*.js\\"]",
+			      "to": "\\"src/**/*.ts!\\"",
+			    },
+			  ],
+			  [
+			    {
+			      "files": [
+			        "./.github/**/*",
+			        "./*.*",
+			      ],
+			      "from": /Existing description/g,
+			      "to": "Stub description.",
+			    },
+			  ],
+			  [
+			    {
+			      "files": "./package.json",
+			      "from": /"version": "1\\.2\\.3"/g,
+			      "to": "\\"version\\": \\"0.0.0\\"",
+			    },
+			  ],
+			]
+		`);
+	});
+});