claude-buddy
Version:
Claude Buddy installation and setup script
704 lines (612 loc) • 18.4 kB
JavaScript
/**
* Environment Detection Module for Claude Buddy Installation Script
* Detects platform, dependencies, permissions, disk space, and existing installations
*/
const os = require('os');
const path = require('path');
const fs = require('fs').promises;
const { execSync } = require('child_process');
const { createLogger } = require('./logger');
const logger = createLogger();
/**
* Detect the complete environment for installation
* @param {string} targetDirectory - Target installation directory
* @returns {Promise<Object>} Environment detection result
*/
async function detectEnvironment(targetDirectory) {
logger.debug('Starting environment detection');
const platform = detectPlatform();
const nodeVersion = detectNodeVersion();
const dependenciesArray = await detectDependencies();
const permissions = await checkPermissions(targetDirectory);
const diskSpace = await checkDiskSpace(targetDirectory);
const existingInstallation = await detectExistingInstallation(targetDirectory);
// Convert dependencies array to object for easier access
const dependencies = {};
for (const dep of dependenciesArray) {
dependencies[dep.name] = dep;
}
const result = {
platform,
nodeVersion,
dependencies,
detectedDependencies: dependenciesArray,
permissions,
diskSpace,
existingInstallation
};
logger.debug(`Environment detection complete: ${JSON.stringify(result, null, 2)}`);
return result;
}
/**
* Detect platform information
* @returns {Object} Platform details
*/
function detectPlatform() {
const platformType = os.platform();
let osType;
if (platformType === 'win32') {
osType = 'windows';
} else if (platformType === 'darwin') {
osType = 'darwin';
} else if (platformType === 'linux') {
osType = 'linux';
} else {
osType = 'unknown';
}
return {
os: osType,
arch: os.arch(),
osVersion: os.release(),
shell: detectShell(),
homedir: os.homedir(),
tempdir: os.tmpdir()
};
}
/**
* Detect the default shell
* @returns {string} Shell name
*/
function detectShell() {
const platformType = os.platform();
if (platformType === 'win32') {
return process.env.COMSPEC ? 'cmd' : 'powershell';
}
if (process.env.SHELL) {
return path.basename(process.env.SHELL);
}
return 'bash'; // Default fallback
}
/**
* Detect Node.js version
* @returns {string} Node.js version
*/
function detectNodeVersion() {
return process.version.replace('v', '');
}
/**
* Detect all required and optional dependencies
* @returns {Promise<Array>} Array of detected dependencies
*/
async function detectDependencies() {
const dependencies = [
{
name: 'node',
required: true,
detector: detectNode
},
{
name: 'uv',
required: false,
detector: detectUV
},
{
name: 'python',
required: false,
detector: detectPython
},
{
name: 'git',
required: false,
detector: detectGit
}
];
const results = [];
for (const dep of dependencies) {
logger.verbose(`Detecting ${dep.name}...`);
const result = await dep.detector();
results.push({
name: dep.name,
required: dep.required,
available: result.available,
version: result.version,
location: result.location,
alternativeLocations: result.alternativeLocations || []
});
if (result.available) {
logger.debug(`${dep.name} found: v${result.version} at ${result.location}`);
} else {
logger.debug(`${dep.name} not found`);
}
}
return results;
}
/**
* Detect Node.js installation
* @returns {Promise<Object>} Node.js detection result
*/
async function detectNode() {
return {
available: true,
version: detectNodeVersion(),
location: process.execPath,
alternativeLocations: []
};
}
/**
* Detect UV (Python package manager) installation
* @returns {Promise<Object>} UV detection result
*/
async function detectUV() {
const platformType = os.platform();
// Possible UV locations based on platform
const possiblePaths = platformType === 'win32'
? [
'uv',
path.join(os.homedir(), 'AppData', 'Local', 'Programs', 'uv', 'uv.exe'),
path.join(os.homedir(), '.cargo', 'bin', 'uv.exe')
]
: [
'uv',
path.join(os.homedir(), '.local', 'bin', 'uv'),
path.join(os.homedir(), '.cargo', 'bin', 'uv'),
'/usr/local/bin/uv',
'/opt/homebrew/bin/uv'
];
const alternativeLocations = [];
for (const uvPath of possiblePaths) {
try {
const output = execSync(`"${uvPath}" --version`, {
stdio: 'pipe',
encoding: 'utf-8',
timeout: 5000
}).trim();
// Parse version from output (e.g., "uv 0.1.0")
const versionMatch = output.match(/uv\s+(\d+\.\d+\.\d+)/i);
const version = versionMatch ? versionMatch[1] : output;
return {
available: true,
version,
location: uvPath,
alternativeLocations
};
} catch (err) {
alternativeLocations.push(uvPath);
}
}
return {
available: false,
version: null,
location: null,
alternativeLocations
};
}
/**
* Detect Python installation
* @returns {Promise<Object>} Python detection result
*/
async function detectPython() {
const platformType = os.platform();
// Try python3 first, then python
const pythonCommands = platformType === 'win32'
? ['python', 'python3', 'py']
: ['python3', 'python'];
const alternativeLocations = [];
for (const cmd of pythonCommands) {
try {
const output = execSync(`${cmd} --version`, {
stdio: 'pipe',
encoding: 'utf-8',
timeout: 5000
}).trim();
// Parse version from output (e.g., "Python 3.11.0")
const versionMatch = output.match(/Python\s+(\d+\.\d+\.\d+)/i);
const version = versionMatch ? versionMatch[1] : output;
// Get the full path
let location;
try {
location = execSync(platformType === 'win32' ? `where ${cmd}` : `which ${cmd}`, {
stdio: 'pipe',
encoding: 'utf-8',
timeout: 5000
}).trim().split('\n')[0];
} catch (err) {
location = cmd;
}
return {
available: true,
version,
location,
alternativeLocations
};
} catch (err) {
alternativeLocations.push(cmd);
}
}
return {
available: false,
version: null,
location: null,
alternativeLocations
};
}
/**
* Detect Git installation
* @returns {Promise<Object>} Git detection result
*/
async function detectGit() {
const platformType = os.platform();
try {
const output = execSync('git --version', {
stdio: 'pipe',
encoding: 'utf-8',
timeout: 5000
}).trim();
// Parse version from output (e.g., "git version 2.30.0")
const versionMatch = output.match(/git version (\d+\.\d+\.\d+)/i);
const version = versionMatch ? versionMatch[1] : output;
// Get the full path
let location;
try {
location = execSync(platformType === 'win32' ? 'where git' : 'which git', {
stdio: 'pipe',
encoding: 'utf-8',
timeout: 5000
}).trim().split('\n')[0];
} catch (err) {
location = 'git';
}
return {
available: true,
version,
location,
alternativeLocations: []
};
} catch (err) {
return {
available: false,
version: null,
location: null,
alternativeLocations: ['git']
};
}
}
/**
* Check permissions for the target directory
* @param {string} targetDirectory - Target directory path
* @returns {Promise<Object>} Permission check results
*/
async function checkPermissions(targetDirectory) {
logger.verbose(`Checking permissions for ${targetDirectory}`);
const result = {
targetDirectory,
canRead: false,
canWrite: false,
canExecute: false,
isGitRepo: false,
gitConfigurable: false
};
try {
// Check if directory exists
// let dirExists = false; // Reserved for future use
try {
await fs.access(targetDirectory);
// dirExists = true; // Reserved for future use
} catch (err) {
// Directory doesn't exist, check parent
const parentDir = path.dirname(targetDirectory);
try {
await fs.access(parentDir);
targetDirectory = parentDir;
// dirExists = true; // Not used after this point
} catch (parentErr) {
logger.warn(`Target directory and parent do not exist: ${targetDirectory}`);
return result;
}
}
// Test read permission
try {
await fs.access(targetDirectory, fs.constants.R_OK);
result.canRead = true;
} catch (err) {
logger.verbose('No read permission');
}
// Test write permission
try {
await fs.access(targetDirectory, fs.constants.W_OK);
result.canWrite = true;
} catch (err) {
logger.verbose('No write permission');
}
// Test execute permission (directory traversal)
try {
await fs.access(targetDirectory, fs.constants.X_OK);
result.canExecute = true;
} catch (err) {
logger.verbose('No execute permission');
}
// Check if it's a Git repository
try {
const gitDir = path.join(targetDirectory, '.git');
await fs.access(gitDir);
result.isGitRepo = true;
// Check if we can write to .git/config
try {
const gitConfig = path.join(gitDir, 'config');
await fs.access(gitConfig, fs.constants.W_OK);
result.gitConfigurable = true;
} catch (err) {
logger.verbose('Git config not writable');
}
} catch (err) {
logger.verbose('Not a git repository');
}
} catch (err) {
logger.error(`Failed to check permissions: ${err.message}`);
}
logger.debug(`Permission check result: ${JSON.stringify(result)}`);
return result;
}
/**
* Check available disk space
* @param {string} targetDirectory - Target directory path
* @returns {Promise<Object>} Disk space information
*/
async function checkDiskSpace(targetDirectory) {
logger.verbose(`Checking disk space for ${targetDirectory}`);
const requiredBytes = 50 * 1024 * 1024; // 50 MB minimum
try {
// Try to get actual disk space using df command (Unix-like systems)
const platformType = os.platform();
if (platformType === 'win32') {
// Windows: use fsutil or fallback to reasonable assumption
try {
const drive = path.parse(targetDirectory).root;
const output = execSync(`fsutil volume diskfree ${drive}`, {
stdio: 'pipe',
encoding: 'utf-8',
timeout: 5000
});
// Parse output for available bytes
const match = output.match(/Total # of avail bytes\s+:\s+(\d+)/);
if (match) {
const available = parseInt(match[1], 10);
return {
available,
required: requiredBytes,
sufficient: available >= requiredBytes
};
}
} catch (err) {
logger.verbose(`Failed to check Windows disk space: ${err.message}`);
}
} else {
// Unix-like: use df command
try {
const output = execSync(`df -k "${targetDirectory}"`, {
stdio: 'pipe',
encoding: 'utf-8',
timeout: 5000
});
// Parse df output (second line, fourth column is available KB)
const lines = output.trim().split('\n');
if (lines.length >= 2) {
const parts = lines[1].split(/\s+/);
if (parts.length >= 4) {
const availableKB = parseInt(parts[3], 10);
const available = availableKB * 1024; // Convert to bytes
return {
available,
required: requiredBytes,
sufficient: available >= requiredBytes
};
}
}
} catch (err) {
logger.verbose(`Failed to check disk space: ${err.message}`);
}
}
} catch (err) {
logger.verbose(`Disk space check failed: ${err.message}`);
}
// Fallback: assume sufficient space
return {
available: requiredBytes * 10, // Assume 500 MB available
required: requiredBytes,
sufficient: true
};
}
/**
* Detect existing Claude Buddy installation
* @param {string} targetDirectory - Target directory path
* @returns {Promise<Object|null>} Existing installation info or null
*/
async function detectExistingInstallation(targetDirectory) {
logger.verbose('Checking for existing installation');
const metadataPath = path.join(targetDirectory, '.claude-buddy', 'install-metadata.json');
try {
await fs.access(metadataPath);
// Read metadata file
const metadataContent = await fs.readFile(metadataPath, 'utf-8');
const metadata = JSON.parse(metadataContent);
logger.debug(`Found existing installation: v${metadata.version}`);
// Check installation integrity
const corruptionIssues = await checkInstallationIntegrity(targetDirectory, metadata);
return {
isInstalled: true,
version: metadata.version || 'unknown',
installDate: metadata.installDate || null,
componentsStatus: metadata.installedComponents || {},
isCorrupted: corruptionIssues.length > 0,
corruptionDetails: corruptionIssues.length > 0 ? corruptionIssues : undefined
};
} catch (err) {
if (err.code === 'ENOENT') {
// No existing installation
logger.debug('No existing installation found');
return null;
}
// Metadata file exists but is corrupted
logger.warn(`Installation metadata is corrupted: ${err.message}`);
return {
isInstalled: true,
version: 'unknown',
installDate: null,
componentsStatus: {},
isCorrupted: true,
corruptionDetails: [`Metadata file is corrupted: ${err.message}`]
};
}
}
/**
* Check installation integrity
* @param {string} targetDirectory - Target directory path
* @param {Object} metadata - Installation metadata
* @returns {Promise<Array>} Array of corruption issues found
*/
async function checkInstallationIntegrity(targetDirectory, metadata) {
const issues = [];
// Expected core directories
const expectedDirs = [
'.claude-buddy',
'.claude',
'directive'
];
for (const dir of expectedDirs) {
const dirPath = path.join(targetDirectory, dir);
try {
await fs.access(dirPath);
} catch (err) {
issues.push(`Missing directory: ${dir}`);
}
}
// Expected core files based on components
if (metadata.installedComponents) {
const components = metadata.installedComponents;
if (components.configs && components.configs.enabled) {
const configPath = path.join(targetDirectory, '.claude-buddy', 'buddy-config.json');
try {
await fs.access(configPath);
} catch (err) {
issues.push('Missing buddy-config.json');
}
}
if (components.foundation && components.foundation.enabled) {
const foundationPath = path.join(targetDirectory, 'directive', 'foundation.md');
try {
await fs.access(foundationPath);
} catch (err) {
issues.push('Missing foundation.md');
}
}
}
return issues;
}
/**
* Compare versions (semver-compatible)
* @param {string} v1 - First version
* @param {string} v2 - Second version
* @returns {number} -1 if v1 < v2, 0 if equal, 1 if v1 > v2
*/
function compareVersions(v1, v2) {
const parts1 = v1.split('.').map(Number);
const parts2 = v2.split('.').map(Number);
for (let i = 0; i < 3; i++) {
const p1 = parts1[i] || 0;
const p2 = parts2[i] || 0;
if (p1 > p2) return 1;
if (p1 < p2) return -1;
}
return 0;
}
/**
* Validate environment for installation
* @param {Object} environment - Environment detection result
* @returns {Object} Validation result with issues array
*/
function validateEnvironment(environment) {
const issues = [];
const warnings = [];
// Check Node.js version (>= 18.0.0)
const nodeVersion = environment.nodeVersion;
const majorVersion = parseInt(nodeVersion.split('.')[0], 10);
if (majorVersion < 18) {
issues.push({
severity: 'error',
message: `Node.js version ${nodeVersion} is not supported. Required: >= 18.0.0`,
suggestion: 'Please upgrade Node.js to version 18 or higher'
});
}
// Check write permissions
if (!environment.permissions.canWrite) {
issues.push({
severity: 'error',
message: 'No write permission to target directory',
suggestion: 'Run with appropriate permissions or choose a different directory'
});
}
// Check disk space
if (!environment.diskSpace.sufficient) {
issues.push({
severity: 'error',
message: `Insufficient disk space. Required: ${environment.diskSpace.required} bytes, Available: ${environment.diskSpace.available} bytes`,
suggestion: 'Free up disk space and try again'
});
}
// Check platform support
if (environment.platform.os === 'unknown') {
warnings.push({
severity: 'warning',
message: `Unsupported platform: ${os.platform()}`,
suggestion: 'Installation may not work correctly on this platform'
});
}
// Warn about missing optional dependencies
const uvDep = environment.detectedDependencies.find(d => d.name === 'uv');
if (uvDep && !uvDep.available) {
warnings.push({
severity: 'warning',
message: 'UV (Python package manager) not found',
suggestion: 'Hooks will be disabled. Install UV to enable hook functionality: curl -LsSf https://astral.sh/uv/install.sh | sh'
});
}
const pythonDep = environment.detectedDependencies.find(d => d.name === 'python');
if (pythonDep && !pythonDep.available) {
warnings.push({
severity: 'warning',
message: 'Python not found',
suggestion: 'Hooks will be disabled. Install Python 3.8+ to enable hook functionality'
});
}
return {
valid: issues.length === 0,
issues,
warnings
};
}
module.exports = {
detectEnvironment,
detectPlatform,
detectNodeVersion,
detectDependencies,
detectNode,
detectUV,
detectPython,
detectGit,
checkPermissions,
checkDiskSpace,
detectExistingInstallation,
checkInstallationIntegrity,
compareVersions,
validateEnvironment
};