From 6372b672b333979d9c7820cd37f7a169eadc3063 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Thu, 22 Oct 2026 23:40:10 +0900 Subject: [PATCH 1/2] fix(lsp): run a pinned @biomejs/biome in the biome bunx fallback bunx biome resolved the unrelated unscoped `biome` npm package, so a machine without Biome installed ran a third-party env-var tool instead of the Biome language server. Run @biomejs/biome@2.6.2 instead, and look up a biome on the current PATH rather than the copy Bun captured at startup. Closes #133 --- packages/lsp/src/server/biome.ts | 10 ++- packages/lsp/test/unit/server.test.ts | 90 ++++++++++++++++++--------- 2 files changed, 67 insertions(+), 33 deletions(-) diff --git a/packages/lsp/src/server/biome.ts b/packages/lsp/src/server/biome.ts index 0e46ee9..d04079e 100644 --- a/packages/lsp/src/server/biome.ts +++ b/packages/lsp/src/server/biome.ts @@ -10,6 +10,9 @@ import path from 'node:path' import process from 'node:process' import { attachLSPProcessHandlers, log, nearestRoot } from './utils' +/** @biomejs/biome version installed by the bunx fallback. */ +export const BIOME_VERSION = '2.6.2' + export const BiomeServer: LSPServerInfo = { id: 'biome', extensions: [ @@ -60,7 +63,8 @@ export const BiomeServer: LSPServerInfo = { } // Check global PATH - const globalBin = Bun.which('biome') + // Read PATH now rather than the copy Bun captured at startup + const globalBin = Bun.which('biome', { PATH: process.env.PATH ?? '' }) if (globalBin) { const proc = spawn(globalBin, ['lsp-proxy', '--stdio'], { cwd: root, @@ -70,9 +74,9 @@ export const BiomeServer: LSPServerInfo = { return { process: proc } } - // Fallback: Try via bunx + // Fallback: Try via bunx. The unscoped `biome` npm package is an unrelated project. try { - const proc = spawn('bunx', ['biome', 'lsp-proxy', '--stdio'], { + const proc = spawn('bunx', [`@biomejs/biome@${BIOME_VERSION}`, 'lsp-proxy', '--stdio'], { cwd: root, env: { ...process.env, BUN_BE_BUN: '1' }, }) diff --git a/packages/lsp/test/unit/server.test.ts b/packages/lsp/test/unit/server.test.ts index 547383d..f883523 100644 --- a/packages/lsp/test/unit/server.test.ts +++ b/packages/lsp/test/unit/server.test.ts @@ -1,8 +1,10 @@ +import type { LSPServerInfo } from '../../src/server/types' import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { describe, expect, test } from 'bun:test' import { + BiomeServer, DartServer, DenoServer, EslintServer, @@ -461,38 +463,45 @@ describe.skipIf(process.platform === 'win32')('findRunnableBin', () => { }) }) -describe.skipIf(process.platform === 'win32')('TypescriptServer classic fallback', () => { - // Each fake server records its name and arguments in `invoked` under the project root. - const recorder = (name: string): string => `#!/bin/sh\necho "${name} $*" > invoked\n` +// Each fake server records its name and arguments in `invoked` under the project root. +const recorder = (name: string): string => `#!/bin/sh\necho "${name} $*" > invoked\n` + +/** Spawn `server` with only `pathBins` on PATH and return what the launched binary recorded. */ +async function spawnServerWith( + server: LSPServerInfo, + pathBins: Record, + setupRoot: (rootDir: string) => Promise = async () => {}, +): Promise { + const binDir = await fs.mkdtemp(path.join(os.tmpdir(), 'lsp-fallback-bin-')) + const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'lsp-fallback-root-')) + const savedPath = process.env.PATH + try { + for (const [name, script] of Object.entries(pathBins)) { + await fs.writeFile(path.join(binDir, name), script, { mode: 0o755 }) + } + await setupRoot(rootDir) + process.env.PATH = binDir + const handle = await server.spawn(rootDir) + process.env.PATH = savedPath + expect(handle).toBeDefined() + const proc = handle!.process + if (proc.exitCode === null && proc.signalCode === null) { + await new Promise(resolve => proc.once('exit', resolve)) + } + return (await fs.readFile(path.join(rootDir, 'invoked'), 'utf8')).trim() + } + finally { + process.env.PATH = savedPath + await fs.rm(binDir, { recursive: true, force: true }) + await fs.rm(rootDir, { recursive: true, force: true }) + } +} - async function spawnTypescriptWith( +describe.skipIf(process.platform === 'win32')('TypescriptServer classic fallback', () => { + const spawnTypescriptWith = ( pathBins: Record, - setupRoot: (rootDir: string) => Promise = async () => {}, - ): Promise { - const binDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ts-fallback-bin-')) - const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ts-fallback-root-')) - const savedPath = process.env.PATH - try { - for (const [name, script] of Object.entries(pathBins)) { - await fs.writeFile(path.join(binDir, name), script, { mode: 0o755 }) - } - await setupRoot(rootDir) - process.env.PATH = binDir - const handle = await TypescriptServer.spawn(rootDir) - process.env.PATH = savedPath - expect(handle).toBeDefined() - const proc = handle!.process - if (proc.exitCode === null && proc.signalCode === null) { - await new Promise(resolve => proc.once('exit', resolve)) - } - return (await fs.readFile(path.join(rootDir, 'invoked'), 'utf8')).trim() - } - finally { - process.env.PATH = savedPath - await fs.rm(binDir, { recursive: true, force: true }) - await fs.rm(rootDir, { recursive: true, force: true }) - } - } + setupRoot?: (rootDir: string) => Promise, + ): Promise => spawnServerWith(TypescriptServer, pathBins, setupRoot) test('falls back to bunx with a pinned typescript-language-server when the one on PATH cannot run', async () => { const invoked = await spawnTypescriptWith({ @@ -526,6 +535,27 @@ describe.skipIf(process.platform === 'win32')('TypescriptServer classic fallback }) }) +describe.skipIf(process.platform === 'win32')('BiomeServer fallback', () => { + test('falls back to bunx with a pinned @biomejs/biome when biome is not installed', async () => { + const invoked = await spawnServerWith(BiomeServer, { bunx: recorder('bunx') }) + expect(invoked).toBe('bunx @biomejs/biome@2.6.2 lsp-proxy --stdio') + }) + + test('prefers biome on PATH over bunx', async () => { + const invoked = await spawnServerWith(BiomeServer, { biome: recorder('path'), bunx: recorder('bunx') }) + expect(invoked).toBe('path lsp-proxy --stdio') + }) + + test('prefers a project-local biome', async () => { + const invoked = await spawnServerWith(BiomeServer, { biome: recorder('path'), bunx: recorder('bunx') }, async (rootDir) => { + const binDir = path.join(rootDir, 'node_modules', '.bin') + await fs.mkdir(binDir, { recursive: true }) + await fs.writeFile(path.join(binDir, 'biome'), recorder('local'), { mode: 0o755 }) + }) + expect(invoked).toBe('local lsp-proxy --stdio') + }) +}) + describe('VueServer', () => { test('has correct id', () => { expect(VueServer.id).toBe('vue') From 7cbe2e6ea5724858eb77d6d84a3ab4a128459682 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Thu, 22 Oct 2026 23:57:39 +0900 Subject: [PATCH 2/2] chore(lsp): apply AI code review suggestions Check for bunx before the Biome fallback spawns it, and return undefined with the install hint when it is missing. spawn reports a missing command only as an async error event, so the old try/catch never logged the hint and a dead process handle reached the LSP manager. --- packages/lsp/src/server/biome.ts | 20 ++++++++++---------- packages/lsp/test/unit/server.test.ts | 22 ++++++++++++++++++++-- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/packages/lsp/src/server/biome.ts b/packages/lsp/src/server/biome.ts index d04079e..6d02215 100644 --- a/packages/lsp/src/server/biome.ts +++ b/packages/lsp/src/server/biome.ts @@ -75,17 +75,17 @@ export const BiomeServer: LSPServerInfo = { } // Fallback: Try via bunx. The unscoped `biome` npm package is an unrelated project. - try { - const proc = spawn('bunx', [`@biomejs/biome@${BIOME_VERSION}`, 'lsp-proxy', '--stdio'], { - cwd: root, - env: { ...process.env, BUN_BE_BUN: '1' }, - }) - attachLSPProcessHandlers(proc, 'biome') - return { process: proc } - } - catch (err) { - log.warn({ root, err }, 'Biome not found. Install with: npm install @biomejs/biome') + // spawn reports a missing bunx only as an async 'error' event, so check for it first. + const bunx = Bun.which('bunx', { PATH: process.env.PATH ?? '' }) + if (!bunx) { + log.warn({ root }, 'Biome not found. Install with: npm install @biomejs/biome') return undefined } + const proc = spawn(bunx, [`@biomejs/biome@${BIOME_VERSION}`, 'lsp-proxy', '--stdio'], { + cwd: root, + env: { ...process.env, BUN_BE_BUN: '1' }, + }) + attachLSPProcessHandlers(proc, 'biome') + return { process: proc } }, } diff --git a/packages/lsp/test/unit/server.test.ts b/packages/lsp/test/unit/server.test.ts index f883523..41eca9c 100644 --- a/packages/lsp/test/unit/server.test.ts +++ b/packages/lsp/test/unit/server.test.ts @@ -2,7 +2,7 @@ import type { LSPServerInfo } from '../../src/server/types' import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' -import { describe, expect, test } from 'bun:test' +import { describe, expect, spyOn, test } from 'bun:test' import { BiomeServer, DartServer, @@ -20,7 +20,7 @@ import { TypescriptServer, VueServer, } from '../../src/server' -import { findRunnableBin } from '../../src/server/utils' +import { findRunnableBin, log } from '../../src/server/utils' describe('LSP_SERVERS', () => { test('contains expected servers', () => { @@ -554,6 +554,24 @@ describe.skipIf(process.platform === 'win32')('BiomeServer fallback', () => { }) expect(invoked).toBe('local lsp-proxy --stdio') }) + + test('returns undefined with the install hint when bunx is missing', async () => { + const emptyDir = await fs.mkdtemp(path.join(os.tmpdir(), 'lsp-fallback-empty-')) + const savedPath = process.env.PATH + const warn = spyOn(log, 'warn') + try { + process.env.PATH = emptyDir + const handle = await BiomeServer.spawn(emptyDir) + process.env.PATH = savedPath + expect(handle).toBeUndefined() + expect(warn).toHaveBeenCalledWith(expect.anything(), 'Biome not found. Install with: npm install @biomejs/biome') + } + finally { + warn.mockRestore() + process.env.PATH = savedPath + await fs.rm(emptyDir, { recursive: true, force: true }) + } + }) }) describe('VueServer', () => {