diff --git a/.gitignore b/.gitignore index ae9a5ef..9ebbbd4 100644 --- a/.gitignore +++ b/.gitignore @@ -158,6 +158,20 @@ $RECYCLE.BIN/ /docs/_build/ /site/ +# ============================================================================= +# Node.js / npm +# ============================================================================= + +# Downloaded binaries (installed via postinstall) +/bin/ + +# npm package files +node_modules/ +package-lock.json +npm-debug.log* +yarn-debug.log* +yarn-error.log* + # ============================================================================= # Misc # ============================================================================= diff --git a/npm/postinstall.js b/npm/postinstall.js new file mode 100644 index 0000000..fae7021 --- /dev/null +++ b/npm/postinstall.js @@ -0,0 +1,200 @@ +#!/usr/bin/env node + +/** + * Postinstall script for agent-precommit npm package. + * Downloads the appropriate prebuilt binary for the current platform. + */ + +const fs = require('fs'); +const path = require('path'); +const https = require('https'); +const { spawn } = require('child_process'); +const { createWriteStream, mkdirSync, chmodSync, existsSync, unlinkSync } = fs; + +const PACKAGE_VERSION = require('../package.json').version; +const BINARY_NAME = process.platform === 'win32' ? 'apc.exe' : 'apc'; +const REPO = 'agent-precommit/agent-precommit'; + +// Map Node.js platform/arch to Rust target triples +const PLATFORM_MAPPING = { + 'darwin-x64': 'x86_64-apple-darwin', + 'darwin-arm64': 'aarch64-apple-darwin', + 'linux-x64': 'x86_64-unknown-linux-gnu', + 'linux-arm64': 'aarch64-unknown-linux-gnu', + 'win32-x64': 'x86_64-pc-windows-msvc', + 'win32-arm64': 'aarch64-pc-windows-msvc', +}; + +function getPlatformKey() { + return `${process.platform}-${process.arch}`; +} + +function getTargetTriple() { + const key = getPlatformKey(); + const triple = PLATFORM_MAPPING[key]; + if (!triple) { + throw new Error( + `Unsupported platform: ${key}. ` + + `Supported platforms: ${Object.keys(PLATFORM_MAPPING).join(', ')}` + ); + } + return triple; +} + +function getDownloadUrl(targetTriple) { + const ext = process.platform === 'win32' ? 'zip' : 'tar.gz'; + const filename = `apc-v${PACKAGE_VERSION}-${targetTriple}.${ext}`; + return `https://github.com/${REPO}/releases/download/v${PACKAGE_VERSION}/${filename}`; +} + +function getBinDir() { + const binDir = path.join(__dirname, '..', 'bin'); + if (!existsSync(binDir)) { + mkdirSync(binDir, { recursive: true }); + } + return binDir; +} + +function download(url, dest) { + return new Promise((resolve, reject) => { + const file = createWriteStream(dest); + + const request = (currentUrl, redirectCount = 0) => { + if (redirectCount > 5) { + reject(new Error('Too many redirects')); + return; + } + + https.get(currentUrl, (response) => { + // Handle redirects + if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) { + file.close(); + request(response.headers.location, redirectCount + 1); + return; + } + + if (response.statusCode !== 200) { + file.close(); + unlinkSync(dest); + reject(new Error(`Failed to download: HTTP ${response.statusCode}`)); + return; + } + + response.pipe(file); + + file.on('finish', () => { + file.close(); + resolve(); + }); + }).on('error', (err) => { + file.close(); + if (existsSync(dest)) { + unlinkSync(dest); + } + reject(err); + }); + }; + + request(url); + }); +} + +function extractTarGz(archivePath, destDir) { + return new Promise((resolve, reject) => { + const tar = spawn('tar', ['xzf', archivePath, '-C', destDir], { + stdio: 'inherit', + }); + + tar.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`tar extraction failed with code ${code}`)); + } + }); + + tar.on('error', reject); + }); +} + +function extractZip(archivePath, destDir) { + return new Promise((resolve, reject) => { + // On Windows, use PowerShell to extract + const powershell = spawn('powershell', [ + '-Command', + `Expand-Archive -Path '${archivePath}' -DestinationPath '${destDir}' -Force` + ], { + stdio: 'inherit', + }); + + powershell.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`zip extraction failed with code ${code}`)); + } + }); + + powershell.on('error', reject); + }); +} + +async function install() { + const targetTriple = getTargetTriple(); + const downloadUrl = getDownloadUrl(targetTriple); + const binDir = getBinDir(); + const isWindows = process.platform === 'win32'; + const archiveExt = isWindows ? 'zip' : 'tar.gz'; + const archivePath = path.join(binDir, `apc.${archiveExt}`); + const binaryPath = path.join(binDir, BINARY_NAME); + + console.log(`[agent-precommit] Platform: ${getPlatformKey()}`); + console.log(`[agent-precommit] Target: ${targetTriple}`); + console.log(`[agent-precommit] Downloading from: ${downloadUrl}`); + + try { + // Download the archive + await download(downloadUrl, archivePath); + console.log('[agent-precommit] Download complete'); + + // Extract the binary + if (isWindows) { + await extractZip(archivePath, binDir); + } else { + await extractTarGz(archivePath, binDir); + } + console.log('[agent-precommit] Extraction complete'); + + // Clean up the archive + unlinkSync(archivePath); + + // Make binary executable (Unix only) + if (!isWindows && existsSync(binaryPath)) { + chmodSync(binaryPath, 0o755); + } + + // Verify the binary exists + if (!existsSync(binaryPath)) { + throw new Error(`Binary not found at ${binaryPath} after extraction`); + } + + console.log('[agent-precommit] Installation complete!'); + } catch (error) { + console.error(`[agent-precommit] Installation failed: ${error.message}`); + console.error('[agent-precommit] You can try installing manually:'); + console.error(`[agent-precommit] cargo install agent-precommit`); + console.error(`[agent-precommit] # or`); + console.error(`[agent-precommit] pip install agent-precommit`); + + // Don't fail the npm install - the run.js will handle the missing binary + process.exit(0); + } +} + +// Check if we should skip installation (e.g., in CI or when using cargo) +if (process.env.AGENT_PRECOMMIT_SKIP_INSTALL === '1') { + console.log('[agent-precommit] Skipping binary download (AGENT_PRECOMMIT_SKIP_INSTALL=1)'); + process.exit(0); +} + +install(); diff --git a/npm/run.js b/npm/run.js new file mode 100644 index 0000000..a5f0ef7 --- /dev/null +++ b/npm/run.js @@ -0,0 +1,72 @@ +#!/usr/bin/env node + +/** + * Runner script for agent-precommit npm package. + * Executes the platform-specific binary with all provided arguments. + */ + +const { spawn } = require('child_process'); +const path = require('path'); +const fs = require('fs'); + +const BINARY_NAME = process.platform === 'win32' ? 'apc.exe' : 'apc'; + +function getBinaryPath() { + // First, check in the bin directory (installed via postinstall) + const binPath = path.join(__dirname, '..', 'bin', BINARY_NAME); + if (fs.existsSync(binPath)) { + return binPath; + } + + // Check if apc is available in PATH (installed via cargo or other means) + const { execSync } = require('child_process'); + try { + const whichCmd = process.platform === 'win32' ? 'where' : 'which'; + const result = execSync(`${whichCmd} apc`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }); + const systemPath = result.trim().split('\n')[0]; + if (systemPath && fs.existsSync(systemPath)) { + return systemPath; + } + } catch { + // apc not found in PATH + } + + return null; +} + +function main() { + const binaryPath = getBinaryPath(); + + if (!binaryPath) { + console.error('[agent-precommit] Error: Binary not found.'); + console.error('[agent-precommit] The prebuilt binary could not be downloaded during installation.'); + console.error('[agent-precommit] '); + console.error('[agent-precommit] You can install agent-precommit manually:'); + console.error('[agent-precommit] cargo install agent-precommit'); + console.error('[agent-precommit] # or'); + console.error('[agent-precommit] pip install agent-precommit'); + console.error('[agent-precommit] '); + console.error('[agent-precommit] Or download directly from:'); + console.error('[agent-precommit] https://github.com/agent-precommit/agent-precommit/releases'); + process.exit(1); + } + + // Forward all arguments to the binary + const args = process.argv.slice(2); + + const child = spawn(binaryPath, args, { + stdio: 'inherit', + env: process.env, + }); + + child.on('error', (error) => { + console.error(`[agent-precommit] Failed to execute binary: ${error.message}`); + process.exit(1); + }); + + child.on('close', (code) => { + process.exit(code ?? 0); + }); +} + +main(); diff --git a/package.json b/package.json new file mode 100644 index 0000000..9f80fcd --- /dev/null +++ b/package.json @@ -0,0 +1,46 @@ +{ + "name": "agent-precommit", + "version": "0.1.0", + "description": "Smart pre-commit hooks for humans and AI coding agents", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/agent-precommit/agent-precommit.git" + }, + "homepage": "https://github.com/agent-precommit/agent-precommit", + "bugs": { + "url": "https://github.com/agent-precommit/agent-precommit/issues" + }, + "keywords": [ + "git", + "precommit", + "pre-commit", + "hooks", + "ai", + "agents", + "cli" + ], + "bin": { + "apc": "npm/run.js", + "agent-precommit": "npm/run.js" + }, + "files": [ + "npm", + "bin" + ], + "scripts": { + "postinstall": "node npm/postinstall.js" + }, + "engines": { + "node": ">=16" + }, + "os": [ + "darwin", + "linux", + "win32" + ], + "cpu": [ + "x64", + "arm64" + ] +}