tree-ast-grep-mcp
Version:
Simple, direct ast-grep wrapper for AI coding agents. Zero abstractions, maximum performance.
487 lines • 19.8 kB
JavaScript
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
import { execFile, spawn } from 'child_process';
import { promisify } from 'util';
import { BinaryError } from '../types/errors.js';
const execFileAsync = promisify(execFile);
/**
* Manages ast-grep binary discovery, installation, validation, and execution.
*/
export class AstGrepBinaryManager {
binaryPath = null;
isInitialized = false;
options;
/**
* Create a manager with optional installation directives.
*/
constructor(options = {}) {
this.options = options;
}
/**
* Resolve and prepare an ast-grep binary for subsequent tool execution.
*/
async initialize() {
if (this.isInitialized)
return;
// Priority order:
// 1. Custom binary path
if (this.options.customBinaryPath) {
await this.useCustomBinary(this.options.customBinaryPath);
return;
}
// 2. System binary (if requested)
if (this.options.useSystem) {
await this.useSystemBinary();
return;
}
// 3. Platform-specific or auto-install
if (this.options.autoInstall || this.options.platform) {
await this.installPlatformBinary();
return;
}
// 4. Fallback to system binary
await this.useSystemBinary();
}
/**
* Validate and register a caller supplied binary path without downloading.
*/
async useCustomBinary(customPath) {
if (await this.testBinary(customPath)) {
this.binaryPath = customPath;
this.isInitialized = true;
}
else {
throw new BinaryError(`Custom binary path "${customPath}" is not valid`);
}
}
/**
* Locate ast-grep on PATH and ensure it can be executed.
*/
async useSystemBinary() {
const systemPath = await this.findBinaryInPath();
if (systemPath && await this.testBinary(systemPath)) {
this.binaryPath = systemPath;
this.isInitialized = true;
}
else {
throw new BinaryError('ast-grep binary not found in PATH. Please install ast-grep or use --auto-install option.');
}
}
/**
* Download and cache a platform specific ast-grep binary when requested.
*/
async installPlatformBinary() {
const platform = this.options.platform === 'auto' ?
process.platform : this.options.platform || process.platform;
const arch = process.arch;
// Validate platform/architecture support
const supportedPlatforms = ['win32', 'darwin', 'linux'];
const supportedArchs = ['x64', 'arm64'];
if (!supportedPlatforms.includes(platform)) {
console.error(`Unsupported platform: ${platform}. Falling back to system binary.`);
await this.useSystemBinary();
return;
}
if (!supportedArchs.includes(arch)) {
console.error(`Unsupported architecture: ${arch}. Falling back to system binary.`);
await this.useSystemBinary();
return;
}
const cacheDir = this.options.cacheDir ||
path.join(os.homedir(), '.ast-grep-mcp', 'binaries');
const binaryName = this.getBinaryName(platform, arch);
const binaryPath = path.join(cacheDir, binaryName);
// Check if binary exists in cache and is valid
if (await this.fileExists(binaryPath)) {
if (await this.testBinary(binaryPath)) {
console.error(`Using cached binary: ${binaryPath}`);
this.binaryPath = binaryPath;
this.isInitialized = true;
return;
}
else {
// Remove invalid cached binary
try {
await fs.unlink(binaryPath);
}
catch {
// Ignore cleanup errors
}
}
}
// Try to download binary
try {
console.error(`Installing ast-grep binary for ${platform}-${arch}...`);
await this.downloadBinary(platform, arch, binaryPath);
this.binaryPath = binaryPath;
this.isInitialized = true;
console.error(`Successfully installed binary at ${binaryPath}`);
}
catch (error) {
console.error(`Failed to download binary: ${error instanceof Error ? error.message : String(error)}`);
console.error('Falling back to system binary...');
// Fallback to system binary
try {
await this.useSystemBinary();
}
catch (systemError) {
throw new BinaryError(`Failed to install ast-grep binary and no system binary found.\n` +
`Download error: ${error instanceof Error ? error.message : String(error)}\n` +
`System binary error: ${systemError instanceof Error ? systemError.message : String(systemError)}\n\n` +
`Solutions:\n` +
`1. Install ast-grep manually: https://github.com/ast-grep/ast-grep#installation\n` +
`2. Use --use-system option if ast-grep is available\n` +
`3. Set AST_GREP_BINARY_PATH environment variable\n` +
`4. Check network connectivity for automatic download`);
}
}
}
/**
* Search the environment PATH for an ast-grep executable.
*/
async findBinaryInPath() {
const paths = process.env.PATH?.split(path.delimiter) || [];
const binaryNames = process.platform === 'win32' ?
['ast-grep.exe', 'ast-grep.cmd', 'ast-grep.ps1', 'ast-grep'] : ['ast-grep'];
for (const searchPath of paths) {
for (const binaryName of binaryNames) {
const fullPath = path.join(searchPath, binaryName);
if (await this.fileExists(fullPath) && await this.testBinary(fullPath)) {
return fullPath;
}
}
}
return null;
}
/**
* Run --version against the provided binary to confirm it is usable.
*/
async testBinary(binaryPath) {
try {
if (binaryPath.endsWith('.ps1')) {
// For PowerShell scripts, use powershell.exe to execute
await execFileAsync('powershell.exe', ['-File', binaryPath, '--version'], { timeout: 5000 });
}
else if (binaryPath.endsWith('.cmd')) {
// For batch files, use cmd.exe to execute
await execFileAsync('cmd.exe', ['/c', binaryPath, '--version'], { timeout: 5000 });
}
else {
// For executables, run directly
await execFileAsync(binaryPath, ['--version'], { timeout: 5000 });
}
return true;
}
catch {
return false;
}
}
/**
* Determine whether a file exists without throwing on access errors.
*/
async fileExists(filePath) {
try {
await fs.access(filePath);
return true;
}
catch {
return false;
}
}
/**
* Build the expected ast-grep file name for the given platform and architecture.
*/
getBinaryName(platform, arch) {
const extension = platform === 'win32' ? '.exe' : '';
return `ast-grep-${platform}-${arch}${extension}`;
}
/**
* Download, extract, and validate a platform specific ast-grep binary.
*/
async downloadBinary(platform, arch, targetPath) {
const version = '0.39.5'; // Latest version
const baseUrl = `https://github.com/ast-grep/ast-grep/releases/download/${version}`;
const fileMap = {
'win32-x64': 'app-x86_64-pc-windows-msvc.zip',
'win32-arm64': 'app-aarch64-pc-windows-msvc.zip',
'darwin-x64': 'app-x86_64-apple-darwin.zip',
'darwin-arm64': 'app-aarch64-apple-darwin.zip',
'linux-x64': 'app-x86_64-unknown-linux-gnu.zip',
'linux-arm64': 'app-aarch64-unknown-linux-gnu.zip'
};
const fileName = fileMap[`${platform}-${arch}`];
if (!fileName) {
throw new BinaryError(`Unsupported platform: ${platform}-${arch}`);
}
const downloadUrl = `${baseUrl}/${fileName}`;
const tempZipPath = targetPath + '.zip';
// Ensure cache directory exists
await fs.mkdir(path.dirname(targetPath), { recursive: true });
console.error(`Downloading ast-grep binary for ${platform}-${arch}...`);
try {
// Download with retry logic
await this.downloadWithRetry(downloadUrl, tempZipPath, 3);
// Extract binary from zip
await this.extractBinary(tempZipPath, targetPath, platform);
// Set executable permissions on Unix systems
if (platform !== 'win32') {
await fs.chmod(targetPath, '755');
}
// Test the downloaded binary
if (!await this.testBinary(targetPath)) {
throw new BinaryError('Downloaded binary failed validation test');
}
console.error(`Binary installed successfully at ${targetPath}`);
}
catch (error) {
// Cleanup on failure
await this.cleanup([tempZipPath, targetPath]);
if (error instanceof BinaryError) {
throw error;
}
throw new BinaryError(`Failed to download ast-grep binary: ${error instanceof Error ? error.message : String(error)}\n` +
`Fallback options:\n` +
`1. Install ast-grep manually: https://github.com/ast-grep/ast-grep\n` +
`2. Use --use-system option if ast-grep is in PATH\n` +
`3. Set AST_GREP_BINARY_PATH environment variable`);
}
}
/**
* Download a file with retry logic and exponential backoff.
*/
async downloadWithRetry(url, outputPath, maxRetries) {
let lastError = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
console.error(`Download attempt ${attempt}/${maxRetries}...`);
await this.downloadFile(url, outputPath);
return; // Success
}
catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
console.error(`Attempt ${attempt} failed: ${lastError.message}`);
if (attempt < maxRetries) {
// Wait before retry (exponential backoff)
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10000);
console.error(`Retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
throw lastError || new Error('Download failed after all retries');
}
/**
* Stream a remote file to disk using the built in fetch implementation.
*/
async downloadFile(url, outputPath) {
// Use Node.js built-in fetch (available in Node 18+)
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
if (!response.body) {
throw new Error('No response body');
}
// Stream download to handle large files
const fileStream = await fs.open(outputPath, 'w');
const writer = fileStream.createWriteStream();
try {
const reader = response.body.getReader();
let totalBytes = 0;
const contentLength = response.headers.get('content-length');
const expectedBytes = contentLength ? parseInt(contentLength, 10) : 0;
while (true) {
const { done, value } = await reader.read();
if (done)
break;
await writer.write(value);
totalBytes += value.length;
// Log progress for large downloads
if (expectedBytes > 0 && totalBytes % (1024 * 1024) === 0) {
const progress = Math.round((totalBytes / expectedBytes) * 100);
console.error(`Download progress: ${progress}% (${totalBytes}/${expectedBytes} bytes)`);
}
}
console.error(`Download completed: ${totalBytes} bytes`);
}
finally {
await writer.close();
await fileStream.close();
}
}
/**
* Extract the ast-grep binary from an archive and stage the executable.
*/
async extractBinary(zipPath, targetPath, platform) {
try {
const extractDir = path.join(path.dirname(targetPath), 'extract');
await fs.mkdir(extractDir, { recursive: true });
if (platform === 'win32') {
// Use PowerShell on Windows
await execFileAsync('powershell', [
'-Command',
`Expand-Archive -Path "${zipPath}" -DestinationPath "${extractDir}" -Force`
], { timeout: 30000 });
}
else {
// Check if unzip is available, fallback to manual extraction
try {
await execFileAsync('unzip', ['-o', zipPath, '-d', extractDir], { timeout: 30000 });
}
catch (unzipError) {
throw new Error('Unzip command not available. Please install unzip or use --use-system option.');
}
}
// Find the ast-grep binary in extracted files
const extractedFiles = await this.findFilesRecursively(extractDir);
const binaryPattern = platform === 'win32' ? /ast-grep\.exe$/ : /ast-grep$/;
const astGrepFile = extractedFiles.find(file => binaryPattern.test(file));
if (!astGrepFile) {
throw new Error(`ast-grep binary not found in archive. Found files: ${extractedFiles.join(', ')}`);
}
// Move binary to final location
await fs.rename(astGrepFile, targetPath);
// Cleanup extract directory
await fs.rm(extractDir, { recursive: true, force: true });
}
catch (error) {
throw new Error(`Failed to extract binary: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Walk a directory tree and collect file paths for archive extraction.
*/
async findFilesRecursively(dir) {
const files = [];
async function scan(currentDir) {
const items = await fs.readdir(currentDir);
for (const item of items) {
const itemPath = path.join(currentDir, item);
const stats = await fs.stat(itemPath);
if (stats.isFile()) {
files.push(itemPath);
}
else if (stats.isDirectory()) {
await scan(itemPath);
}
}
}
await scan(dir);
return files;
}
/**
* Remove temporary files created during download or extraction.
*/
async cleanup(paths) {
for (const filePath of paths) {
try {
await fs.unlink(filePath);
}
catch {
// Ignore cleanup errors
}
}
}
/**
* Return the resolved ast-grep binary path if initialization succeeded.
*/
getBinaryPath() {
return this.binaryPath;
}
/**
* Execute ast-grep with the provided arguments and optional stdin payload.
*/
async executeAstGrep(args, options = {}) {
if (!this.binaryPath) {
throw new BinaryError('Binary not initialized');
}
const cwd = options.cwd || process.cwd();
const timeout = options.timeout || 30000;
// Determine the command and arguments based on file type
const { command, commandArgs } = this.getExecutionCommand(this.binaryPath, args);
// If stdin is provided, use spawn to write to child stdin
if (options.stdin !== undefined) {
return await new Promise((resolve, reject) => {
const child = spawn(command, commandArgs, { cwd });
let stdout = '';
let stderr = '';
const timer = setTimeout(() => {
try {
child.kill('SIGKILL');
}
catch { }
reject(new Error(`ast-grep execution timed out after ${timeout}ms`));
}, timeout);
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', (chunk) => { stdout += String(chunk); });
child.stderr.on('data', (chunk) => { stderr += String(chunk); });
child.on('error', (err) => {
clearTimeout(timer);
if (err.code === 'ENOENT') {
reject(new BinaryError(`ast-grep binary not found at ${this.binaryPath}`));
}
else {
reject(new Error(`ast-grep execution failed: ${err instanceof Error ? err.message : String(err)}`));
}
});
child.on('close', (code) => {
clearTimeout(timer);
if (code === 0) {
resolve({ stdout, stderr });
}
else {
reject(new Error(`ast-grep exited with code ${code}: ${stderr || stdout}`));
}
});
try {
child.stdin.write(options.stdin);
child.stdin.end();
}
catch (e) {
clearTimeout(timer);
reject(new Error(`Failed to write stdin to ast-grep: ${e instanceof Error ? e.message : String(e)}`));
}
});
}
// No stdin: use execFile
try {
const result = await execFileAsync(command, commandArgs, { cwd, timeout, maxBuffer: 1024 * 1024 * 10 });
return { stdout: result.stdout, stderr: result.stderr };
}
catch (error) {
if (error.code === 'ETIMEDOUT') {
throw new Error(`ast-grep execution timed out after ${timeout}ms`);
}
if (error.code === 'ENOENT') {
throw new BinaryError(`ast-grep binary not found at ${this.binaryPath}`);
}
throw new Error(`ast-grep execution failed: ${error.message}`);
}
}
/**
* Determine the appropriate command wrapper for invoking the binary on each platform.
*/
getExecutionCommand(binaryPath, args) {
if (binaryPath.endsWith('.ps1')) {
return {
command: 'powershell.exe',
commandArgs: ['-File', binaryPath, ...args]
};
}
else if (binaryPath.endsWith('.cmd')) {
return {
command: 'cmd.exe',
commandArgs: ['/c', binaryPath, ...args]
};
}
else {
return {
command: binaryPath,
commandArgs: args
};
}
}
}
//# sourceMappingURL=binary-manager.js.map