UNPKG

claudekit

Version:

CLI tools for Claude Code development workflow

386 lines (344 loc) 11.1 kB
import type { Stats } from 'node:fs'; import { promises as fs, constants } from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import * as crypto from 'node:crypto'; import { Logger } from '../utils/logger.js'; const logger = Logger.create('filesystem'); interface NodeJSError { code?: string; message: string; } /** * Filesystem utilities with Unix focus for ClaudeKit CLI * * Provides essential file operations with proper path validation, * permission checks, backup functionality, and idempotency. */ // ============================================================================ // Path Validation // ============================================================================ /** * Validates that a project path is reasonable and safe * * @param input - Path to validate * @returns true if path is valid for project operations */ export function validateProjectPath(input: string): boolean { if (!input || typeof input !== 'string') { return false; } // Check for directory traversal attempts in the original input if (input.includes('..')) { return false; } // Normalize the path to handle relative paths and resolve symlinks const normalizedPath = path.resolve(input); // Double-check for directory traversal after normalization if (normalizedPath.includes('..') || normalizedPath !== path.normalize(normalizedPath)) { return false; } // Ensure path is not root or system directories const systemPaths = ['/', '/usr', '/bin', '/sbin', '/etc', '/var', '/tmp']; if (systemPaths.includes(normalizedPath)) { return false; } // Ensure path is not in user's critical directories const homeDir = os.homedir(); const criticalUserPaths = [ homeDir, path.join(homeDir, 'Library'), path.join(homeDir, '.ssh'), path.join(homeDir, '.gnupg'), ]; if (criticalUserPaths.includes(normalizedPath)) { return false; } // Must be a reasonable length if (normalizedPath.length > 1000) { return false; } // Should not contain null bytes or other control characters for (let i = 0; i < normalizedPath.length; i++) { const charCode = normalizedPath.charCodeAt(i); if (charCode <= 31 || charCode === 127) { return false; } } return true; } // ============================================================================ // Directory Operations // ============================================================================ /** * Ensures a directory exists, creating it recursively if needed * * @param dirPath - Directory path to ensure exists * @throws Error if path validation fails or directory cannot be created */ export async function ensureDirectoryExists(dirPath: string): Promise<void> { if (!validateProjectPath(dirPath)) { throw new Error(`Invalid directory path: ${dirPath}`); } try { // Check if directory already exists const stats = await fs.stat(dirPath); if (!stats.isDirectory()) { throw new Error(`Path exists but is not a directory: ${dirPath}`); } return; } catch (error: unknown) { const nodeError = error as NodeJSError; if (nodeError.code !== undefined && nodeError.code !== 'ENOENT') { throw error; } } // Create directory recursively try { await fs.mkdir(dirPath, { recursive: true, mode: 0o755 }); } catch (error: unknown) { const nodeError = error as NodeJSError; throw new Error(`Failed to create directory ${dirPath}: ${nodeError.message}`); } } // ============================================================================ // Permission Operations // ============================================================================ /** * Checks if we have write permission to a directory or file's parent directory * * @param targetPath - Path to check write permissions for * @returns true if we can write to the target location */ export async function checkWritePermission(targetPath: string): Promise<boolean> { try { const stats = await fs.stat(targetPath); if (stats.isDirectory()) { // Check write permission on directory await fs.access(targetPath, constants.W_OK); return true; } else { // Check write permission on file await fs.access(targetPath, constants.W_OK); return true; } } catch (error: unknown) { const nodeError = error as NodeJSError; if (nodeError.code !== undefined && nodeError.code === 'ENOENT') { // File doesn't exist, check parent directory const parentDir = path.dirname(targetPath); try { await fs.access(parentDir, constants.W_OK); return true; } catch { return false; } } return false; } } // ============================================================================ // File Hashing and Comparison // ============================================================================ /** * Calculates SHA-256 hash of a file * * @param filePath - Path to file to hash * @returns Promise resolving to hex-encoded hash string * @throws Error if file cannot be read */ export async function getFileHash(filePath: string): Promise<string> { try { const content = await fs.readFile(filePath); const hash = crypto.createHash('sha256'); hash.update(content); return hash.digest('hex'); } catch (error: unknown) { const nodeError = error as NodeJSError; throw new Error(`Failed to hash file ${filePath}: ${nodeError.message}`); } } /** * Determines if a target file needs updating compared to source * Uses SHA-256 comparison for reliable change detection * * @param source - Source file path * @param target - Target file path * @returns true if target doesn't exist or differs from source */ export async function needsUpdate(source: string, target: string): Promise<boolean> { try { // If target doesn't exist, it needs updating await fs.access(target, constants.F_OK); } catch (error: unknown) { const nodeError = error as NodeJSError; if (nodeError.code !== undefined && nodeError.code === 'ENOENT') { return true; } throw error; } try { // Compare hashes const sourceHash = await getFileHash(source); const targetHash = await getFileHash(target); return sourceHash !== targetHash; } catch { // If we can't read either file, assume update is needed return true; } } // ============================================================================ // File Operations with Backup // ============================================================================ /** * Copies a file from source to target with optional backup * * @param source - Source file path * @param target - Target file path * @param backup - Whether to create backup of existing target * @throws Error if operation fails */ export async function copyFileWithBackup( source: string, target: string, backup: boolean = true, onConflict?: (source: string, target: string) => Promise<boolean> ): Promise<void> { // Validate paths if (!validateProjectPath(source) || !validateProjectPath(target)) { throw new Error('Invalid source or target path'); } // Check write permissions if (!(await checkWritePermission(target))) { throw new Error(`No write permission for target: ${target}`); } // Verify source exists and is readable try { await fs.access(source, constants.R_OK); } catch { throw new Error(`Source file not accessible: ${source}`); } // Check if target exists and has different content let targetExists = false; try { await fs.access(target, constants.F_OK); targetExists = true; } catch { // Target doesn't exist - no conflict } if (targetExists) { // Check if files are different const sourceHash = await getFileHash(source); const targetHash = await getFileHash(target); if (sourceHash !== targetHash) { // Files are different - potential conflict if (onConflict) { const shouldProceed = await onConflict(source, target); if (!shouldProceed) { logger.info(`Skipping ${target} - user chose not to overwrite`); return; } } // Create backup if requested if (backup) { // Create timestamped backup const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const backupPath = `${target}.backup-${timestamp}`; logger.debug(`Creating backup: ${backupPath}`); await fs.copyFile(target, backupPath); } } else { // Files are identical - skip copy logger.debug(`Skipping ${target} - identical to source`); return; } } // Ensure target directory exists const targetDir = path.dirname(target); await ensureDirectoryExists(targetDir); // Copy file try { await fs.copyFile(source, target); } catch (error: unknown) { const nodeError = error as NodeJSError; throw new Error(`Failed to copy ${source} to ${target}: ${nodeError.message}`); } } // ============================================================================ // Utility Functions // ============================================================================ /** * Checks if a path exists * * @param filePath - Path to check * @returns true if path exists */ export async function pathExists(filePath: string): Promise<boolean> { try { await fs.access(filePath, constants.F_OK); return true; } catch { return false; } } /** * Gets file statistics * * @param filePath - Path to get stats for * @returns File stats or null if file doesn't exist */ export async function getFileStats(filePath: string): Promise<Stats | null> { try { return await fs.stat(filePath); } catch (error: unknown) { const nodeError = error as NodeJSError; if (nodeError.code !== undefined && nodeError.code === 'ENOENT') { return null; } throw error; } } /** * Safely removes a file if it exists * * @param filePath - Path to file to remove * @returns true if file was removed, false if it didn't exist */ export async function safeRemove(filePath: string): Promise<boolean> { try { await fs.unlink(filePath); return true; } catch (error: unknown) { const nodeError = error as NodeJSError; if (nodeError.code !== undefined && nodeError.code === 'ENOENT') { return false; } throw error; } } /** * Expands tilde (~) in paths to home directory * * @param filePath - Path that may contain tilde * @returns Expanded path */ export function expandHomePath(filePath: string): string { if (filePath.startsWith('~/')) { return path.join(os.homedir(), filePath.slice(2)); } if (filePath === '~') { return os.homedir(); } return filePath; } /** * Normalizes a path for consistent handling * Expands home directory and resolves relative paths * * @param filePath - Path to normalize * @returns Normalized absolute path */ export function normalizePath(filePath: string): string { const expanded = expandHomePath(filePath); return path.resolve(expanded); }