Skip to content

Commit 56a8257

Browse files
committed
auto-discover vscode-codeql managed CodeQL CLI dist
The MCP server now automatically finds the CodeQL CLI binary installed by the GitHub.vscode-codeql extension, which stores it off-PATH at: <globalStorage>/github.vscode-codeql/distribution<N>/codeql/codeql Discovery uses distribution.json (folderIndex hint) with a fallback to scanning distribution* directories sorted by descending index. This is implemented at two layers: - VS Code extension CliResolver (Strategy 3, between PATH and known locations) — uses the StoragePaths-provided storage directory - Server-side cli-executor (fallback when CODEQL_PATH is unset) — probes platform-specific VS Code global storage directories for Code, Code - Insiders, and VSCodium Also fixes extension.test.ts constructor mocks for Vitest 4.x compatibility (vi.clearAllMocks instead of vi.resetAllMocks). T_EDITOR=true git rebase --continue
1 parent 1691206 commit 56a8257

File tree

10 files changed

+835
-168
lines changed

10 files changed

+835
-168
lines changed

.prettierignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,6 @@ node_modules
1616
server/dist/
1717
query-results*
1818
workshops/
19+
20+
.vscode-test
21+

extensions/vscode/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@
137137
"build": "npm run clean && npm run lint && npm run bundle",
138138
"bundle": "node esbuild.config.js",
139139
"bundle:server": "node scripts/bundle-server.js",
140-
"clean": "rm -rf dist server *.vsix",
140+
"clean": "rm -rf dist server .vscode-test/* *.vsix",
141141
"lint": "eslint src/ test/",
142142
"lint:fix": "eslint src/ test/ --fix",
143143
"package": "vsce package --no-dependencies --out codeql-development-mcp-server-v$(node -e 'process.stdout.write(require(`./package.json`).version)').vsix",

extensions/vscode/src/codeql/cli-resolver.ts

Lines changed: 115 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import { execFile } from 'child_process';
2-
import { access } from 'fs/promises';
2+
import { access, readdir, readFile } from 'fs/promises';
33
import { constants } from 'fs';
4+
import { join } from 'path';
45
import { DisposableObject } from '../common/disposable';
56
import type { Logger } from '../common/logger';
67

