Repository: JoshuaKGoldberg/create-typescript-app (ts)

Issue:
🐛 Bug: "options.bin?.replace is not a function" when package.json bin is an object
### 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/create-typescript-app/issues?q=is%3Aissue) and found none that matched my issue.

### Expected

Running `npx create-typescript-app` should not crash on any valid `package.json` files.

### Actual

Running on https://github.com/JoshuaKGoldberg/cspell-populate-words/tree/a84073d03e8517558269d567e9a47e9a87b4359e:

```plaintext
 npx create-typescript-app --add-cta-transitions

┌  ✨ create-typescript-app@2.15.2 ✨
│
●  Learn more on:
│    https://github.com/JoshuaKGoldberg/create-typescript-app
│
◇  Running with mode --transition using the template:
│    create-typescript-app
│
│
◇  Detected --preset everything from existing files on disk.
│  
◇  Inferred options from existing repository
│
■  Error running create-typescript-app:
│
■  TypeError: options.bin?.replace is not a function
│      at Object.produce (file:///Users/josh/repos/create-typescript-app/lib/blocks/blockPackageJson.js:56:30)
│      at block.produce (file:///Users/josh/repos/create-typescript-app/node_modules/.pnpm/bingo-stratum@0.5.7_bingo-fs@0.5.4_bingo-systems@0.5.4_bingo@0.5.10_zod@3.24.2/node_modules/bingo-stratum/lib/creators/createBlock.js:15:32)
│      at produceBlock (file:///Users/josh/repos/create-typescript-app/node_modules/.pnpm/bingo-stratum@0.5.7_bingo-fs@0.5.4_bingo-systems@0.5.4_bingo@0.5.10_zod@3.24.2/node_modules/bingo-stratum/lib/producers/produceBlock.js:9:26)
│      at produceBlocks (file:///Users/josh/repos/create-typescript-app/node_modules/.pnpm/bingo-stratum@0.5.7_bingo-fs@0.5.4_bingo-systems@0.5.4_bingo@0.5.10_zod@3.24.2/node_modules/bingo-stratum/lib/producers/produceBlocks.js:23:35)
│      at produceStratumTemplate (file:///Users/josh/repos/create-typescript-app/node_modules/.pnpm/bingo-stratum@0.5.7_bingo-fs@0.5.4_bingo-systems@0.5.4_bingo@0.5.10_zod@3.24.2/node_modules/bingo-stratum/lib/producers/produceStratumTemplate.js:10:12)
│      at Object.produce (file:///Users/josh/repos/create-typescript-app/node_modules/.pnpm/bingo-stratum@0.5.7_bingo-fs@0.5.4_bingo-systems@0.5.4_bingo@0.5.10_zod@3.24.2/node_modules/bingo-stratum/lib/creators/createStratumTemplate.js:72:20)
│      at produceTemplate (file:///Users/josh/repos/create-typescript-app/node_modules/.pnpm/bingo@0.5.10/node_modules/bingo/lib/producers/produceTemplate.js:15:35)
│      at runTemplate (file:///Users/josh/repos/create-typescript-app/node_modules/.pnpm/bingo@0.5.10/node_modules/bingo/lib/runners/runTemplate.js:18:28)
│      at async file:///Users/josh/repos/create-typescript-app/node_modules/.pnpm/bingo@0.5.10/node_modules/bingo/lib/cli/transition/runModeTransition.js:65:98
│      at async tryCatchError (file:///Users/josh/repos/create-typescript-app/node_modules/.pnpm/bingo@0.5.10/node_modules/bingo/lib/utils/tryCatch.js:3:16)
│
└  Leaving changes to the local directory on disk. 👋
```

### Additional Info

`cspell-populate-words` has an object `bin`:

```json
	"bin": {
		"cspell-populate-words": "bin/index.mjs"
	},
```

https://github.com/JoshuaKGoldberg/cspell-populate-words/blob/a84073d03e8517558269d567e9a47e9a87b4359e/package.json#L16-L18

Pull request:
fix: support package.json bin objects
## PR Checklist

