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

Issue:
🐛 Bug:  At least one dependency name should be specified for removal after --mode initialization
### 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

After fixing #722, we should pass with no errors.

### Actual

```plaintext
Error: Failed uninstalling initialization-only packages
    at withSpinner (file:///Users/josh/repos/template-typescript-node-package/lib/shared/cli/spinners.js:16:11)
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
    at async initializeWithOptions (file:///Users/josh/repos/template-typescript-node-package/lib/initialize/initializeWithOptions.js:47:5)
    ... 2 lines matching cause stack trace ...
    at async Object.initialize (file:///Users/josh/repos/template-typescript-node-package/lib/initialize/index.js:9:10)
    at async bin (file:///Users/josh/repos/template-typescript-node-package/lib/bin/index.js:47:10)
    at async file:///Users/josh/repos/template-typescript-node-package/bin/index.js:4:20 {
  [cause]: Error: Command failed with exit code 1: pnpm remove
   ERR_PNPM_MUST_REMOVE_SOMETHING  At least one dependency name should be specified for removal
      at makeError (file:///Users/josh/repos/template-typescript-node-package/node_modules/.pnpm/execa@8.0.1/node_modules/execa/lib/error.js:60:11)
      at handlePromise (file:///Users/josh/repos/template-typescript-node-package/node_modules/.pnpm/execa@8.0.1/node_modules/execa/index.js:124:26)
      at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
      at async removeDependencies (file:///Users/josh/repos/template-typescript-node-package/lib/shared/packages.js:9:3)
      at async uninstallPackages (file:///Users/josh/repos/template-typescript-node-package/lib/steps/uninstallPackages.js:9:3)
      at async withSpinner (file:///Users/josh/repos/template-typescript-node-package/lib/shared/cli/spinners.js:11:20)
      at async initializeWithOptions (file:///Users/josh/repos/template-typescript-node-package/lib/initialize/initializeWithOptions.js:47:5)
      at async run (file:///Users/josh/repos/template-typescript-node-package/lib/initialize/index.js:11:7)
      at async runOrRestore (file:///Users/josh/repos/template-typescript-node-package/lib/shared/runOrRestore.js:7:5)
      at async Object.initialize (file:///Users/josh/repos/template-typescript-node-package/lib/initialize/index.js:9:10) {
    shortMessage: 'Command failed with exit code 1: pnpm remove',
    command: 'pnpm remove',
    escapedCommand: 'pnpm remove',
    exitCode: 1,
    signal: undefined,
    signalDescription: undefined,
    stdout: ' ERR_PNPM_MUST_REMOVE_SOMETHING  At least one dependency name should be specified for removal',
    stderr: '',
    cwd: '/Users/josh/repos/temp/testing-cta-6',
    failed: true,
    timedOut: false,
    isCanceled: false,
    killed: false
  }
}
```



### Additional Info

😂 it never ends...

Pull request:
fix: don't pnpm remove an empty list of packages
<!-- 👋 Hi, thanks for sending a PR to template-typescript-node-package! 💖.
Please fill out all fields below and make sure each item is true and [x] checked.
Otherwise we may not be able to review your PR. -->

## PR Checklist