8+
/** Expected binary name for the CodeQL CLI on the current platform. */
9+
const CODEQL_BINARY_NAME = process.platform === 'win32' ? 'codeql.exe' : 'codeql';
10+
711
/** Known filesystem locations where the CodeQL CLI may be installed. */
812
const KNOWN_LOCATIONS = [
913
'/usr/local/bin/codeql',
@@ -18,15 +22,20 @@ const KNOWN_LOCATIONS = [
1822
* Detection strategy (in order):
1923
* 1. `CODEQL_PATH` environment variable
2024
* 2. `codeql` on `$PATH` (via `which`)
21-
* 3. Known filesystem locations
25+
* 3. `vscode-codeql` managed distribution (via `distribution.json` hint
26+
* or directory scan of `distribution*` folders)
27+
* 4. Known filesystem locations
2228
*
2329
* Results are cached. Call `invalidateCache()` when the environment changes
2430
* (e.g. `extensions.onDidChange` fires).
2531
*/
2632
export class CliResolver extends DisposableObject {
2733
private cachedPath: string | undefined | null = null; // null = not yet resolved
2834

29-
constructor(private readonly logger: Logger) {
35+
constructor(
36+
private readonly logger: Logger,
37+
private readonly vsCodeCodeqlStoragePath?: string,
38+
) {
3039
super();
3140
}
3241

@@ -58,7 +67,15 @@ export class CliResolver extends DisposableObject {
5867
return whichPath;
5968
}
6069

61-
// Strategy 3: known filesystem locations
70+
// Strategy 3: vscode-codeql managed distribution
71+
const distPath = await this.resolveFromVsCodeDistribution();
72+
if (distPath) {
73+
this.logger.info(`CodeQL CLI found via vscode-codeql distribution: ${distPath}`);
74+
this.cachedPath = distPath;
75+
return distPath;
76+
}
77+
78+
// Strategy 4: known filesystem locations
6279
for (const location of KNOWN_LOCATIONS) {
6380
const validated = await this.validateBinary(location);
6481
if (validated) {
@@ -89,6 +106,100 @@ export class CliResolver extends DisposableObject {
89106
}
90107
}
91108

109+
/**
110+
* Discover the CodeQL CLI binary from the `vscode-codeql` extension's
111+
* managed distribution directory.
112+
*
113+
* The `GitHub.vscode-codeql` extension downloads the CodeQL CLI into:
114+
* `<globalStorage>/github.vscode-codeql/distribution<N>/codeql/codeql`
115+
*
116+
* where `<N>` is an incrementing folder index that increases each time
117+
* the extension upgrades the CLI. A `distribution.json` file in the
118+
* storage root contains a `folderIndex` property that identifies the
119+
* current distribution directory. We use that as a fast-path hint and
120+
* fall back to scanning for the highest-numbered `distribution*` folder.
121+
*/
122+
private async resolveFromVsCodeDistribution(): Promise<string | undefined> {
123+
if (!this.vsCodeCodeqlStoragePath) return undefined;
124+
125+
try {
126+
// Fast path: read distribution.json for the exact folder index
127+
const hintPath = await this.resolveFromDistributionJson();
128+
if (hintPath) return hintPath;
129+
} catch {
130+
this.logger.debug('distribution.json hint unavailable, falling back to directory scan');
131+
}
132+
133+
// Fallback: scan for distribution* directories
134+
return this.resolveFromDistributionScan();
135+
}
136+
137+
/**
138+
* Read `distribution.json` to get the current `folderIndex` and validate
139+
* the binary at the corresponding path.
140+
*/
141+
private async resolveFromDistributionJson(): Promise<string | undefined> {
142+
if (!this.vsCodeCodeqlStoragePath) return undefined;
143+
144+
const jsonPath = join(this.vsCodeCodeqlStoragePath, 'distribution.json');
145+
const content = await readFile(jsonPath, 'utf-8');
146+
const data = JSON.parse(content) as { folderIndex?: number };
147+
148+
if (typeof data.folderIndex !== 'number') return undefined;
149+
150+
const binaryPath = join(
151+
this.vsCodeCodeqlStoragePath,
152+
`distribution${data.folderIndex}`,
153+
'codeql',
154+
CODEQL_BINARY_NAME,
155+
);
156+
const validated = await this.validateBinary(binaryPath);
157+
if (validated) {
158+
this.logger.debug(`Resolved CLI via distribution.json (folderIndex=${data.folderIndex})`);
159+
return binaryPath;
160+
}
161+
return undefined;
162+
}
163+
164+
/**
165+
* Scan for `distribution*` directories sorted by numeric suffix (highest
166+
* first) and return the first one containing a valid `codeql` binary.
167+
*/
168+
private async resolveFromDistributionScan(): Promise<string | undefined> {
169+
if (!this.vsCodeCodeqlStoragePath) return undefined;
170+
171+
try {
172+
const entries = await readdir(this.vsCodeCodeqlStoragePath, { withFileTypes: true });
173+
174+
const distDirs = entries
175+
.filter(e => e.isDirectory() && /^distribution\d*$/.test(e.name))
176+
.map(e => ({
177+
name: e.name,
178+
num: parseInt(e.name.replace('distribution', '') || '0', 10),
179+
}))
180+
.sort((a, b) => b.num - a.num);
181+
182+
for (const dir of distDirs) {
183+
const binaryPath = join(
184+
this.vsCodeCodeqlStoragePath,
185+
dir.name,
186+
'codeql',
187+
CODEQL_BINARY_NAME,
188+
);
189+
const validated = await this.validateBinary(binaryPath);
190+
if (validated) {
191+
this.logger.debug(`Resolved CLI via distribution scan: ${dir.name}`);
192+
return binaryPath;
193+
}
194+
}
195+
} catch {
196+
this.logger.debug(
197+
`Could not scan vscode-codeql distribution directory: ${this.vsCodeCodeqlStoragePath}`,
198+
);
199+
}
200+
return undefined;
201+
}
202+
92203
/** Attempt to find `codeql` on PATH. */
93204
private resolveFromPath(): Promise<string | undefined> {
94205
return new Promise((resolve) => {

extensions/vscode/src/extension.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@ export async function activate(
2424
logger.info('CodeQL Development MCP Server extension activating...');
2525

2626
// --- Core components ---
27-
const cliResolver = new CliResolver(logger);
27+
const storagePaths = new StoragePaths(context);
28+
const cliResolver = new CliResolver(logger, storagePaths.getCodeqlGlobalStoragePath());
2829
const serverManager = new ServerManager(context, logger);
2930
const packInstaller = new PackInstaller(cliResolver, serverManager, logger);
30-
const storagePaths = new StoragePaths(context);
3131
const envBuilder = new EnvironmentBuilder(
3232
context,
3333
cliResolver,

extensions/vscode/test/codeql/cli-resolver.test.ts

Lines changed: 180 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ vi.mock('child_process', () => ({
99
// Mock fs/promises
1010
vi.mock('fs/promises', () => ({
1111
access: vi.fn(),
12+
readdir: vi.fn(),
13+
readFile: vi.fn(),
1214
}));
1315

1416
import { execFile } from 'child_process';
15-
import { access } from 'fs/promises';
17+
import { access, readdir, readFile } from 'fs/promises';
1618

1719
function createMockLogger() {
1820
return {
@@ -164,4 +166,181 @@ describe('CliResolver', () => {
164166
it('should be disposable', () => {
165167
expect(() => resolver.dispose()).not.toThrow();
166168
});
169+
170+
describe('vscode-codeql distribution discovery', () => {
171+
const storagePath = '/mock/globalStorage/github.vscode-codeql';
172+
const binaryName = process.platform === 'win32' ? 'codeql.exe' : 'codeql';
173+
174+
beforeEach(() => {
175+
const originalEnv = process.env.CODEQL_PATH;
176+
delete process.env.CODEQL_PATH;
177+
178+
// Make `which codeql` fail
179+
vi.mocked(execFile).mockImplementation(
180+
(_cmd: any, _args: any, callback: any) => {
181+
if (String(_cmd) === 'which' || String(_cmd) === 'where') {
182+
callback(new Error('not found'), '', '');
183+
} else {
184+
// codeql --version for validateBinary
185+
callback(null, 'CodeQL CLI 2.24.2\n', '');
186+
}
187+
return {} as any;
188+
},
189+
);
190+
191+
// All known filesystem locations fail
192+
vi.mocked(access).mockRejectedValue(new Error('ENOENT'));
193+
194+
return () => {
195+
if (originalEnv === undefined) {
196+
delete process.env.CODEQL_PATH;
197+
} else {
198+
process.env.CODEQL_PATH = originalEnv;
199+
}
200+
};
201+
});
202+
203+
it('should resolve from distribution.json hint', async () => {
204+
resolver = new CliResolver(logger, storagePath);
205+
206+
// distribution.json exists with folderIndex=3
207+
vi.mocked(readFile).mockResolvedValueOnce(
208+
JSON.stringify({ folderIndex: 3, release: { name: 'v2.24.2' } }),
209+
);
210+
211+
// The binary at distribution3/codeql/codeql is valid
212+
const expectedPath = `${storagePath}/distribution3/codeql/${binaryName}`;
213+
vi.mocked(access).mockImplementation((path: any) => {
214+
if (String(path) === expectedPath) return Promise.resolve(undefined as any);
215+
return Promise.reject(new Error('ENOENT'));
216+
});
217+
218+
const result = await resolver.resolve();
219+
expect(result).toBe(expectedPath);
220+
expect(logger.info).toHaveBeenCalledWith(
221+
expect.stringContaining('vscode-codeql distribution'),
222+
);
223+
});
224+
225+
it('should fall back to directory scan when distribution.json is missing', async () => {
226+
resolver = new CliResolver(logger, storagePath);
227+
228+
// distribution.json read fails
229+
vi.mocked(readFile).mockRejectedValueOnce(new Error('ENOENT'));
230+
231+
// Directory listing returns distribution directories
232+
vi.mocked(readdir).mockResolvedValueOnce([
233+
{ name: 'distribution1', isDirectory: () => true },
234+
{ name: 'distribution3', isDirectory: () => true },
235+
{ name: 'distribution2', isDirectory: () => true },
236+
{ name: 'queries', isDirectory: () => true },
237+
{ name: 'distribution.json', isDirectory: () => false },
238+
] as any);
239+
240+
// Only distribution3 has a valid binary
241+
const expectedPath = `${storagePath}/distribution3/codeql/${binaryName}`;
242+
vi.mocked(access).mockImplementation((path: any) => {
243+
if (String(path) === expectedPath) return Promise.resolve(undefined as any);
244+
return Promise.reject(new Error('ENOENT'));
245+
});
246+
247+
const result = await resolver.resolve();
248+
expect(result).toBe(expectedPath);
249+
});
250+
251+
it('should scan directories sorted by descending number', async () => {
252+
resolver = new CliResolver(logger, storagePath);
253+
254+
vi.mocked(readFile).mockRejectedValueOnce(new Error('ENOENT'));
255+
256+
vi.mocked(readdir).mockResolvedValueOnce([
257+
{ name: 'distribution1', isDirectory: () => true },
258+
{ name: 'distribution10', isDirectory: () => true },
259+
{ name: 'distribution2', isDirectory: () => true },
260+
] as any);
261+
262+
// All binaries are valid — should pick distribution10 (highest number)
263+
vi.mocked(access).mockResolvedValue(undefined as any);
264+
265+
const result = await resolver.resolve();
266+
expect(result).toBe(`${storagePath}/distribution10/codeql/${binaryName}`);
267+
});
268+
269+
it('should return undefined when no storage path is provided', async () => {
270+
resolver = new CliResolver(logger); // no storage path
271+
272+
vi.mocked(execFile).mockImplementation(
273+
(_cmd: any, _args: any, callback: any) => {
274+
callback(new Error('not found'), '', '');
275+
return {} as any;
276+
},
277+
);
278+
279+
const result = await resolver.resolve();
280+
expect(result).toBeUndefined();
281+
});
282+
283+
it('should skip distribution directories without a valid binary', async () => {
284+
resolver = new CliResolver(logger, storagePath);
285+
286+
vi.mocked(readFile).mockRejectedValueOnce(new Error('ENOENT'));
287+
288+
vi.mocked(readdir).mockResolvedValueOnce([
289+
{ name: 'distribution3', isDirectory: () => true },
290+
{ name: 'distribution2', isDirectory: () => true },
291+
{ name: 'distribution1', isDirectory: () => true },
292+
] as any);
293+
294+
const expectedPath = `${storagePath}/distribution1/codeql/${binaryName}`;
295+
vi.mocked(access).mockImplementation((path: any) => {
296+
// Only distribution1 has the binary
297+
if (String(path) === expectedPath) return Promise.resolve(undefined as any);
298+
return Promise.reject(new Error('ENOENT'));
299+
});
300+
301+
const result = await resolver.resolve();
302+
expect(result).toBe(expectedPath);
303+
});
304+
305+
it('should handle distribution.json with invalid JSON gracefully', async () => {
306+
resolver = new CliResolver(logger, storagePath);
307+
308+
// Return non-JSON content
309+
vi.mocked(readFile).mockResolvedValueOnce('not-valid-json');
310+
311+
vi.mocked(readdir).mockResolvedValueOnce([
312+
{ name: 'distribution1', isDirectory: () => true },
313+
] as any);
314+
315+
const expectedPath = `${storagePath}/distribution1/codeql/${binaryName}`;
316+
vi.mocked(access).mockImplementation((path: any) => {
317+
if (String(path) === expectedPath) return Promise.resolve(undefined as any);
318+
return Promise.reject(new Error('ENOENT'));
319+
});
320+
321+
const result = await resolver.resolve();
322+
expect(result).toBe(expectedPath);
323+
});
324+
325+
it('should handle distribution.json without folderIndex property', async () => {
326+
resolver = new CliResolver(logger, storagePath);
327+
328+
vi.mocked(readFile).mockResolvedValueOnce(
329+
JSON.stringify({ release: { name: 'v2.24.2' } }),
330+
);
331+
332+
vi.mocked(readdir).mockResolvedValueOnce([
333+
{ name: 'distribution1', isDirectory: () => true },
334+
] as any);
335+
336+
const expectedPath = `${storagePath}/distribution1/codeql/${binaryName}`;
337+
vi.mocked(access).mockImplementation((path: any) => {
338+
if (String(path) === expectedPath) return Promise.resolve(undefined as any);
339+
return Promise.reject(new Error('ENOENT'));
340+
});
341+
342+
const result = await resolver.resolve();
343+
expect(result).toBe(expectedPath);
344+
});
345+
});
167346
});

0 commit comments

Comments
 (0)