Skip to content

Commit abda9fb

Browse files
committed
feat(exports): auto-enable bin detection by default (#873)
1 parent 085f079 commit abda9fb

2 files changed

Lines changed: 100 additions & 13 deletions

File tree

src/features/pkg/exports.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1116,6 +1116,84 @@ describe('generateExports', () => {
11161116
`)
11171117
})
11181118

1119+
test('bin: implicit auto-detect with single shebang', async ({ expect }) => {
1120+
const results = generateExports(
1121+
{
1122+
es: [
1123+
genChunk(
1124+
'cli.js',
1125+
true,
1126+
undefined,
1127+
'#!/usr/bin/env node\nconsole.log("hello")',
1128+
),
1129+
],
1130+
},
1131+
{ exports: {} },
1132+
)
1133+
await expect(results).resolves.toMatchObject({
1134+
bin: { 'fake-pkg': './cli.js' },
1135+
})
1136+
})
1137+
1138+
test('bin: implicit auto-detect with multiple shebangs warns', async ({
1139+
expect,
1140+
}) => {
1141+
const warnings: string[] = []
1142+
const logger = {
1143+
...globalLogger,
1144+
warn: (...msgs: any[]) => {
1145+
warnings.push(msgs.join(' '))
1146+
},
1147+
}
1148+
const results = await generateExports(
1149+
{
1150+
es: [
1151+
genChunk('cli.js', true, undefined, '#!/usr/bin/env node\n'),
1152+
genChunk('tool.js', true, undefined, '#!/usr/bin/env node\n'),
1153+
],
1154+
},
1155+
{ exports: {}, logger },
1156+
)
1157+
expect(results.bin).toBeUndefined()
1158+
expect(
1159+
warnings.some((w) =>
1160+
w.includes('Multiple entry chunks with shebangs found'),
1161+
),
1162+
).toBe(true)
1163+
})
1164+
1165+
test('bin: implicit auto-detect with no shebangs silently skips', async ({
1166+
expect,
1167+
}) => {
1168+
const warnings: string[] = []
1169+
const logger = {
1170+
...globalLogger,
1171+
warn: (...msgs: any[]) => {
1172+
warnings.push(msgs.join(' '))
1173+
},
1174+
}
1175+
const results = await generateExports(
1176+
{
1177+
es: [genChunk('index.js', true, undefined, 'console.log("hello")')],
1178+
},
1179+
{ exports: {}, logger },
1180+
)
1181+
expect(results.bin).toBeUndefined()
1182+
expect(warnings.filter((w) => w.includes('bin'))).toHaveLength(0)
1183+
})
1184+
1185+
test('bin: false disables auto-detection', async ({ expect }) => {
1186+
const results = generateExports(
1187+
{
1188+
es: [genChunk('cli.js', true, undefined, '#!/usr/bin/env node\n')],
1189+
},
1190+
{ exports: { bin: false } },
1191+
)
1192+
await expect(results).resolves.toMatchObject({
1193+
bin: undefined,
1194+
})
1195+
})
1196+
11191197
test('generate css publish exports', async ({ expect }) => {
11201198
const results = generateExports(
11211199
{ es: [genChunk('index.js'), genAsset('style.css')] },

src/features/pkg/exports.ts

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -140,12 +140,13 @@ export interface ExportsOptions {
140140
/**
141141
* Generate the `bin` field in `package.json` for CLI executables.
142142
*
143-
* Controls how command names are mapped to built entry files:
143+
* By default, tsdown auto-detects entry chunks with shebangs.
144+
* If exactly one is found, it is used as the bin entry.
145+
* If multiple are found, a warning is shown.
146+
* Set to `false` to disable auto-detection.
144147
*
145-
* - `true`: Auto-detect a single CLI entry from entry chunks that contain
146-
* a shebang (for example, `#!/usr/bin/env node`). The command name is
147-
* derived from the package name without its scope. Throws if multiple
148-
* shebang entries are found. Warns and skips generation if none are found.
148+
* - `true`: Auto-detect with strict behavior (errors if multiple shebang entries are found).
149+
* - `false`: Disable bin auto-detection.
149150
* - `string`: Use the given source file path (relative to `cwd`) as the
150151
* CLI entry. The command name is derived from the package name without
151152
* its scope. Warns if the source file does not contain a shebang.
@@ -505,17 +506,17 @@ function generateBin(
505506
logger: Logger,
506507
cwd: string,
507508
): string | Record<string, string> | undefined {
508-
if (!bin) return
509+
if (bin === false) return
509510

510-
if (bin === true || typeof bin === 'string') {
511+
if (bin === true || bin === undefined || typeof bin === 'string') {
511512
if (!pkg.name)
512513
throw new Error(
513514
'Package name is required when using string form for `bin`',
514515
)
515516

516517
const binName = pkg.name[0] === '@' ? pkg.name.split('/', 2)[1] : pkg.name
517518

518-
if (bin === true) {
519+
if (bin === true || bin === undefined) {
519520
let detected: string | undefined
520521
const seen = new Set<string>()
521522

@@ -530,9 +531,15 @@ function generateBin(
530531
seen.add(chunk.facadeModuleId)
531532

532533
if (detected) {
533-
throw new Error(
534-
'Multiple entry chunks with shebangs found. Use `exports.bin: { name: "./src/file.ts" }` to specify which one to use.',
534+
if (bin === true) {
535+
throw new Error(
536+
'Multiple entry chunks with shebangs found. Use `exports.bin: { command: "./src/file.ts" }` to specify which one to use.',
537+
)
538+
}
539+
logger.warn(
540+
'Multiple entry chunks with shebangs found. Use `exports.bin: true` or `exports.bin: { command: "./src/file.ts" }` to configure explicitly.',
535541
)
542+
return
536543
}
537544
detected = devExports
538545
? `./${slash(path.relative(pkgRoot, chunk.facadeModuleId))}`
@@ -541,9 +548,11 @@ function generateBin(
541548
}
542549

543550
if (detected == null) {
544-
logger.warn(
545-
'`exports.bin` is true but no entry chunks with shebangs were found',
546-
)
551+
if (bin === true) {
552+
logger.warn(
553+
'`exports.bin` is true but no entry chunks with shebangs were found',
554+
)
555+
}
547556
return
548557
}
549558
return { [binName]: detected }

0 commit comments

Comments
 (0)