- [x] Addresses an existing open issue: fixes #2039
- [x] That issue was marked as [`status: accepting prs`](https://github.com/JoshuaKGoldberg/create-typescript-app/issues?q=is%3Aopen+is%3Aissue+label%3A%22status%3A+accepting+prs%22)
- [x] Steps in [CONTRIBUTING.md](https://github.com/JoshuaKGoldberg/create-typescript-app/blob/main/.github/CONTRIBUTING.md) were taken

## Overview

Updates the `PartialPackageData` type to note that `bin` could be a `Record<string, string>`, not just a `string`. `blockPackageJson` will collect its values if it is an object.

Also updates other references to `options.bin` to use a new `getPrimaryBin` function for determining what the main runner is. Per https://docs.npmjs.com/cli/v11/configuring-npm/package-json#bin using the same name as the repo is equivalent to using a string.

🎁 

Patch:
diff --git a/src/base.ts b/src/base.ts
index 30a5f86..be42ce9 100644
--- a/src/base.ts
+++ b/src/base.ts
@@ -45,7 +45,7 @@ export const base = createBase({
 			.optional()
 			.describe("username on npm to publish packages under"),
 		bin: z
-			.string()
+			.union([z.string(), z.record(z.string())])
 			.optional()
 			.describe('value to set in `package.json`\'s `"bin"` property'),
 		contributors: z
diff --git a/src/blocks/bin/getPrimaryBin.ts b/src/blocks/bin/getPrimaryBin.ts
new file mode 100644
index 0000000..d1b3b7a
--- /dev/null
+++ b/src/blocks/bin/getPrimaryBin.ts
@@ -0,0 +1,6 @@
+export function getPrimaryBin(
+	bin: Record<string, string | undefined> | string | undefined,
+	repository: string,
+) {
+	return typeof bin === "object" ? bin[repository] : bin;
+}
diff --git a/src/blocks/blockPackageJson.ts b/src/blocks/blockPackageJson.ts
index c003a5f..0b5bad8 100644
--- a/src/blocks/blockPackageJson.ts
+++ b/src/blocks/blockPackageJson.ts
@@ -63,7 +63,7 @@ export const blockPackageJson = base.createBlock({
 								packageManager: `pnpm@${options.pnpm}`,
 							}),
 							files: [
-								options.bin?.replace(/^\.\//, ""),
+								...collectBinFiles(options.bin),
 								...(addons.properties.files ?? []),
 								"package.json",
 								"README.md",
@@ -108,6 +108,16 @@ export const blockPackageJson = base.createBlock({
 	},
 });
 
+function collectBinFiles(bin: Record<string, string> | string | undefined) {
+	if (!bin) {
+		return [];
+	}
+
+	const files = typeof bin === "object" ? Object.values(bin) : [bin];
+
+	return files.map((file) => file.replace(/^\.\//, ""));
+}
+
 function removeRangePrefix(version: string) {
 	return version.replaceAll(/[\^~><=]/gu, "").split(" ")[0];
 }
diff --git a/src/blocks/blockTypeScript.ts b/src/blocks/blockTypeScript.ts
index 3a14fed..52a84da 100644
--- a/src/blocks/blockTypeScript.ts
+++ b/src/blocks/blockTypeScript.ts
@@ -1,5 +1,6 @@
 import { base } from "../base.js";
 import { getPackageDependencies } from "../data/packageData.js";
+import { getPrimaryBin } from "./bin/getPrimaryBin.js";
 import { blockDevelopmentDocs } from "./blockDevelopmentDocs.js";
 import { blockExampleFiles } from "./blockExampleFiles.js";
 import { blockGitHubActionsCI } from "./blockGitHubActionsCI.js";
@@ -15,6 +16,8 @@ export const blockTypeScript = base.createBlock({
 		name: "TypeScript",
 	},
 	produce({ options }) {
+		const primaryBin = getPrimaryBin(options.bin, options.repository);
+
 		return {
 			addons: [
 				blockDevelopmentDocs({
@@ -86,12 +89,12 @@ export * from "./types.js";
 				}),
 				blockVitest({ coverage: { include: ["src"] }, exclude: ["lib"] }),
 				blockVSCode({
-					debuggers: options.bin
+					debuggers: primaryBin
 						? [
 								{
 									name: "Debug Program",
 									preLaunchTask: "build",
-									program: options.bin,
+									program: primaryBin,
 									request: "launch",
 									skipFiles: ["<node_internals>/**"],
 									type: "node",
diff --git a/src/blocks/blockVSCode.ts b/src/blocks/blockVSCode.ts
index e461c83..b0a9c5e 100644
--- a/src/blocks/blockVSCode.ts
+++ b/src/blocks/blockVSCode.ts
@@ -2,6 +2,7 @@ import sortKeys from "sort-keys";
 import { z } from "zod";
 
 import { base } from "../base.js";
+import { getPrimaryBin } from "./bin/getPrimaryBin.js";
 import { blockDevelopmentDocs } from "./blockDevelopmentDocs.js";
 
 export const blockVSCode = base.createBlock({
@@ -30,6 +31,7 @@ export const blockVSCode = base.createBlock({
 	},
 	produce({ addons, options }) {
 		const { debuggers, extensions, settings, tasks } = addons;
+		const primaryBin = getPrimaryBin(options.bin, options.repository);
 
 		return {
 			addons: [
@@ -40,13 +42,13 @@ export const blockVSCode = base.createBlock({
 					],
 					sections: {
 						Building: {
-							innerSections: options.bin
+							innerSections: primaryBin
 								? [
 										{
 											contents: `
 This repository includes a [VS Code launch configuration](https://code.visualstudio.com/docs/editor/debugging) for debugging.
 To debug a \`bin\` app, add a breakpoint to your code, then run _Debug Program_ from the VS Code Debug panel (or press F5).
-VS Code will automatically run the \`build\` task in the background before running \`${options.bin}\`.
+VS Code will automatically run the \`build\` task in the background before running \`${primaryBin}\`.
 `,
 											heading: "Built App Debugging",
 										},
diff --git a/src/types.ts b/src/types.ts
index b30d8ad..509c865 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -12,7 +12,7 @@ export interface AllContributorsData {
 
 export interface PartialPackageData {
 	author?: string | { email: string; name: string };
-	bin?: string;
+	bin?: Record<string, string> | string;
 	dependencies?: Record<string, string>;
 	description?: string;
 	devDependencies?: Record<string, string>;


Test patch:
diff --git a/src/blocks/bin/getPrimaryBin.test.ts b/src/blocks/bin/getPrimaryBin.test.ts
new file mode 100644
index 0000000..a65a8b8
--- /dev/null
+++ b/src/blocks/bin/getPrimaryBin.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, test } from "vitest";
+
+import { getPrimaryBin } from "./getPrimaryBin.js";
+
+const repository = "test-repository";
+
+describe(getPrimaryBin, () => {
+	test.each([
+		[undefined, undefined],
+		["bin/index.js", "bin/index.js"],
+		[{ [repository]: "bin/index.js" }, "bin/index.js"],
+		[{}, undefined],
+		[{ other: "bin/index.js" }, undefined],
+	])("%j", (bin, expected) => {
+		expect(getPrimaryBin(bin, repository)).toBe(expected);
+	});
+});
diff --git a/src/blocks/blockPackageJson.test.ts b/src/blocks/blockPackageJson.test.ts
index d2a5816..58bcf73 100644
--- a/src/blocks/blockPackageJson.test.ts
+++ b/src/blocks/blockPackageJson.test.ts
@@ -184,6 +184,59 @@ describe("blockPackageJson", () => {
 		`);
 	});
 
+	test("with object bin", () => {
+		const creation = testBlock(blockPackageJson, {
+			options: {
+				...options,
+				bin: "bin/index.js",
+			},
+		});
+
+		expect(creation).toMatchInlineSnapshot(`
+			{
+			  "files": {
+			    "package.json": "{"name":"test-repository","version":"0.0.0","description":"A very very very very very very very very very very very very very very very very long HTML-ish description ending with an emoji. 🧵","repository":{"type":"git","url":"git+https://github.com/test-owner/test-repository.git"},"license":"MIT","author":{"email":"npm@email.com"},"type":"module","main":"lib/index.js","bin":"bin/index.js","files":["README.md","bin/index.js","package.json"]}",
+			  },
+			  "scripts": [
+			    {
+			      "commands": [
+			        "pnpm install --no-frozen-lockfile",
+			      ],
+			      "phase": 1,
+			    },
+			  ],
+			}
+		`);
+	});
+
+	test("with string bin", () => {
+		const creation = testBlock(blockPackageJson, {
+			options: {
+				...options,
+				bin: {
+					absolute: "bin/absolute.js",
+					relative: "./bin/relative.js",
+				},
+			},
+		});
+
+		expect(creation).toMatchInlineSnapshot(`
+			{
+			  "files": {
+			    "package.json": "{"name":"test-repository","version":"0.0.0","description":"A very very very very very very very very very very very very very very very very long HTML-ish description ending with an emoji. 🧵","repository":{"type":"git","url":"git+https://github.com/test-owner/test-repository.git"},"license":"MIT","author":{"email":"npm@email.com"},"type":"module","main":"lib/index.js","bin":{"absolute":"bin/absolute.js","relative":"./bin/relative.js"},"files":["README.md","bin/absolute.js","bin/relative.js","package.json"]}",
+			  },
+			  "scripts": [
+			    {
+			      "commands": [
+			        "pnpm install --no-frozen-lockfile",
+			      ],
+			      "phase": 1,
+			    },
+			  ],
+			}
+		`);
+	});
+
 	test("offline mode", () => {
 		const creation = testBlock(blockPackageJson, {
 			offline: true,