Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/check-bun-exists.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@kidd-cli/bundler': patch
---

Check that `bun` exists in PATH before compiling; return a descriptive error when it is missing
104 changes: 78 additions & 26 deletions packages/bundler/src/compile/compile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,23 @@ const mockExecFile = vi.mocked(execFile)
const mockExistsSync = vi.mocked(existsSync)
const mockReaddirSync = vi.mocked(readdirSync)

/**
* Default execFile mock that succeeds for `bun --version` (existence check)
* and succeeds for all other calls. Override per-test to simulate failures.
*/
function mockExecFileSuccess() {
mockExecFile.mockImplementation(
// @ts-expect-error -- callback signature mismatch with overloaded execFile
(_cmd: string, _args: string[], cb: (err: Error | null, stdout: string) => void) => {
cb(null, '')
}
)
}

beforeEach(() => {
vi.clearAllMocks()
mockReaddirSync.mockReturnValue([])
mockExecFileSuccess()
})

describe('compile operation', () => {
Expand All @@ -39,6 +53,23 @@ describe('compile operation', () => {
})
})

it('should return err when bun is not installed', async () => {
mockExecFile.mockImplementation(
// @ts-expect-error -- callback signature mismatch with overloaded execFile
(_cmd: string, _args: string[], cb: (err: Error | null, stdout: string) => void) => {
cb(new Error('spawn bun ENOENT'), '')
}
)

const [error, output] = await compile({ config: {}, cwd: '/project' })

expect(output).toBeNull()
expect(error).toBeInstanceOf(Error)
expect(error).toMatchObject({
message: expect.stringContaining('bun is not installed'),
})
})

it('should return err when bundled entry does not exist', async () => {
mockExistsSync.mockReturnValue(false)

Expand All @@ -51,12 +82,19 @@ describe('compile operation', () => {

it('should return err when bun build fails', async () => {
mockExistsSync.mockReturnValue(true)
mockExecFile.mockImplementation(
// @ts-expect-error -- callback signature mismatch with overloaded execFile
(_cmd: string, _args: string[], cb: (err: Error | null, stdout: string) => void) => {
cb(new Error('bun build crashed'), '')
}
)
mockExecFile
.mockImplementationOnce(
// @ts-expect-error -- callback signature mismatch with overloaded execFile
(_cmd: string, _args: string[], cb: (err: Error | null, stdout: string) => void) => {
cb(null, '1.0.0')
}
)
.mockImplementation(
// @ts-expect-error -- callback signature mismatch with overloaded execFile
(_cmd: string, _args: string[], cb: (err: Error | null, stdout: string) => void) => {
cb(new Error('bun build crashed'), '')
}
)

const [error, output] = await compile({ config: {}, cwd: '/project' })

Expand All @@ -67,16 +105,23 @@ describe('compile operation', () => {

it('should include stderr in error message when verbose is true', async () => {
mockExistsSync.mockReturnValue(true)
mockExecFile.mockImplementation(
// @ts-expect-error -- callback signature mismatch with overloaded execFile
(
_cmd: string,
_args: string[],
cb: (err: Error | null, stdout: string, stderr: string) => void
) => {
cb(new Error('bun build crashed'), '', 'error: could not resolve "chokidar"')
}
)
mockExecFile
.mockImplementationOnce(
// @ts-expect-error -- callback signature mismatch with overloaded execFile
(_cmd: string, _args: string[], cb: (err: Error | null, stdout: string) => void) => {
cb(null, '1.0.0')
}
)
.mockImplementation(
// @ts-expect-error -- callback signature mismatch with overloaded execFile
(
_cmd: string,
_args: string[],
cb: (err: Error | null, stdout: string, stderr: string) => void
) => {
cb(new Error('bun build crashed'), '', 'error: could not resolve "chokidar"')
}
)

const [error] = await compile({
config: { compile: { name: 'my-app', targets: ['linux-x64'] } },
Expand All @@ -91,16 +136,23 @@ describe('compile operation', () => {

it('should not include stderr in error message when verbose is false', async () => {
mockExistsSync.mockReturnValue(true)
mockExecFile.mockImplementation(
// @ts-expect-error -- callback signature mismatch with overloaded execFile
(
_cmd: string,
_args: string[],
cb: (err: Error | null, stdout: string, stderr: string) => void
) => {
cb(new Error('bun build crashed'), '', 'error: could not resolve "chokidar"')
}
)
mockExecFile
.mockImplementationOnce(
// @ts-expect-error -- callback signature mismatch with overloaded execFile
(_cmd: string, _args: string[], cb: (err: Error | null, stdout: string) => void) => {
cb(null, '1.0.0')
}
)
.mockImplementation(
// @ts-expect-error -- callback signature mismatch with overloaded execFile
(
_cmd: string,
_args: string[],
cb: (err: Error | null, stdout: string, stderr: string) => void
) => {
cb(new Error('bun build crashed'), '', 'error: could not resolve "chokidar"')
}
)

const [error] = await compile({
config: { compile: { name: 'my-app', targets: ['linux-x64'] } },
Expand Down
30 changes: 30 additions & 0 deletions packages/bundler/src/compile/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ const COMPILE_TARGET_LABELS: Readonly<Record<CompileTarget, string>> = {
* @returns A result tuple with compile output on success or an Error on failure.
*/
export async function compile(params: CompileParams): AsyncResult<CompileOutput> {
const [bunCheckError] = await checkBunExists()
if (bunCheckError) {
return err(bunCheckError)
}

const resolved = resolveConfig(params)
const bundledEntry = detectBuildEntry(resolved.buildOutDir)

Expand Down Expand Up @@ -212,6 +217,31 @@ function formatCompileError(target: CompileTarget, execError: Error, verbose: bo
return header
}

/**
* Check whether the `bun` binary is available on the system PATH.
*
* @private
* @returns A result tuple with `null` on success or an Error when `bun` is not found.
*/
function checkBunExists(): AsyncResult<null> {
return new Promise((resolve) => {
execFileCb('bun', ['--version'], (error) => {
if (error) {
resolve(
err(
new Error(
'bun is not installed or not found in PATH. Install it from https://bun.sh to use compile.'
)
)
)
return
}

resolve(ok(null))
})
})
}

/**
* Promisified wrapper around `execFile` to invoke `bun build`.
*
Expand Down
Loading