UNPKG

agnix

Version:

Linter for AI agent configurations. Validates SKILL.md, CLAUDE.md, hooks, MCP, and more.

382 lines (335 loc) • 11 kB
#!/usr/bin/env node const https = require('https'); const fs = require('fs'); const path = require('path'); const { execSync, spawnSync } = require('child_process'); const os = require('os'); const crypto = require('crypto'); const GITHUB_REPO = 'agent-sh/agnix'; const VERSION = require('./package.json').version; function removeIfExists(filePath) { try { if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); } } catch (_) { // best-effort cleanup } } function restoreWrapperBackup(wrapperPath, wrapperBackup, installCompleted) { if (!fs.existsSync(wrapperBackup)) { return; } if (installCompleted) { removeIfExists(wrapperBackup); return; } removeIfExists(wrapperPath); fs.renameSync(wrapperBackup, wrapperPath); } /** * Get platform-specific asset name and binary name. Platform and arch are * parameters so tests can cover mappings other than the host's. */ function getPlatformInfo(platform = os.platform(), arch = os.arch()) { const mapping = { 'darwin-arm64': { asset: 'agnix-aarch64-apple-darwin.tar.gz', extractedName: 'agnix', binary: 'agnix-binary', }, 'darwin-x64': { // x64 Mac uses ARM binary via Rosetta 2 asset: 'agnix-aarch64-apple-darwin.tar.gz', extractedName: 'agnix', binary: 'agnix-binary', }, 'linux-x64': { asset: 'agnix-x86_64-unknown-linux-gnu.tar.gz', // Statically linked, so it runs on hosts whose glibc is older than the // one the gnu build was linked against (issue #1371). fallbackAsset: 'agnix-x86_64-unknown-linux-musl.tar.gz', extractedName: 'agnix', binary: 'agnix-binary', }, 'linux-arm64': { asset: 'agnix-aarch64-unknown-linux-gnu.tar.gz', extractedName: 'agnix', binary: 'agnix-binary', }, 'win32-x64': { asset: 'agnix-x86_64-pc-windows-msvc.zip', extractedName: 'agnix.exe', binary: 'agnix-binary.exe', }, }; const key = `${platform}-${arch}`; const info = mapping[key]; if (!info) { console.error(`Unsupported platform: ${platform}-${arch}`); console.error('Supported platforms: darwin-arm64, darwin-x64, linux-x64, win32-x64'); process.exit(1); } return info; } /** * Download a file from URL, following redirects. */ function downloadFile(url, destPath) { return new Promise((resolve, reject) => { let file = null; let settled = false; const fail = (err) => { if (settled) { return; } settled = true; if (file) { file.destroy(); } removeIfExists(destPath); reject(err); }; const request = https.get(url, (response) => { if (response.statusCode === 302 || response.statusCode === 301) { const redirectUrl = response.headers.location; if (redirectUrl) { response.resume(); const nextUrl = new URL(redirectUrl, url).href; downloadFile(nextUrl, destPath).then(resolve).catch(reject); return; } } if (response.statusCode !== 200) { response.resume(); fail(new Error(`Download failed with status ${response.statusCode}`)); return; } file = fs.createWriteStream(destPath); response.pipe(file); response.on('error', fail); file.on('finish', () => { if (settled) { return; } settled = true; file.close((err) => { if (err) { removeIfExists(destPath); reject(err); } else { resolve(); } }); }); file.on('error', fail); }); request.on('error', fail); }); } /** * Extract archive based on platform. */ function extractArchive(archivePath, destDir) { const platform = os.platform(); if (platform === 'win32') { // Use PowerShell to extract zip execSync( `powershell -Command "Expand-Archive -Path '${archivePath}' -DestinationPath '${destDir}' -Force"`, { stdio: 'inherit' } ); } else { // Use tar for .tar.gz execSync(`tar -xzf "${archivePath}" -C "${destDir}"`, { stdio: 'inherit' }); } } /** * Compute the SHA-256 of a file as a lowercase hex string, streaming the file * so large archives are not read fully into memory. */ function sha256File(filePath) { return new Promise((resolve, reject) => { const hash = crypto.createHash('sha256'); const stream = fs.createReadStream(filePath); stream.on('error', reject); stream.on('data', (chunk) => hash.update(chunk)); stream.on('end', () => resolve(hash.digest('hex'))); }); } function parseSha256Sidecar(contents, expectedFileName) { const expectedBase = path.basename(expectedFileName); for (const line of contents.split(/\r?\n/)) { const trimmed = line.trim(); if (!trimmed) { continue; } const [rawHash, rawFileName] = trimmed.split(/\s+/, 2); if (!rawFileName) { continue; } const normalizedFileName = rawFileName.replace(/^\*/, '').replace(/\\/g, '/'); const baseName = path.posix.basename(normalizedFileName); if (baseName !== expectedBase) { continue; } const expected = rawHash.toLowerCase(); if (!/^[0-9a-f]{64}$/.test(expected)) { throw new Error(`Invalid checksum file for ${expectedBase}`); } return expected; } throw new Error(`Checksum file does not contain an entry for ${expectedBase}`); } /** * Download the `.sha256` sidecar for an archive and verify the archive against * it before extraction, so a tampered download is rejected (mirrors * scripts/download.sh). Throws on a missing/malformed sidecar or hash mismatch. */ async function verifyChecksum(archivePath, checksumUrl, deps = { downloadFile }) { const checksumPath = `${archivePath}.sha256`; try { await deps.downloadFile(checksumUrl, checksumPath); const raw = fs.readFileSync(checksumPath, 'utf8').trim(); const expected = parseSha256Sidecar(raw, path.basename(archivePath)); const actual = (await sha256File(archivePath)).toLowerCase(); if (actual !== expected) { throw new Error( `Checksum mismatch for ${path.basename(archivePath)}: expected ${expected}, got ${actual}` ); } console.log('Checksum verified.'); } finally { removeIfExists(checksumPath); } } /** * Whether the installed binary can actually execute on this host. A binary * built against a newer glibc than the host provides fails in the dynamic * loader, which npm otherwise only surfaces on the user's first real run. */ function binaryRuns(binaryPath, deps = { spawnSync }) { try { const result = deps.spawnSync(binaryPath, ['--version'], { stdio: 'ignore' }); return !result.error && result.status === 0; } catch (_) { return false; } } /** * Download one release asset, verify it, and place the extracted binary at * `binaryPath`. Removes the archive whether or not the download succeeded. */ async function fetchBinary({ asset, extractedName, binDir, binaryPath }) { const downloadUrl = `https://github.com/${GITHUB_REPO}/releases/download/v${VERSION}/${asset}`; const archivePath = path.join(binDir, asset); const extractedPath = path.join(binDir, extractedName); try { await downloadFile(downloadUrl, archivePath); await verifyChecksum(archivePath, `${downloadUrl}.sha256`); console.log('Extracting...'); extractArchive(archivePath, binDir); // Rename extracted binary to avoid conflict with wrapper script if (fs.existsSync(extractedPath) && extractedPath !== binaryPath) { fs.renameSync(extractedPath, binaryPath); } // Make binary executable on Unix if (os.platform() !== 'win32') { fs.chmodSync(binaryPath, 0o755); } if (!fs.existsSync(binaryPath)) { throw new Error('Binary not found after extraction'); } } finally { // Best-effort cleanup so a failed install leaves no stale archive. removeIfExists(archivePath); } } async function main() { const platformInfo = getPlatformInfo(); const binDir = path.join(__dirname, 'bin'); const binaryPath = path.join(binDir, platformInfo.binary); const wrapperPath = path.join(binDir, 'agnix'); const wrapperBackup = path.join(binDir, 'agnix.backup'); let installCompleted = false; // Skip if binary already exists if (fs.existsSync(binaryPath)) { console.log('agnix binary already installed'); return; } // Ensure bin directory exists if (!fs.existsSync(binDir)) { fs.mkdirSync(binDir, { recursive: true }); } console.log(`Downloading agnix v${VERSION} for ${os.platform()}-${os.arch()}...`); try { // Save wrapper script if it exists (npm places it during install) if (fs.existsSync(wrapperPath) && wrapperPath !== binaryPath) { fs.copyFileSync(wrapperPath, wrapperBackup); } await fetchBinary({ asset: platformInfo.asset, extractedName: platformInfo.extractedName, binDir, binaryPath, }); let runs = binaryRuns(binaryPath); // The default Linux asset is glibc-linked, so on a host older than the // build's glibc floor it cannot load at all. Retry with the static build // rather than leaving behind a binary that dies in the dynamic loader. if (!runs && platformInfo.fallbackAsset) { console.log( `Downloaded binary cannot run on this host, retrying with ${platformInfo.fallbackAsset}...` ); removeIfExists(binaryPath); await fetchBinary({ asset: platformInfo.fallbackAsset, extractedName: platformInfo.extractedName, binDir, binaryPath, }); runs = binaryRuns(binaryPath); } // Restore wrapper script if it was backed up if (fs.existsSync(wrapperBackup)) { fs.renameSync(wrapperBackup, wrapperPath); } if (!runs) { // Keep the install itself successful: some sandboxes forbid spawning // during postinstall, and the binary is on disk either way. console.warn('Warning: the installed agnix binary did not run on this host.'); console.warn('If `agnix --version` fails, build from source with: cargo install agnix-cli'); } installCompleted = true; console.log('agnix installed successfully'); } catch (error) { console.error(`Failed to install agnix: ${error.message}`); console.error(''); console.error('You can install manually:'); console.error(' cargo install agnix-cli'); process.exitCode = 1; } finally { // Best-effort cleanup so a failed install leaves no stale backup. Archives // are removed by fetchBinary itself. try { restoreWrapperBackup(wrapperPath, wrapperBackup, installCompleted); } catch (_) { // ignore } } } if (require.main === module) { main(); } module.exports = { binaryRuns, downloadFile, extractArchive, fetchBinary, getPlatformInfo, main, parseSha256Sidecar, removeIfExists, restoreWrapperBackup, sha256File, verifyChecksum, };