- [x] Addresses an existing open issue: fixes #724
- [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

Refactors the two existing package removal functions to be just one.

Patch:
diff --git a/src/shared/packages.ts b/src/shared/packages.ts
index f238466..ae67839 100644
--- a/src/shared/packages.ts
+++ b/src/shared/packages.ts
@@ -1,4 +1,4 @@
-import { $ } from "execa";
+import { execaCommand } from "execa";
 
 import { readFileSafe } from "./readFileSafe.js";
 import { PartialPackageData } from "./types.js";
@@ -11,22 +11,14 @@ export async function readPackageData() {
 
 export async function removeDependencies(
 	packageNames: string[],
-	packageData: PartialPackageData,
+	existing: Record<string, string> = {},
+	flags = "",
 ) {
-	await $`pnpm remove ${packageNames.filter(
-		packageExists(packageData.dependencies),
-	)}`;
-}
-
-export async function removeDevDependencies(
-	packageNames: string[],
-	packageData: PartialPackageData,
-) {
-	await $`pnpm remove ${packageNames.filter(
-		packageExists(packageData.devDependencies),
-	)} -D`;
-}
+	const present = packageNames.filter((packageName) => packageName in existing);
 
-function packageExists(listing: Record<string, string> = {}) {
-	return (packageName: string) => packageName in listing;
+	if (present.length) {
+		await execaCommand(
+			`pnpm remove ${present.join(" ")}${flags ? ` ${flags}` : ""}`,
+		);
+	}
 }
diff --git a/src/steps/finalizeDependencies.ts b/src/steps/finalizeDependencies.ts
index 7936489..58a1f0c 100644
--- a/src/steps/finalizeDependencies.ts
+++ b/src/steps/finalizeDependencies.ts
@@ -1,6 +1,6 @@
 import { execaCommand } from "execa";
 
-import { readPackageData, removeDevDependencies } from "../shared/packages.js";
+import { readPackageData, removeDependencies } from "../shared/packages.js";
 import { Options } from "../shared/types.js";
 
 export async function finalizeDependencies(options: Options) {
@@ -68,9 +68,10 @@ export async function finalizeDependencies(options: Options) {
 
 	if (!options.excludeContributors) {
 		await execaCommand(`npx all-contributors-cli generate`);
-		await removeDevDependencies(
+		await removeDependencies(
 			["all-contributors-cli", "all-contributors-for-repository"],
-			await readPackageData(),
+			(await readPackageData()).devDependencies,
+			"-D",
 		);
 	}
 
diff --git a/src/steps/uninstallPackages.ts b/src/steps/uninstallPackages.ts
index c3bbd95..4e63811 100644
--- a/src/steps/uninstallPackages.ts
+++ b/src/steps/uninstallPackages.ts
@@ -1,10 +1,6 @@
 import { $ } from "execa";
 
-import {
-	readPackageData,
-	removeDependencies,
-	removeDevDependencies,
-} from "../shared/packages.js";
+import { readPackageData, removeDependencies } from "../shared/packages.js";
 
 export async function uninstallPackages() {
 	const packageData = await readPackageData();
@@ -24,10 +20,10 @@ export async function uninstallPackages() {
 			"replace-in-file",
 			"title-case",
 		],
-		packageData,
+		packageData.dependencies,
 	);
 
-	await removeDevDependencies(
+	await removeDependencies(
 		[
 			"@octokit/request-error",
 			"@types/git-url-parse",
@@ -38,7 +34,8 @@ export async function uninstallPackages() {
 			"globby",
 			"tsx",
 		],
-		packageData,
+		packageData.devDependencies,
+		"-D",
 	);
 
 	await $`pnpm add prettier -D`;


Test patch:
diff --git a/src/shared/packages.test.ts b/src/shared/packages.test.ts
new file mode 100644
index 0000000..c8d6144
--- /dev/null
+++ b/src/shared/packages.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { removeDependencies } from "./packages.js";
+
+const mockExecaCommand = vi.fn();
+
+vi.mock("execa", () => ({
+	get execaCommand() {
+		return mockExecaCommand;
+	},
+}));
+
+describe("removeDependencies", () => {
+	it("removes all packages that already exist when all already exist", async () => {
+		await removeDependencies(["one", "two"], {
+			one: "1.2.3",
+			two: "4.5.6",
+		});
+
+		expect(mockExecaCommand.mock.calls).toMatchInlineSnapshot(`
+			[
+			  [
+			    "pnpm remove one two",
+			  ],
+			]
+		`);
+	});
+
+	it("removes only packages that already exist when some don't exist", async () => {
+		await removeDependencies(["exists", "missing"], {
+			exists: "1.2.3",
+		});
+
+		expect(mockExecaCommand.mock.calls).toMatchInlineSnapshot(`
+			[
+			  [
+			    "pnpm remove exists",
+			  ],
+			]
+		`);
+	});
+
+	it("adds a flag to removing packages when one is provided", async () => {
+		await removeDependencies(
+			["exists", "missing"],
+			{
+				exists: "1.2.3",
+			},
+			"-D",
+		);
+
+		expect(mockExecaCommand.mock.calls).toMatchInlineSnapshot(`
+			[
+			  [
+			    "pnpm remove exists -D",
+			  ],
+			]
+		`);
+	});
+
+	it("does nothing when no packages already exist", async () => {
+		await removeDependencies(["missing"]);
+
+		expect(mockExecaCommand.mock.calls).toMatchInlineSnapshot("[]");
+	});
+});
diff --git a/src/steps/finalizeDependencies.test.ts b/src/steps/finalizeDependencies.test.ts
index 7485b97..d6bb2ee 100644
--- a/src/steps/finalizeDependencies.test.ts
+++ b/src/steps/finalizeDependencies.test.ts
@@ -11,13 +11,9 @@ vi.mock("execa", () => ({
 	},
 }));
 
-const mockRemoveDevDependencies = vi.fn();
-
 vi.mock("../shared/packages.js", () => ({
 	readPackageData: () => [],
-	get removeDevDependencies() {
-		return mockRemoveDevDependencies;
-	},
+	removeDependencies: vi.fn(),
 }));
 
 const options = {