UNPKG

@probelabs/probe

Version:

Node.js wrapper for the probe code search tool

1,068 lines (936 loc) 35.6 kB
/** * Binary downloader for the probe package * @module downloader */ import axios from 'axios'; import fs from 'fs-extra'; import path from 'path'; import { createHash } from 'crypto'; import { promisify } from 'util'; import { exec as execCallback } from 'child_process'; import tar from 'tar'; import os from 'os'; import { fileURLToPath } from 'url'; import { ensureBinDirectory } from './utils.js'; import { getPackageBinDir } from './directory-resolver.js'; const exec = promisify(execCallback); /** * Create a plain, serializable Error from possibly complex/circular errors (e.g., axios) * Avoids passing circular req/res objects to IPC serializers and test runners. */ function sanitizeError(err) { try { const status = err?.response?.status; const statusText = err?.response?.statusText; const url = err?.config?.url || err?.response?.config?.url; const code = err?.code; const serverMsg = typeof err?.response?.data === 'string' ? err.response.data.slice(0, 500) : (typeof err?.response?.data?.message === 'string' ? err.response.data.message : undefined); const base = err?.message || String(err); const parts = [base]; if (status || statusText) parts.push(`[${(status ?? '')} ${(statusText ?? '')}]`.trim()); if (code) parts.push(`code=${code}`); if (url) parts.push(`url=${url}`); if (serverMsg) parts.push(`server="${String(serverMsg).replace(/\s+/g, ' ').trim()}"`); const e = new Error(parts.filter(Boolean).join(' ')); if (status) e.status = status; if (code) e.code = code; if (url) e.url = url; return e; } catch (_) { return new Error(err?.message || String(err)); } } // GitHub repository information const REPO_OWNER = "probelabs"; const REPO_NAME = "probe"; const BINARY_NAME = "probe"; // Get the directory of the current module const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // Note: LOCAL_DIR and VERSION_INFO_PATH are now resolved dynamically // using getPackageBinDir() to handle different installation environments // Download lock management - prevents concurrent downloads // // Two-tier locking system: // 1. In-memory locks: Prevent duplicate downloads within the same Node.js process // 2. File-based locks: Coordinate downloads across separate processes // // How it works with multiple processes: // Process A: Creates lock file → Downloads binary → Removes lock file // Process B: Sees lock file → Polls every 1s → Binary appears → Uses binary // Process C: Sees lock file → Polls every 1s → Binary appears → Uses binary // // The polling loop checks every second for: // - Is the binary now available? (download completed) // - Has the lock expired? (>5 minutes old, process crashed) // const downloadLocks = new Map(); // Map of version -> { promise, timestamp } (in-memory, per-process) const LOCK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes timeout for stuck downloads const LOCK_POLL_INTERVAL_MS = 1000; // Poll every 1 second when waiting for file lock const MAX_LOCK_WAIT_MS = 5 * 60 * 1000; // Maximum 5 minutes to wait for file lock /** * Acquires a file-based lock that works across processes * @param {string} lockPath - Path to the lock file * @param {string} version - Version being locked * @returns {Promise<boolean|null>} True if lock was acquired, false if locked by another process, null if locking unavailable (permissions/errors) */ async function acquireFileLock(lockPath, version) { const lockData = { version, pid: process.pid, timestamp: Date.now() }; try { // Try to create lock file atomically (fails if already exists) await fs.writeFile(lockPath, JSON.stringify(lockData), { flag: 'wx' }); if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Acquired file lock: ${lockPath}`); } return true; } catch (error) { if (error.code === 'EEXIST') { // Lock file exists - check if it's stale try { const existingLock = JSON.parse(await fs.readFile(lockPath, 'utf-8')); const lockAge = Date.now() - existingLock.timestamp; if (lockAge > LOCK_TIMEOUT_MS) { // Lock is stale, remove it if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Removing stale lock file (age: ${Math.round(lockAge / 1000)}s, pid: ${existingLock.pid})`); } await fs.remove(lockPath); return false; // Caller should retry } // Lock is fresh, another process is downloading if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Download in progress by process ${existingLock.pid}, waiting...`); } return false; } catch (readError) { // Can't read lock file, might be corrupted - remove it if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Lock file corrupted, removing: ${readError.message}`); } try { await fs.remove(lockPath); } catch {} return false; } } // Handle permission errors and other filesystem errors if (error.code === 'EACCES' || error.code === 'EPERM' || error.code === 'EROFS') { if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Cannot create lock file (${error.code}): ${lockPath}`); console.log(`File-based locking unavailable, will proceed without cross-process coordination`); } return null; // Lock unavailable, caller should proceed without it } // For other errors, log and return null (proceed without lock) if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Unexpected error creating lock file: ${error.message}`); console.log(`Proceeding without file-based lock`); } return null; } } /** * Releases a file-based lock * @param {string} lockPath - Path to the lock file */ async function releaseFileLock(lockPath) { try { await fs.remove(lockPath); if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Released file lock: ${lockPath}`); } } catch (error) { // Ignore errors when releasing lock if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Warning: Could not release lock file: ${error.message}`); } } } /** * Waits for a file-based lock to be released and the download to complete * Uses a polling loop that checks every second for: * 1. Binary is now available (download completed) * 2. Lock has expired (>5 minutes old) * * @param {string} lockPath - Path to the lock file * @param {string} binaryPath - Expected path to the downloaded binary * @returns {Promise<boolean>} True if binary appeared, false if timed out */ async function waitForFileLock(lockPath, binaryPath) { const startTime = Date.now(); // Poll in a loop until binary appears, lock expires, or we timeout while (Date.now() - startTime < MAX_LOCK_WAIT_MS) { // Check #1: Is the binary now available? if (await fs.pathExists(binaryPath)) { if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Binary now available at ${binaryPath}, download completed by another process`); } return true; } // Check #2: Is the lock file gone? (download finished or failed) const lockExists = await fs.pathExists(lockPath); if (!lockExists) { if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Lock file removed but binary not found - download may have failed`); } return false; } // Check #3: Is the lock stale (expired)? try { const lockData = JSON.parse(await fs.readFile(lockPath, 'utf-8')); const lockAge = Date.now() - lockData.timestamp; if (lockAge > LOCK_TIMEOUT_MS) { if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Lock expired (age: ${Math.round(lockAge / 1000)}s), will retry download`); } return false; } } catch { // Ignore errors reading lock file - will retry on next poll } // Wait 1 second before checking again await new Promise(resolve => setTimeout(resolve, LOCK_POLL_INTERVAL_MS)); } if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Timeout waiting for file lock`); } return false; } /** * Acquires a download lock for a specific version (in-memory for same process) * If another download is in progress in the same process, waits for it to complete * Includes timeout mechanism to prevent permanent locks from failed downloads * @param {string} version - Version being downloaded * @param {Function} downloadFn - Function to execute if lock is acquired * @returns {Promise<string>} Path to the binary */ async function withDownloadLock(version, downloadFn) { const lockKey = version || 'latest'; // First, check in-memory lock (same process) if (downloadLocks.has(lockKey)) { const lock = downloadLocks.get(lockKey); const lockAge = Date.now() - lock.timestamp; // If lock is too old, it's likely stuck - remove it and start fresh if (lockAge > LOCK_TIMEOUT_MS) { if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`In-memory lock for version ${lockKey} expired (age: ${Math.round(lockAge / 1000)}s), removing stale lock`); } downloadLocks.delete(lockKey); } else { if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Download already in progress in this process for version ${lockKey}, waiting...`); } try { return await lock.promise; } catch (error) { // If the locked download failed, we'll try again below if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`In-memory locked download failed, will retry: ${error.message}`); } } } } // Create new download promise with timeout protection const downloadPromise = Promise.race([ downloadFn(), new Promise((_, reject) => setTimeout(() => reject(new Error(`Download timeout after ${LOCK_TIMEOUT_MS / 1000}s`)), LOCK_TIMEOUT_MS) ) ]); downloadLocks.set(lockKey, { promise: downloadPromise, timestamp: Date.now() }); try { const result = await downloadPromise; return result; } finally { // Clean up lock after download completes (success or failure) downloadLocks.delete(lockKey); } } /** * Detects the current OS and architecture * @returns {Object} Object containing OS and architecture information */ function detectOsArch() { const osType = os.platform(); const archType = os.arch(); let osInfo; let archInfo; // Detect OS switch (osType) { case 'linux': osInfo = { type: 'linux', keywords: ['linux', 'Linux', 'musl', 'gnu'] }; break; case 'darwin': osInfo = { type: 'darwin', keywords: ['darwin', 'Darwin', 'mac', 'Mac', 'apple', 'Apple', 'osx', 'OSX'] }; break; case 'win32': osInfo = { type: 'windows', keywords: ['windows', 'Windows', 'msvc', 'pc-windows'] }; break; default: throw new Error(`Unsupported operating system: ${osType}`); } // Detect architecture switch (archType) { case 'x64': archInfo = { type: 'x86_64', keywords: ['x86_64', 'amd64', 'x64', '64bit', '64-bit'] }; break; case 'arm64': archInfo = { type: 'aarch64', keywords: ['arm64', 'aarch64', 'arm', 'ARM'] }; break; default: throw new Error(`Unsupported architecture: ${archType}`); } if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Detected OS: ${osInfo.type}, Architecture: ${archInfo.type}`); } return { os: osInfo, arch: archInfo }; } /** * Constructs the asset name and download URL directly based on version and platform * @param {string} version - The version to download (e.g., "0.6.0-rc60") * @param {Object} osInfo - OS information from detectOsArch() * @param {Object} archInfo - Architecture information from detectOsArch() * @returns {Object} Asset information with name and url */ function constructAssetInfo(version, osInfo, archInfo) { let platform; let extension; // Map OS and arch to the expected format in release names switch (osInfo.type) { case 'linux': platform = `${archInfo.type}-unknown-linux-musl`; extension = 'tar.gz'; break; case 'darwin': platform = `${archInfo.type}-apple-darwin`; extension = 'tar.gz'; break; case 'windows': platform = `${archInfo.type}-pc-windows-msvc`; extension = 'zip'; break; default: throw new Error(`Unsupported OS type: ${osInfo.type}`); } const assetName = `probe-v${version}-${platform}.${extension}`; const checksumName = `${assetName}.sha256`; const baseUrl = `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases/download/v${version}`; const assetUrl = `${baseUrl}/${assetName}`; const checksumUrl = `${baseUrl}/${checksumName}`; if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Constructed asset URL: ${assetUrl}`); } return { name: assetName, url: assetUrl, checksumName: checksumName, checksumUrl: checksumUrl }; } /** * Gets the latest release information from GitHub * @param {string} [version] - Specific version to get * @returns {Promise<Object>} Release information */ async function getLatestRelease(version) { if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log('Fetching release information...'); } try { let releaseUrl; if (version) { // Always use the specified version releaseUrl = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags/v${version}`; } else { // Get all releases to find the most recent one (including prereleases) releaseUrl = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/releases`; } const response = await axios.get(releaseUrl); if (response.status !== 200) { throw new Error(`Failed to fetch release information: ${response.statusText}`); } let releaseData; if (version) { // Single release for specific version releaseData = response.data; } else { // Array of releases, pick the most recent one (first in the array) if (!Array.isArray(response.data) || response.data.length === 0) { throw new Error('No releases found'); } releaseData = response.data[0]; } const tag = releaseData.tag_name; const assets = releaseData.assets.map(asset => ({ name: asset.name, url: asset.browser_download_url })); if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Found release: ${tag} with ${assets.length} assets`); } return { tag, assets }; } catch (error) { if (axios.isAxiosError(error) && error.response?.status === 404) { // If the specific version is not found, try to get all releases if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Release v${version} not found, trying to fetch all releases...`); } const response = await axios.get(`https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/releases`); if (response.data.length === 0) { throw new Error('No releases found'); } // Try to find a release that matches the version let bestRelease = response.data[0]; // Default to latest release if (version && version !== '0.0.0') { if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Looking for releases matching version: ${version}`); console.log(`Available releases: ${response.data.slice(0, 5).map(r => r.tag_name).join(', ')}...`); } // Try to find exact match first for (const release of response.data) { const releaseTag = release.tag_name.startsWith('v') ? release.tag_name.substring(1) : release.tag_name; if (releaseTag === version) { if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Found exact matching release: ${release.tag_name}`); } bestRelease = release; break; } } // If no exact match, try to find a release with matching major.minor version if (bestRelease === response.data[0]) { const versionParts = version.split(/[\.-]/); const majorMinor = versionParts.slice(0, 2).join('.'); if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Looking for releases matching major.minor: ${majorMinor}`); } for (const release of response.data) { const releaseTag = release.tag_name.startsWith('v') ? release.tag_name.substring(1) : release.tag_name; const releaseVersionParts = releaseTag.split(/[\.-]/); const releaseMajorMinor = releaseVersionParts.slice(0, 2).join('.'); if (releaseMajorMinor === majorMinor) { if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Found matching major.minor release: ${release.tag_name}`); } bestRelease = release; break; } } } } const tag = bestRelease.tag_name; const assets = bestRelease.assets.map(asset => ({ name: asset.name, url: asset.browser_download_url })); if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Using release: ${tag} with ${assets.length} assets`); } return { tag, assets }; } throw sanitizeError(error); } } /** * Finds the best matching asset for the current OS and architecture * @param {Array} assets - List of assets * @param {Object} osInfo - OS information * @param {Object} archInfo - Architecture information * @returns {Object} Best matching asset */ function findBestAsset(assets, osInfo, archInfo) { if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Finding appropriate binary for ${osInfo.type} ${archInfo.type}...`); } let bestAsset = null; let bestScore = 0; for (const asset of assets) { // Skip checksum files if (asset.name.endsWith('.sha256') || asset.name.endsWith('.md5') || asset.name.endsWith('.asc')) { continue; } if (osInfo.type === 'windows' && asset.name.match(/darwin|linux/)) { if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Skipping non-Windows binary: ${asset.name}`); } continue; } else if (osInfo.type === 'darwin' && asset.name.match(/windows|msvc|linux/)) { if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Skipping non-macOS binary: ${asset.name}`); } continue; } else if (osInfo.type === 'linux' && asset.name.match(/darwin|windows|msvc/)) { if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Skipping non-Linux binary: ${asset.name}`); } continue; } let score = 0; if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Evaluating asset: ${asset.name}`); } // Check for OS match - give higher priority to exact OS matches let osMatched = false; for (const keyword of osInfo.keywords) { if (asset.name.includes(keyword)) { score += 10; osMatched = true; if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(` OS match found (${keyword}): +10, score = ${score}`); } break; } } // Check for architecture match for (const keyword of archInfo.keywords) { if (asset.name.includes(keyword)) { score += 5; if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(` Arch match found (${keyword}): +5, score = ${score}`); } break; } } // Prefer exact matches for binary name if (asset.name.startsWith(`${BINARY_NAME}-`)) { score += 3; if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(` Binary name match: +3, score = ${score}`); } } if (osMatched && score >= 15) { score += 5; if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(` OS+Arch bonus: +5, score = ${score}`); } } if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(` Final score for ${asset.name}: ${score}`); } // If we have a perfect match, use it immediately if (score === 23) { if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Found perfect match: ${asset.name}`); } return asset; } // Otherwise, keep track of the best match so far if (score > bestScore) { bestScore = score; bestAsset = asset; if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(` New best asset: ${asset.name} (score: ${score})`); } } } if (!bestAsset) { throw new Error(`Could not find a suitable binary for ${osInfo.type} ${archInfo.type}`); } if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Selected asset: ${bestAsset.name} (score: ${bestScore})`); } return bestAsset; } /** * Downloads the asset and its checksum * @param {Object} asset - Asset to download * @param {string} outputDir - Directory to save to * @returns {Promise<Object>} Paths to the asset and checksum */ async function downloadAsset(asset, outputDir) { await fs.ensureDir(outputDir); const assetPath = path.join(outputDir, asset.name); if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Downloading ${asset.name}...`); } // Download the asset const assetResponse = await axios.get(asset.url, { responseType: 'arraybuffer' }); await fs.writeFile(assetPath, Buffer.from(assetResponse.data)); // Try to download the checksum const checksumUrl = asset.checksumUrl || `${asset.url}.sha256`; const checksumFileName = asset.checksumName || `${asset.name}.sha256`; let checksumPath = null; try { if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Downloading checksum...`); } const checksumResponse = await axios.get(checksumUrl); checksumPath = path.join(outputDir, checksumFileName); await fs.writeFile(checksumPath, checksumResponse.data); } catch (error) { if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log('No checksum file found, skipping verification'); } } return { assetPath, checksumPath }; } /** * Verifies the checksum of the downloaded asset * @param {string} assetPath - Path to the asset * @param {string|null} checksumPath - Path to the checksum file * @returns {Promise<boolean>} Whether verification succeeded */ async function verifyChecksum(assetPath, checksumPath) { if (!checksumPath) { return true; } if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Verifying checksum...`); } // Read the expected checksum const checksumContent = await fs.readFile(checksumPath, 'utf-8'); const expectedChecksum = checksumContent.trim().split(' ')[0]; // Calculate the actual checksum const fileBuffer = await fs.readFile(assetPath); const actualChecksum = createHash('sha256').update(fileBuffer).digest('hex'); if (expectedChecksum !== actualChecksum) { console.error(`Checksum verification failed!`); console.error(`Expected: ${expectedChecksum}`); console.error(`Actual: ${actualChecksum}`); return false; } if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Checksum verified successfully`); } return true; } /** * Extracts and installs the binary * @param {string} assetPath - Path to the asset * @param {string} outputDir - Directory to extract to * @returns {Promise<string>} Path to the extracted binary */ async function extractBinary(assetPath, outputDir) { if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Extracting ${path.basename(assetPath)}...`); } const assetName = path.basename(assetPath); const isWindows = os.platform() === 'win32'; // Use the correct binary name: probe.exe for Windows, probe-binary for Unix const binaryName = isWindows ? `${BINARY_NAME}.exe` : `${BINARY_NAME}-binary`; const binaryPath = path.join(outputDir, binaryName); try { // Create a temporary extraction directory const extractDir = path.join(outputDir, 'temp_extract'); await fs.ensureDir(extractDir); // Determine file type and extract accordingly if (assetName.endsWith('.tar.gz') || assetName.endsWith('.tgz')) { if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Extracting tar.gz to ${extractDir}...`); } await tar.extract({ file: assetPath, cwd: extractDir }); } else if (assetName.endsWith('.zip')) { if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Extracting zip to ${extractDir}...`); } await exec(`unzip -q "${assetPath}" -d "${extractDir}"`); } else { // Assume it's a direct binary if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Copying binary directly to ${binaryPath}`); } await fs.copyFile(assetPath, binaryPath); // Make the binary executable if (!isWindows) { await fs.chmod(binaryPath, 0o755); } // Clean up the extraction directory await fs.remove(extractDir); if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Binary installed to ${binaryPath}`); } return binaryPath; } // Find the binary in the extracted files if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Searching for binary in extracted files...`); } const findBinary = async (dir) => { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { const result = await findBinary(fullPath); if (result) return result; } else if (entry.isFile()) { // Check if this is the binary we're looking for if (entry.name === binaryName || entry.name === BINARY_NAME || (isWindows && entry.name.endsWith('.exe'))) { return fullPath; } } } return null; }; const binaryFilePath = await findBinary(extractDir); if (!binaryFilePath) { // List all extracted files for debugging const allFiles = await fs.readdir(extractDir, { recursive: true }); console.error(`Binary not found in extracted files. Found: ${allFiles.join(', ')}`); throw new Error(`Binary not found in the archive.`); } // Copy the binary directly to the final location if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Found binary at ${binaryFilePath}`); console.log(`Copying binary to ${binaryPath}`); } await fs.copyFile(binaryFilePath, binaryPath); // Make the binary executable if (!isWindows) { await fs.chmod(binaryPath, 0o755); } // Clean up await fs.remove(extractDir); if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Binary successfully installed to ${binaryPath}`); } return binaryPath; } catch (error) { console.error(`Error extracting binary: ${error instanceof Error ? error.message : String(error)}`); throw sanitizeError(error); } } /** * Gets version info from the version file * @returns {Promise<Object|null>} Version information */ async function getVersionInfo(binDir) { try { const versionInfoPath = path.join(binDir, 'version-info.json'); if (await fs.pathExists(versionInfoPath)) { const content = await fs.readFile(versionInfoPath, 'utf-8'); return JSON.parse(content); } return null; } catch (error) { console.warn(`Warning: Could not read version info: ${error}`); return null; } } /** * Saves version info to the version file * @param {string} version - Version to save * @param {string} binDir - Directory where version info should be saved * @returns {Promise<void>} */ async function saveVersionInfo(version, binDir) { const versionInfo = { version, lastUpdated: new Date().toISOString() }; const versionInfoPath = path.join(binDir, 'version-info.json'); await fs.writeFile(versionInfoPath, JSON.stringify(versionInfo, null, 2)); if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Version info saved: ${version} at ${versionInfoPath}`); } } /** * Gets the package version from package.json * @returns {Promise<string>} Package version */ async function getPackageVersion() { try { // Try multiple possible locations for package.json const possiblePaths = [ path.resolve(__dirname, '..', 'package.json'), // When installed from npm: src/../package.json path.resolve(__dirname, '..', '..', 'package.json') // In development: src/../../package.json ]; for (const packageJsonPath of possiblePaths) { try { if (fs.existsSync(packageJsonPath)) { if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Found package.json at: ${packageJsonPath}`); } const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); if (packageJson.version) { if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Using version from package.json: ${packageJson.version}`); } return packageJson.version; } } } catch (err) { console.error(`Error reading package.json at ${packageJsonPath}:`, err); } } // If we can't find the version in package.json, return a default version return '0.0.0'; } catch (error) { console.error('Error getting package version:', error); return '0.0.0'; } } /** * Internal function that performs the actual download * @param {string} version - Version to download * @returns {Promise<string>} Path to the downloaded binary */ async function doDownload(version) { // Get writable directory for binary storage (handles CI, npx, Docker scenarios) const localDir = await getPackageBinDir(); if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Downloading probe binary (version: ${version || 'latest'})...`); console.log(`Using binary directory: ${localDir}`); } const isWindows = os.platform() === 'win32'; // Use the correct binary name: probe.exe for Windows, probe-binary for Unix const binaryName = isWindows ? `${BINARY_NAME}.exe` : `${BINARY_NAME}-binary`; const binaryPath = path.join(localDir, binaryName); // Get OS and architecture information const { os: osInfo, arch: archInfo } = detectOsArch(); // Determine which version to download let versionToUse = version; let bestAsset; let tagVersion; if (!versionToUse || versionToUse === '0.0.0') { // No specific version - use GitHub API to get the latest release if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log('No specific version requested, will use the latest release'); } const { tag, assets } = await getLatestRelease(undefined); tagVersion = tag.startsWith('v') ? tag.substring(1) : tag; bestAsset = findBestAsset(assets, osInfo, archInfo); if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Found release version: ${tagVersion}`); } } else { // Specific version requested - construct download URL directly if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Direct download for version: ${versionToUse}`); } tagVersion = versionToUse; bestAsset = constructAssetInfo(versionToUse, osInfo, archInfo); } const { assetPath, checksumPath } = await downloadAsset(bestAsset, localDir); // Verify checksum if available const checksumValid = await verifyChecksum(assetPath, checksumPath); if (!checksumValid) { throw new Error('Checksum verification failed'); } // Extract the binary const extractedBinaryPath = await extractBinary(assetPath, localDir); // Save the version information await saveVersionInfo(tagVersion, localDir); // Clean up the downloaded archive try { await fs.remove(assetPath); if (checksumPath) { await fs.remove(checksumPath); } } catch (err) { if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Warning: Could not clean up temporary files: ${err}`); } } if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Binary successfully installed at ${extractedBinaryPath} (version: ${tagVersion})`); } return extractedBinaryPath; } /** * Downloads the probe binary with download locking to prevent concurrent downloads * @param {string} [version] - Specific version to download * @returns {Promise<string>} Path to the downloaded binary */ export async function downloadProbeBinary(version) { try { // Get writable directory for binary storage (handles CI, npx, Docker scenarios) const localDir = await getPackageBinDir(); // If no version is specified, use the package version if (!version || version === '0.0.0') { version = await getPackageVersion(); } const isWindows = os.platform() === 'win32'; const binaryName = isWindows ? `${BINARY_NAME}.exe` : `${BINARY_NAME}-binary`; const binaryPath = path.join(localDir, binaryName); // Check if the binary already exists and version matches if (await fs.pathExists(binaryPath)) { const versionInfo = await getVersionInfo(localDir); // If versions match, use existing binary if (versionInfo && versionInfo.version === version) { if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Using existing binary at ${binaryPath} (version: ${versionInfo.version})`); } return binaryPath; } if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Existing binary version (${versionInfo?.version || 'unknown'}) doesn't match requested version (${version}). Downloading new version...`); } } // File-based lock for cross-process coordination const lockPath = path.join(localDir, `.probe-download-${version}.lock`); // Try to acquire file lock with retries const maxRetries = 3; for (let retry = 0; retry < maxRetries; retry++) { const lockAcquired = await acquireFileLock(lockPath, version); if (lockAcquired === true) { // We got the lock - do the download try { const result = await withDownloadLock(version, () => doDownload(version)); return result; } finally { // Always release file lock await releaseFileLock(lockPath); } } if (lockAcquired === null) { // File locking unavailable (permissions/errors) - proceed without it if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`File-based locking unavailable, downloading without cross-process coordination`); } return await withDownloadLock(version, () => doDownload(version)); } // lockAcquired === false: Lock not acquired - another process is downloading // Wait for the download to complete const downloadCompleted = await waitForFileLock(lockPath, binaryPath); if (downloadCompleted) { // Binary is now available return binaryPath; } // Download failed or lock became stale - retry if (retry < maxRetries - 1) { if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`Retrying download (attempt ${retry + 2}/${maxRetries})...`); } } } // All retries exhausted - try one last download without lock if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') { console.log(`All lock attempts exhausted, attempting direct download`); } return await withDownloadLock(version, () => doDownload(version)); } catch (error) { console.error('Error downloading probe binary:', error?.message || String(error)); throw sanitizeError(error); } }