From b6c03ab831ac4af1eab0bd5f7a2f5ea2df4fa083 Mon Sep 17 00:00:00 2001 From: Kevin Deng Date: Fri, 27 Mar 2026 11:40:56 +0800 Subject: [PATCH] feat(exports): auto-enable bin detection by default When exports is enabled, bin auto-detection now runs by default without needing explicit `bin: true`. Single shebang entries are auto-set, multiple shebangs produce a warning (not an error), and no shebangs silently skip. Explicit `bin: true` retains strict behavior (throws on multiple). Set `bin: false` to opt out. --- src/features/pkg/exports.test.ts | 78 ++++++++++++++++++++++++++++++++ src/features/pkg/exports.ts | 33 ++++++++++---- 2 files changed, 101 insertions(+), 10 deletions(-) diff --git a/src/features/pkg/exports.test.ts b/src/features/pkg/exports.test.ts index ff9b61d8f..011953571 100644 --- a/src/features/pkg/exports.test.ts +++ b/src/features/pkg/exports.test.ts @@ -866,6 +866,84 @@ describe('generateExports', () => { }) }) + test('bin: implicit auto-detect with single shebang', async ({ expect }) => { + const results = generateExports( + { + es: [ + genChunk( + 'cli.js', + true, + undefined, + '#!/usr/bin/env node\nconsole.log("hello")', + ), + ], + }, + { exports: {} }, + ) + await expect(results).resolves.toMatchObject({ + bin: { 'fake-pkg': './cli.js' }, + }) + }) + + test('bin: implicit auto-detect with multiple shebangs warns', async ({ + expect, + }) => { + const warnings: string[] = [] + const logger = { + ...globalLogger, + warn: (...msgs: any[]) => { + warnings.push(msgs.join(' ')) + }, + } + const results = await generateExports( + { + es: [ + genChunk('cli.js', true, undefined, '#!/usr/bin/env node\n'), + genChunk('tool.js', true, undefined, '#!/usr/bin/env node\n'), + ], + }, + { exports: {}, logger }, + ) + expect(results.bin).toBeUndefined() + expect( + warnings.some((w) => + w.includes('Multiple entry chunks with shebangs found'), + ), + ).toBe(true) + }) + + test('bin: implicit auto-detect with no shebangs silently skips', async ({ + expect, + }) => { + const warnings: string[] = [] + const logger = { + ...globalLogger, + warn: (...msgs: any[]) => { + warnings.push(msgs.join(' ')) + }, + } + const results = await generateExports( + { + es: [genChunk('index.js', true, undefined, 'console.log("hello")')], + }, + { exports: {}, logger }, + ) + expect(results.bin).toBeUndefined() + expect(warnings.filter((w) => w.includes('bin'))).toHaveLength(0) + }) + + test('bin: false disables auto-detection', async ({ expect }) => { + const results = generateExports( + { + es: [genChunk('cli.js', true, undefined, '#!/usr/bin/env node\n')], + }, + { exports: { bin: false } }, + ) + await expect(results).resolves.toMatchObject({ + bin: undefined, + }) + }) + test('generate css publish exports', async ({ expect }) => { const results = generateExports( { es: [genChunk('index.js'), genAsset('style.css')] }, diff --git a/src/features/pkg/exports.ts b/src/features/pkg/exports.ts index 08da58600..8865a7390 100644 --- a/src/features/pkg/exports.ts +++ b/src/features/pkg/exports.ts @@ -99,8 +99,13 @@ export interface ExportsOptions { /** * Auto-generate the `bin` field in package.json. * - * - `true`: Auto-detect entry chunks with shebangs. Uses package name (without scope) as bin name. - * Errors if multiple shebang entries are found. + * By default, tsdown auto-detects entry chunks with shebangs. + * If exactly one is found, it is used as the bin entry. + * If multiple are found, a warning is shown. + * Set to `false` to disable auto-detection. + * + * - `true`: Auto-detect with strict behavior (errors if multiple shebang entries are found). + * - `false`: Disable bin auto-detection. * - `string`: Source file path to use as the bin entry. Bin name defaults to package name (without scope). * - `Record`: Map of bin command names to source file paths. * @@ -435,9 +440,9 @@ function generateBin( logger: Logger, cwd: string, ): string | Record | undefined { - if (!bin) return + if (bin === false) return - if (bin === true || typeof bin === 'string') { + if (bin === true || bin === undefined || typeof bin === 'string') { if (!pkg.name) throw new Error( 'Package name is required when using string form for `bin`', @@ -445,7 +450,7 @@ function generateBin( const binName = pkg.name[0] === '@' ? pkg.name.split('/', 2)[1] : pkg.name - if (bin === true) { + if (bin === true || bin === undefined) { let detected: string | undefined const seen = new Set() @@ -460,18 +465,26 @@ function generateBin( seen.add(chunk.facadeModuleId) if (detected) { - throw new Error( - 'Multiple entry chunks with shebangs found. Use `exports.bin: { name: "./src/file.ts" }` to specify which one to use.', + if (bin === true) { + throw new Error( + 'Multiple entry chunks with shebangs found. Use `exports.bin: { name: "./src/file.ts" }` to specify which one to use.', + ) + } + logger.warn( + 'Multiple entry chunks with shebangs found. Use `exports.bin: true` or `exports.bin: { name: "./src/file.ts" }` to configure explicitly.', ) + return } detected = join(pkgRoot, chunk.outDir, slash(chunk.fileName)) } } if (detected == null) { - logger.warn( - '`exports.bin` is true but no entry chunks with shebangs were found', - ) + if (bin === true) { + logger.warn( + '`exports.bin` is true but no entry chunks with shebangs were found', + ) + } return } return { [binName]: detected }