From 35d53788f00ebb391c710857b2f89c6723cd122e Mon Sep 17 00:00:00 2001 From: jay23606 <4602056+jay23606@users.noreply.github.com> Date: Thu, 22 Oct 2026 07:59:05 -0400 Subject: [PATCH 1/6] Fix Bun entry point detection Co-Authored-By: Claude Haiku 5.5 Signed-off-by: jay23606 <4602056+jay23606@users.noreply.github.com> --- gdf.js | 38 ++++++++++++++++++++++++++++++++++++++ test/gdf.test.js | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/gdf.js b/gdf.js index 4f6eb87..4c66abc 100755 --- a/gdf.js +++ b/gdf.js @@ -836,6 +836,42 @@ export class GDF { return this.bun ? 'bunx' : 'npx' } + get bunStartCommand() { + const candidates = [] + + for (const entry of [this.#pj.module, this.#pj.main]) { + if (!entry) continue + + candidates.push(entry) + const extension = path.extname(entry) + const typescriptEntry = extension + ? `${entry.slice(0, -extension.length)}.ts` + : `${entry}.ts` + if (typescriptEntry !== entry) candidates.push(typescriptEntry) + } + + const devScript = this.#pj.scripts?.dev + if (devScript) { + const commands = new Set(['bun', 'bunx', 'node', 'npx', 'npm', 'pnpm', 'yarn', 'tsx', 'ts-node', 'run', 'exec']) + const entries = ShellQuote.parse(devScript) + .filter(entry => typeof entry === 'string' && !entry.startsWith('-') && !commands.has(entry)) + .reverse() + + for (const entry of entries) { + candidates.push(entry) + const extension = path.extname(entry) + const typescriptEntry = extension + ? `${entry.slice(0, -extension.length)}.ts` + : `${entry}.ts` + if (typescriptEntry !== entry) candidates.push(typescriptEntry) + } + } + + candidates.push('src/index.ts', 'index.ts') + const entry = candidates.find(filename => fs.existsSync(path.join(this._appdir, filename))) + return ['bun', entry || 'index.ts'] + } + // command to start the web server get startCommand() { if (this.options.cmd) return this.options.cmd @@ -882,6 +918,8 @@ export class GDF { return start } else if (this.#pj.scripts?.start) { return [this.packager, 'run', 'start'] + } else if (this.bun) { + return this.bunStartCommand } else if (this.#pj.type === 'module' && this.#pj.module) { return [this.packager === 'bun' ? 'bun' : 'node', this.#pj.module] } else if (this.#pj.main) { diff --git a/test/gdf.test.js b/test/gdf.test.js index fe074c5..8db7b87 100644 --- a/test/gdf.test.js +++ b/test/gdf.test.js @@ -656,6 +656,7 @@ describe('gdf: start command and port', function() { it('uses bun for module, main and the default entry point', async function() { sandbox().stub('bun', 'echo 1.1.20\n') sandbox().write('bun.lockb', '') + sandbox().write('app.ts', '') let gdf = await sandbox().run({ main: 'app.ts' }) expect(gdf.startCommand).to.deep.equal(['bun', 'app.ts']) @@ -664,6 +665,41 @@ describe('gdf: start command and port', function() { expect(gdf.startCommand).to.deep.equal(['bun', 'index.ts']) }) + it('resolves a Bun module entry point to its TypeScript file when type is unspecified', async function() { + sandbox().stub('bun', 'echo 1.1.20\n') + sandbox().write('bun.lockb', '') + sandbox().write('src/index.ts', '') + + const gdf = await sandbox().run({ module: 'src/index.js' }) + expect(gdf.startCommand).to.deep.equal(['bun', 'src/index.ts']) + }) + + it('uses the Bun dev entry point without --watch', async function() { + sandbox().stub('bun', 'echo 1.1.20\n') + sandbox().write('bun.lockb', '') + sandbox().write('src/server.ts', '') + + const gdf = await sandbox().run({ scripts: { dev: 'bun --watch src/server.ts' } }) + expect(gdf.startCommand).to.deep.equal(['bun', 'src/server.ts']) + }) + + it('runs a Bun start script when one is defined', async function() { + sandbox().stub('bun', 'echo 1.1.20\n') + sandbox().write('bun.lockb', '') + + const gdf = await sandbox().run({ scripts: { start: 'bun src/server.ts' } }) + expect(gdf.startCommand).to.deep.equal(['bun', 'run', 'start']) + }) + + it('uses src/index.ts when a Bun project has no module or dev entry point', async function() { + sandbox().stub('bun', 'echo 1.1.20\n') + sandbox().write('bun.lockb', '') + sandbox().write('src/index.ts', '') + + const gdf = await sandbox().run({}) + expect(gdf.startCommand).to.deep.equal(['bun', 'src/index.ts']) + }) + it('starts standalone next.js with server.js', async function() { for (const config of ['next.config.js', 'next.config.mjs']) { fs.rmSync('next.config.js', { force: true }) From cd4448e5d1ee67bcf9504b387b5beeac4fc13474 Mon Sep 17 00:00:00 2001 From: Alex Leventer Date: Sat, 24 Oct 2026 07:47:59 -0700 Subject: [PATCH 2/6] Keep existing Bun start commands for SvelteKit and declared entries Only fall back to the dev script and src/index.ts for Bun apps that declare no usable entry. A declared main/module that doesn't exist yet is kept as is, since it is likely build output, and SvelteKit keeps ./build/index.js. --- gdf.js | 46 +++++++++++++++++++--------------------------- test/gdf.test.js | 9 +++++++++ 2 files changed, 28 insertions(+), 27 deletions(-) diff --git a/gdf.js b/gdf.js index 4c66abc..1cefaaf 100755 --- a/gdf.js +++ b/gdf.js @@ -836,38 +836,32 @@ export class GDF { return this.bun ? 'bunx' : 'npx' } + // Bun can run TypeScript directly: prefer foo.ts when the declared foo.js + // is missing. A declared entry that doesn't exist yet is kept as is, as it + // is likely build output. + bunEntry(entry) { + const extension = path.extname(entry) + const typescriptEntry = `${extension ? entry.slice(0, -extension.length) : entry}.ts` + const exists = filename => fs.existsSync(path.join(this._appdir, filename)) + return !exists(entry) && exists(typescriptEntry) ? typescriptEntry : entry + } + + // Bun apps without a start script, module or main, or with a module entry + // and no type: module (e.g. the Elysia template) get bunStartCommand() { - const candidates = [] - - for (const entry of [this.#pj.module, this.#pj.main]) { - if (!entry) continue - - candidates.push(entry) - const extension = path.extname(entry) - const typescriptEntry = extension - ? `${entry.slice(0, -extension.length)}.ts` - : `${entry}.ts` - if (typescriptEntry !== entry) candidates.push(typescriptEntry) - } + if (this.#pj.module) return ['bun', this.bunEntry(this.#pj.module)] + const candidates = [] const devScript = this.#pj.scripts?.dev if (devScript) { const commands = new Set(['bun', 'bunx', 'node', 'npx', 'npm', 'pnpm', 'yarn', 'tsx', 'ts-node', 'run', 'exec']) const entries = ShellQuote.parse(devScript) .filter(entry => typeof entry === 'string' && !entry.startsWith('-') && !commands.has(entry)) .reverse() - - for (const entry of entries) { - candidates.push(entry) - const extension = path.extname(entry) - const typescriptEntry = extension - ? `${entry.slice(0, -extension.length)}.ts` - : `${entry}.ts` - if (typescriptEntry !== entry) candidates.push(typescriptEntry) - } + for (const entry of entries) candidates.push(this.bunEntry(entry)) } - candidates.push('src/index.ts', 'index.ts') + candidates.push('src/index.ts') const entry = candidates.find(filename => fs.existsSync(path.join(this._appdir, filename))) return ['bun', entry || 'index.ts'] } @@ -918,16 +912,14 @@ export class GDF { return start } else if (this.#pj.scripts?.start) { return [this.packager, 'run', 'start'] - } else if (this.bun) { - return this.bunStartCommand } else if (this.#pj.type === 'module' && this.#pj.module) { - return [this.packager === 'bun' ? 'bun' : 'node', this.#pj.module] + return this.packager === 'bun' ? ['bun', this.bunEntry(this.#pj.module)] : ['node', this.#pj.module] } else if (this.#pj.main) { - return [this.packager === 'bun' ? 'bun' : 'node', this.#pj.main] + return this.packager === 'bun' ? ['bun', this.bunEntry(this.#pj.main)] : ['node', this.#pj.main] } else if (this.svelte) { return [this.bun ? 'bun' : 'node', './build/index.js'] } else if (this.packager === 'bun') { - return ['bun', 'index.ts'] + return this.bunStartCommand } else { return ['node', 'index.js'] } diff --git a/test/gdf.test.js b/test/gdf.test.js index 8db7b87..7373342 100644 --- a/test/gdf.test.js +++ b/test/gdf.test.js @@ -674,6 +674,15 @@ describe('gdf: start command and port', function() { expect(gdf.startCommand).to.deep.equal(['bun', 'src/index.ts']) }) + it('keeps a Bun main entry point that has not been built yet', async function() { + sandbox().stub('bun', 'echo 1.1.20\n') + sandbox().write('bun.lockb', '') + sandbox().write('src/index.ts', '') + + const gdf = await sandbox().run({ main: 'dist/index.js' }) + expect(gdf.startCommand).to.deep.equal(['bun', 'dist/index.js']) + }) + it('uses the Bun dev entry point without --watch', async function() { sandbox().stub('bun', 'echo 1.1.20\n') sandbox().write('bun.lockb', '') From e7419fbe52c794e4ff0b9f201705af3fbb402b85 Mon Sep 17 00:00:00 2001 From: Alex Leventer Date: Sat, 24 Oct 2026 07:53:33 -0700 Subject: [PATCH 3/6] Never change the Bun start command for apps with an index.ts An existing index.ts is what Bun apps without main or type: module have always run, so keep it. Only consider a module entry that exists, and only dev script arguments that are script files, so asset paths, directories and unset $VARs aren't picked as the entry. --- gdf.js | 30 ++++++++++++++++++------------ test/gdf.test.js | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 12 deletions(-) diff --git a/gdf.js b/gdf.js index 1cefaaf..8833258 100755 --- a/gdf.js +++ b/gdf.js @@ -836,34 +836,40 @@ export class GDF { return this.bun ? 'bunx' : 'npx' } + // Is filename an existing script file in the app directory? + bunScript(filename) { + if (!['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs'].includes(path.extname(filename))) return false + return !!fs.statSync(path.join(this._appdir, filename), { throwIfNoEntry: false })?.isFile() + } + // Bun can run TypeScript directly: prefer foo.ts when the declared foo.js // is missing. A declared entry that doesn't exist yet is kept as is, as it // is likely build output. bunEntry(entry) { const extension = path.extname(entry) const typescriptEntry = `${extension ? entry.slice(0, -extension.length) : entry}.ts` - const exists = filename => fs.existsSync(path.join(this._appdir, filename)) - return !exists(entry) && exists(typescriptEntry) ? typescriptEntry : entry + const exists = fs.existsSync(path.join(this._appdir, entry)) + return !exists && this.bunScript(typescriptEntry) ? typescriptEntry : entry } - // Bun apps without a start script, module or main, or with a module entry - // and no type: module (e.g. the Elysia template) + // Bun apps without a start script, main, or module with type: module. + // An existing index.ts is what has always been used here, so it wins; + // otherwise look for the entry (e.g. src/index.ts in the Elysia template). get bunStartCommand() { - if (this.#pj.module) return ['bun', this.bunEntry(this.#pj.module)] + if (this.bunScript('index.ts')) return ['bun', 'index.ts'] const candidates = [] + if (this.#pj.module) candidates.push(this.bunEntry(this.#pj.module)) + const devScript = this.#pj.scripts?.dev if (devScript) { - const commands = new Set(['bun', 'bunx', 'node', 'npx', 'npm', 'pnpm', 'yarn', 'tsx', 'ts-node', 'run', 'exec']) - const entries = ShellQuote.parse(devScript) - .filter(entry => typeof entry === 'string' && !entry.startsWith('-') && !commands.has(entry)) - .reverse() - for (const entry of entries) candidates.push(this.bunEntry(entry)) + for (const entry of ShellQuote.parse(devScript)) { + if (typeof entry === 'string' && entry) candidates.push(this.bunEntry(entry)) + } } candidates.push('src/index.ts') - const entry = candidates.find(filename => fs.existsSync(path.join(this._appdir, filename))) - return ['bun', entry || 'index.ts'] + return ['bun', candidates.find(entry => this.bunScript(entry)) || 'index.ts'] } // command to start the web server diff --git a/test/gdf.test.js b/test/gdf.test.js index 7373342..a953914 100644 --- a/test/gdf.test.js +++ b/test/gdf.test.js @@ -683,6 +683,46 @@ describe('gdf: start command and port', function() { expect(gdf.startCommand).to.deep.equal(['bun', 'dist/index.js']) }) + it('keeps an existing Bun index.ts over module and dev script entries', async function() { + sandbox().stub('bun', 'echo 1.1.20\n') + sandbox().write('bun.lockb', '') + sandbox().write('index.ts', '') + sandbox().write('lib/index.js', '') + sandbox().write('src/app.css', '') + + let gdf = await sandbox().run({ module: 'lib/index.js' }) + expect(gdf.startCommand).to.deep.equal(['bun', 'index.ts']) + + gdf = await sandbox().run({ module: 'dist/index.mjs' }) + expect(gdf.startCommand).to.deep.equal(['bun', 'index.ts']) + + gdf = await sandbox().run({ scripts: { dev: 'bun --watch index.ts & bunx tailwindcss -i src/app.css' } }) + expect(gdf.startCommand).to.deep.equal(['bun', 'index.ts']) + }) + + it('skips Bun dev script arguments that are not script files', async function() { + sandbox().stub('bun', 'echo 1.1.20\n') + sandbox().write('bun.lockb', '') + sandbox().write('src/server.ts', '') + sandbox().write('src/app.css', '') + sandbox().write('prisma/schema.prisma', '') + + let gdf = await sandbox().run({ scripts: { dev: 'bun --watch src/server.ts --port $PORT' } }) + expect(gdf.startCommand).to.deep.equal(['bun', 'src/server.ts']) + + gdf = await sandbox().run({ scripts: { dev: 'bun --watch src/server.ts & bunx tailwindcss -i src/app.css & bunx prisma studio' } }) + expect(gdf.startCommand).to.deep.equal(['bun', 'src/server.ts']) + }) + + it('skips a Bun module entry point that does not exist', async function() { + sandbox().stub('bun', 'echo 1.1.20\n') + sandbox().write('bun.lockb', '') + sandbox().write('src/server.ts', '') + + const gdf = await sandbox().run({ module: 'dist/index.mjs', scripts: { dev: 'bun --watch src/server.ts' } }) + expect(gdf.startCommand).to.deep.equal(['bun', 'src/server.ts']) + }) + it('uses the Bun dev entry point without --watch', async function() { sandbox().stub('bun', 'echo 1.1.20\n') sandbox().write('bun.lockb', '') From 35c01631c742d99c0c6ecae3642ca607835ab602 Mon Sep 17 00:00:00 2001 From: Alex Leventer Date: Sat, 24 Oct 2026 08:02:36 -0700 Subject: [PATCH 4/6] Limit Bun entry detection to apps that had no index.ts Restore the main and type: module branches exactly, so a compiled main (e.g. index.js from tsc) is still run. Detection now only replaces the old bun index.ts fallback: an existing index.ts still wins, then an existing module (also .tsx/.mts), src/index.ts, the file run by the last bun command in the dev script, and a not yet built module. --- gdf.js | 52 ++++++++++++++++++++++++++++----------------- test/gdf.test.js | 55 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 19 deletions(-) diff --git a/gdf.js b/gdf.js index 8833258..c505841 100755 --- a/gdf.js +++ b/gdf.js @@ -842,14 +842,34 @@ export class GDF { return !!fs.statSync(path.join(this._appdir, filename), { throwIfNoEntry: false })?.isFile() } - // Bun can run TypeScript directly: prefer foo.ts when the declared foo.js - // is missing. A declared entry that doesn't exist yet is kept as is, as it - // is likely build output. + // Bun can run TypeScript directly: when a declared foo.js is missing, look + // for its TypeScript source bunEntry(entry) { + if (fs.existsSync(path.join(this._appdir, entry))) return entry const extension = path.extname(entry) - const typescriptEntry = `${extension ? entry.slice(0, -extension.length) : entry}.ts` - const exists = fs.existsSync(path.join(this._appdir, entry)) - return !exists && this.bunScript(typescriptEntry) ? typescriptEntry : entry + const base = extension ? entry.slice(0, -extension.length) : entry + return ['.ts', '.tsx', '.mts'].map(ext => base + ext).find(file => this.bunScript(file)) || entry + } + + // The file run by the last `bun` command in the dev script, e.g. + // src/server.ts in "bun run codegen.ts && bun --watch src/server.ts" + get bunDevEntry() { + const devScript = this.#pj.scripts?.dev + if (!devScript) return null + + let entry = null + let command = null + for (const token of ShellQuote.parse(devScript)) { + if (typeof token !== 'string') { + command = null + } else if (command === null) { + if (!/^\w+=/.test(token)) command = token + } else if (command === 'bun' && this.bunScript(this.bunEntry(token))) { + entry = this.bunEntry(token) + } + } + + return entry } // Bun apps without a start script, main, or module with type: module. @@ -858,18 +878,12 @@ export class GDF { get bunStartCommand() { if (this.bunScript('index.ts')) return ['bun', 'index.ts'] - const candidates = [] - if (this.#pj.module) candidates.push(this.bunEntry(this.#pj.module)) - - const devScript = this.#pj.scripts?.dev - if (devScript) { - for (const entry of ShellQuote.parse(devScript)) { - if (typeof entry === 'string' && entry) candidates.push(this.bunEntry(entry)) - } - } + const module = this.#pj.module && this.bunEntry(this.#pj.module) + if (module && this.bunScript(module)) return ['bun', module] + if (this.bunScript('src/index.ts')) return ['bun', 'src/index.ts'] - candidates.push('src/index.ts') - return ['bun', candidates.find(entry => this.bunScript(entry)) || 'index.ts'] + // a declared module that doesn't exist yet is likely build output + return ['bun', this.bunDevEntry || module || 'index.ts'] } // command to start the web server @@ -919,9 +933,9 @@ export class GDF { } else if (this.#pj.scripts?.start) { return [this.packager, 'run', 'start'] } else if (this.#pj.type === 'module' && this.#pj.module) { - return this.packager === 'bun' ? ['bun', this.bunEntry(this.#pj.module)] : ['node', this.#pj.module] + return [this.packager === 'bun' ? 'bun' : 'node', this.#pj.module] } else if (this.#pj.main) { - return this.packager === 'bun' ? ['bun', this.bunEntry(this.#pj.main)] : ['node', this.#pj.main] + return [this.packager === 'bun' ? 'bun' : 'node', this.#pj.main] } else if (this.svelte) { return [this.bun ? 'bun' : 'node', './build/index.js'] } else if (this.packager === 'bun') { diff --git a/test/gdf.test.js b/test/gdf.test.js index a953914..0734ad0 100644 --- a/test/gdf.test.js +++ b/test/gdf.test.js @@ -714,6 +714,61 @@ describe('gdf: start command and port', function() { expect(gdf.startCommand).to.deep.equal(['bun', 'src/server.ts']) }) + it('keeps a Bun main entry point that is compiled from TypeScript', async function() { + sandbox().stub('bun', 'echo 1.1.20\n') + sandbox().write('bun.lockb', '') + sandbox().write('index.ts', '') + + const gdf = await sandbox().run({ main: 'index.js', scripts: { build: 'tsc' } }) + expect(gdf.startCommand).to.deep.equal(['bun', 'index.js']) + }) + + it('uses the file run by the last bun command in the dev script', async function() { + sandbox().stub('bun', 'echo 1.1.20\n') + sandbox().write('bun.lockb', '') + sandbox().write('src/server.ts', '') + sandbox().write('scripts/codegen.ts', '') + sandbox().write('drizzle.config.ts', '') + sandbox().write('instrument.ts', '') + + for (const dev of [ + 'bun run scripts/codegen.ts && bun --watch src/server.ts', + 'bunx drizzle-kit push --config drizzle.config.ts && bun --watch src/server.ts', + 'bun --preload ./instrument.ts src/server.ts', + 'NODE_ENV=development bun --watch src/server.ts' + ]) { + const gdf = await sandbox().run({ scripts: { dev } }) + expect(gdf.startCommand, dev).to.deep.equal(['bun', 'src/server.ts']) + } + }) + + it('prefers a Bun src/index.ts over the dev script entry point', async function() { + sandbox().stub('bun', 'echo 1.1.20\n') + sandbox().write('bun.lockb', '') + sandbox().write('src/index.ts', '') + sandbox().write('src/dev.ts', '') + + const gdf = await sandbox().run({ scripts: { dev: 'bun --watch src/dev.ts' } }) + expect(gdf.startCommand).to.deep.equal(['bun', 'src/index.ts']) + }) + + it('resolves a Bun module entry point to its TSX file', async function() { + sandbox().stub('bun', 'echo 1.1.20\n') + sandbox().write('bun.lockb', '') + sandbox().write('src/index.tsx', '') + + const gdf = await sandbox().run({ module: 'src/index.js' }) + expect(gdf.startCommand).to.deep.equal(['bun', 'src/index.tsx']) + }) + + it('keeps a Bun module entry point that has not been built yet', async function() { + sandbox().stub('bun', 'echo 1.1.20\n') + sandbox().write('bun.lockb', '') + + const gdf = await sandbox().run({ module: 'dist/index.mjs' }) + expect(gdf.startCommand).to.deep.equal(['bun', 'dist/index.mjs']) + }) + it('skips a Bun module entry point that does not exist', async function() { sandbox().stub('bun', 'echo 1.1.20\n') sandbox().write('bun.lockb', '') From 26a8821bbc230cd1cf3f38cd4cfaa7701887ddde Mon Sep 17 00:00:00 2001 From: Alex Leventer Date: Sat, 24 Oct 2026 08:12:05 -0700 Subject: [PATCH 5/6] Follow cd in Bun dev scripts For monorepo layouts like "cd apps/api && bun --watch src/index.ts", run the entry with bun --cwd so the app starts in the same directory as in development. Directories outside the app are ignored. --- gdf.js | 22 +++++++++++++++++----- test/gdf.test.js | 26 ++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/gdf.js b/gdf.js index c505841..43fdccf 100755 --- a/gdf.js +++ b/gdf.js @@ -851,21 +851,29 @@ export class GDF { return ['.ts', '.tsx', '.mts'].map(ext => base + ext).find(file => this.bunScript(file)) || entry } - // The file run by the last `bun` command in the dev script, e.g. - // src/server.ts in "bun run codegen.ts && bun --watch src/server.ts" + // The directory and file run by the last `bun` command in the dev script, + // e.g. src/server.ts in "bun run codegen.ts && bun --watch src/server.ts", + // or apps/api and src/index.ts in "cd apps/api && bun --watch src/index.ts" get bunDevEntry() { const devScript = this.#pj.scripts?.dev if (!devScript) return null let entry = null let command = null + let dir = '.' for (const token of ShellQuote.parse(devScript)) { if (typeof token !== 'string') { command = null } else if (command === null) { if (!/^\w+=/.test(token)) command = token - } else if (command === 'bun' && this.bunScript(this.bunEntry(token))) { - entry = this.bunEntry(token) + } else if (command === 'cd') { + // only follow relative directories that stay within the app + const target = dir === null || path.isAbsolute(token) ? null : path.normalize(path.join(dir, token)) + dir = target === null || target.startsWith('..') || /^[~-]/.test(token) ? null : target + command = 'cd ' + token + } else if (command === 'bun' && dir !== null) { + const file = this.bunEntry(path.join(dir, token)) + if (this.bunScript(file)) entry = { dir, file: path.relative(dir, file) } } } @@ -880,10 +888,14 @@ export class GDF { const module = this.#pj.module && this.bunEntry(this.#pj.module) if (module && this.bunScript(module)) return ['bun', module] + + // a dev script that changes directory names the app explicitly + const dev = this.bunDevEntry + if (dev && dev.dir !== '.') return ['bun', '--cwd', dev.dir, dev.file] if (this.bunScript('src/index.ts')) return ['bun', 'src/index.ts'] // a declared module that doesn't exist yet is likely build output - return ['bun', this.bunDevEntry || module || 'index.ts'] + return ['bun', dev?.file || module || 'index.ts'] } // command to start the web server diff --git a/test/gdf.test.js b/test/gdf.test.js index 0734ad0..76d5ebc 100644 --- a/test/gdf.test.js +++ b/test/gdf.test.js @@ -742,6 +742,32 @@ describe('gdf: start command and port', function() { } }) + it('runs a Bun dev entry point in the directory the dev script changes to', async function() { + sandbox().stub('bun', 'echo 1.1.20\n') + sandbox().write('bun.lockb', '') + sandbox().write('src/index.ts', '') + sandbox().write('apps/api/src/index.ts', '') + + let gdf = await sandbox().run({ scripts: { dev: 'cd apps/api && bun --watch src/index.ts' } }) + expect(gdf.startCommand).to.deep.equal(['bun', '--cwd', 'apps/api', 'src/index.ts']) + + gdf = await sandbox().run({ scripts: { dev: 'cd apps && cd api && bun --watch ./src/index.js' } }) + expect(gdf.startCommand).to.deep.equal(['bun', '--cwd', 'apps/api', 'src/index.ts']) + + expect(sandbox().read('Dockerfile')).to.include('CMD [ "bun", "--cwd", "apps/api", "src/index.ts" ]') + }) + + it('ignores Bun dev script entry points outside the app', async function() { + sandbox().stub('bun', 'echo 1.1.20\n') + sandbox().write('bun.lockb', '') + sandbox().write('src/index.ts', '') + + for (const dev of ['cd .. && bun --watch src/index.ts', 'cd /srv && bun src/index.ts', 'cd ~ && bun src/index.ts']) { + const gdf = await sandbox().run({ scripts: { dev } }) + expect(gdf.startCommand, dev).to.deep.equal(['bun', 'src/index.ts']) + } + }) + it('prefers a Bun src/index.ts over the dev script entry point', async function() { sandbox().stub('bun', 'echo 1.1.20\n') sandbox().write('bun.lockb', '') From e2610d6c46ccb6db337f00a0890591d7330026b5 Mon Sep 17 00:00:00 2001 From: Alex Leventer Date: Sat, 24 Oct 2026 08:18:17 -0700 Subject: [PATCH 6/6] Parse Bun dev scripts like a shell, resolve missing entries for old Bun - the first file after bun is the entry; later arguments are its argv, and values of flags like --preload are skipped - cd doesn't carry out of subshells, background jobs or pipelines; bare cd, ./ and trailing slashes are handled - paths in the generated CMD always use forward slashes - a missing main or module foo.js points at its TypeScript source, as Bun 1.2+ resolves it but older versions don't, unless a build, prepare or postinstall script could produce it --- gdf.js | 97 ++++++++++++++++++++++++++++++++++++++---------- test/gdf.test.js | 68 ++++++++++++++++++++++++++++++++- 2 files changed, 144 insertions(+), 21 deletions(-) diff --git a/gdf.js b/gdf.js index 43fdccf..a97255e 100755 --- a/gdf.js +++ b/gdf.js @@ -50,6 +50,12 @@ const ALPINE_MAPPINGS = { const __dirname = url.fileURLToPath(new URL('.', import.meta.url)) // Generate Dockerfile class +const BUN_SCRIPT_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs'] + +// bun flags that take a separate value, e.g. --preload ./instrument.ts +const BUN_VALUE_FLAGS = ['--preload', '-r', '--require', '--import', '--config', '-c', '--cwd', + '--env-file', '--tsconfig-override', '--define', '-d', '--loader', '-l', '--conditions'] + export class GDF { static templates = path.join(__dirname, 'templates') static runners = [] @@ -838,17 +844,30 @@ export class GDF { // Is filename an existing script file in the app directory? bunScript(filename) { - if (!['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs'].includes(path.extname(filename))) return false + if (!BUN_SCRIPT_EXTENSIONS.includes(path.posix.extname(filename))) return false return !!fs.statSync(path.join(this._appdir, filename), { throwIfNoEntry: false })?.isFile() } - // Bun can run TypeScript directly: when a declared foo.js is missing, look - // for its TypeScript source - bunEntry(entry) { + // The TypeScript source of a missing foo.js, if there is one + bunSource(entry) { if (fs.existsSync(path.join(this._appdir, entry))) return entry - const extension = path.extname(entry) + const extension = path.posix.extname(entry) const base = extension ? entry.slice(0, -extension.length) : entry - return ['.ts', '.tsx', '.mts'].map(ext => base + ext).find(file => this.bunScript(file)) || entry + const sources = extension === '.cjs' ? ['.cts'] : ['.ts', '.tsx', '.mts'] + return sources.map(ext => base + ext).find(file => this.bunScript(file)) || entry + } + + // Could a script produce a declared entry point that doesn't exist yet? + get bunBuildsEntry() { + const { build, prepare, postinstall } = this.#pj.scripts || {} + return !!(build || prepare || postinstall) + } + + // A declared foo.js may be missing because only foo.ts exists. Bun 1.2+ + // runs foo.ts in that case, older versions fail, so point at the source, + // unless foo.js could be build output. + bunEntry(entry) { + return this.bunBuildsEntry ? entry : this.bunSource(entry) } // The directory and file run by the last `bun` command in the dev script, @@ -859,21 +878,59 @@ export class GDF { if (!devScript) return null let entry = null - let command = null - let dir = '.' + let command // the current command, null once its file is found + let skip = false // the next token is a flag value or redirect target + let dir = '.' // null when the directory can't be followed + let listDir = dir // directory at the start of the current command list + const dirs = [] // directories outside of (subshells) + for (const token of ShellQuote.parse(devScript)) { if (typeof token !== 'string') { - command = null - } else if (command === null) { + if (token.comment !== undefined) continue + if (token.op === 'glob') { + if (command === 'bun') command = null + continue + } + if (/^\d*[<>]/.test(token.op)) { + skip = true + continue + } + + // a bare cd changes to $HOME + if (command === 'cd') dir = null + + if (token.op === '(') { + dirs.push(dir) + } else if (token.op === ')') { + dir = dirs.length ? dirs.pop() : null + } else if (token.op === '&' || token.op === '|') { + // background lists and pipelines run in subshells + dir = listDir + } + + if (token.op === ';' || token.op === '&') listDir = dir + command = undefined + skip = false + } else if (skip) { + skip = false + } else if (command === undefined) { if (!/^\w+=/.test(token)) command = token } else if (command === 'cd') { // only follow relative directories that stay within the app - const target = dir === null || path.isAbsolute(token) ? null : path.normalize(path.join(dir, token)) - dir = target === null || target.startsWith('..') || /^[~-]/.test(token) ? null : target - command = 'cd ' + token - } else if (command === 'bun' && dir !== null) { - const file = this.bunEntry(path.join(dir, token)) - if (this.bunScript(file)) entry = { dir, file: path.relative(dir, file) } + const target = dir === null || path.posix.isAbsolute(token) || /^[~-]/.test(token) + ? null + : path.posix.normalize(path.posix.join(dir, token)).replace(/\/+$/, '') || '.' + dir = target === null || target === '..' || target.startsWith('../') ? null : target + command = null + } else if (command === 'bun') { + if (token.startsWith('-')) { + skip = BUN_VALUE_FLAGS.includes(token) + } else if (token !== 'run') { + // the first file is the entry; anything after it is its argv + const file = dir === null ? null : this.bunSource(path.posix.join(dir, token)) + if (file && this.bunScript(file)) entry = { dir, file: path.posix.relative(dir, file) } + command = null + } } } @@ -886,15 +943,15 @@ export class GDF { get bunStartCommand() { if (this.bunScript('index.ts')) return ['bun', 'index.ts'] + // a declared module that doesn't exist yet is likely build output const module = this.#pj.module && this.bunEntry(this.#pj.module) - if (module && this.bunScript(module)) return ['bun', module] + if (module && (this.bunScript(module) || this.bunBuildsEntry)) return ['bun', module] // a dev script that changes directory names the app explicitly const dev = this.bunDevEntry if (dev && dev.dir !== '.') return ['bun', '--cwd', dev.dir, dev.file] if (this.bunScript('src/index.ts')) return ['bun', 'src/index.ts'] - // a declared module that doesn't exist yet is likely build output return ['bun', dev?.file || module || 'index.ts'] } @@ -945,9 +1002,9 @@ export class GDF { } else if (this.#pj.scripts?.start) { return [this.packager, 'run', 'start'] } else if (this.#pj.type === 'module' && this.#pj.module) { - return [this.packager === 'bun' ? 'bun' : 'node', this.#pj.module] + return this.packager === 'bun' ? ['bun', this.bunEntry(this.#pj.module)] : ['node', this.#pj.module] } else if (this.#pj.main) { - return [this.packager === 'bun' ? 'bun' : 'node', this.#pj.main] + return this.packager === 'bun' ? ['bun', this.bunEntry(this.#pj.main)] : ['node', this.#pj.main] } else if (this.svelte) { return [this.bun ? 'bun' : 'node', './build/index.js'] } else if (this.packager === 'bun') { diff --git a/test/gdf.test.js b/test/gdf.test.js index 76d5ebc..b736c13 100644 --- a/test/gdf.test.js +++ b/test/gdf.test.js @@ -757,12 +757,78 @@ describe('gdf: start command and port', function() { expect(sandbox().read('Dockerfile')).to.include('CMD [ "bun", "--cwd", "apps/api", "src/index.ts" ]') }) + it('treats arguments after the Bun dev entry point as its argv', async function() { + sandbox().stub('bun', 'echo 1.1.20\n') + sandbox().write('bun.lockb', '') + sandbox().write('src/server.ts', '') + sandbox().write('drizzle.config.ts', '') + + for (const dev of [ + 'bun --watch src/server.ts --config drizzle.config.ts', + 'bun --watch src/server.ts > log.txt 2>&1', + 'bun --watch src/server.ts # drizzle.config.ts' + ]) { + const gdf = await sandbox().run({ scripts: { dev } }) + expect(gdf.startCommand, dev).to.deep.equal(['bun', 'src/server.ts']) + } + }) + + it('does not follow cd out of Bun dev script subshells and background jobs', async function() { + sandbox().stub('bun', 'echo 1.1.20\n') + sandbox().write('bun.lockb', '') + sandbox().write('server.ts', '') + sandbox().write('web/server.ts', '') + + for (const dev of [ + '(cd web && vite) & bun --watch server.ts', + 'cd web && vite & bun --watch server.ts', + 'cd ./ && bun --watch server.ts' + ]) { + const gdf = await sandbox().run({ scripts: { dev } }) + expect(gdf.startCommand, dev).to.deep.equal(['bun', 'server.ts']) + } + + const gdf = await sandbox().run({ scripts: { dev: 'cd web/ && bun --watch server.ts' } }) + expect(gdf.startCommand).to.deep.equal(['bun', '--cwd', 'web', 'server.ts']) + }) + + it('resolves a missing Bun main or module to its TypeScript source unless a script builds it', async function() { + sandbox().stub('bun', 'echo 1.1.20\n') + sandbox().write('bun.lockb', '') + sandbox().write('src/index.ts', '') + sandbox().write('src/worker.cts', '') + + let gdf = await sandbox().run({ type: 'module', module: 'src/index.js' }) + expect(gdf.startCommand).to.deep.equal(['bun', 'src/index.ts']) + + gdf = await sandbox().run({ main: 'src/worker.cjs' }) + expect(gdf.startCommand).to.deep.equal(['bun', 'src/worker.cts']) + + gdf = await sandbox().run({ type: 'module', module: 'src/index.js', scripts: { build: 'tsc' } }) + expect(gdf.startCommand).to.deep.equal(['bun', 'src/index.js']) + + gdf = await sandbox().run({ main: 'src/index.js', scripts: { prepare: 'tsc' } }) + expect(gdf.startCommand).to.deep.equal(['bun', 'src/index.js']) + }) + + it('prefers a Bun module built by a script over the dev script entry point', async function() { + sandbox().stub('bun', 'echo 1.1.20\n') + sandbox().write('bun.lockb', '') + sandbox().write('src/server.ts', '') + + const gdf = await sandbox().run({ + module: 'dist/index.mjs', + scripts: { build: 'bun build src/server.ts --outdir dist', dev: 'bun --watch src/server.ts' } + }) + expect(gdf.startCommand).to.deep.equal(['bun', 'dist/index.mjs']) + }) + it('ignores Bun dev script entry points outside the app', async function() { sandbox().stub('bun', 'echo 1.1.20\n') sandbox().write('bun.lockb', '') sandbox().write('src/index.ts', '') - for (const dev of ['cd .. && bun --watch src/index.ts', 'cd /srv && bun src/index.ts', 'cd ~ && bun src/index.ts']) { + for (const dev of ['cd .. && bun --watch src/index.ts', 'cd /srv && bun src/index.ts', 'cd ~ && bun src/index.ts', 'cd && bun src/index.ts']) { const gdf = await sandbox().run({ scripts: { dev } }) expect(gdf.startCommand, dev).to.deep.equal(['bun', 'src/index.ts']) }