UNPKG

@fission-ai/openspec

Version:

AI-native system for spec-driven development

449 lines 16.8 kB
import { promises as fs } from 'fs'; import path from 'path'; import os from 'os'; import { FileSystemUtils } from '../../../utils/file-system.js'; /** * Installer for Zsh completion scripts. * Supports both Oh My Zsh and standard Zsh configurations. */ export class ZshInstaller { homeDir; /** * Markers for .zshrc configuration management */ ZSHRC_MARKERS = { start: '# OPENSPEC:START', end: '# OPENSPEC:END', }; constructor(homeDir = os.homedir()) { this.homeDir = homeDir; } /** * Check if Oh My Zsh is installed * * @returns true if Oh My Zsh is detected via $ZSH env var or directory exists */ async isOhMyZshInstalled() { // First check for $ZSH environment variable (standard OMZ setup) if (process.env.ZSH) { return true; } // Fall back to checking for ~/.oh-my-zsh directory const ohMyZshPath = path.join(this.homeDir, '.oh-my-zsh'); try { const stat = await fs.stat(ohMyZshPath); return stat.isDirectory(); } catch { return false; } } /** * Get the appropriate installation path for the completion script * * @returns Object with installation path and whether it's Oh My Zsh */ async getInstallationPath() { const isOhMyZsh = await this.isOhMyZshInstalled(); if (isOhMyZsh) { // Oh My Zsh custom completions directory return { path: path.join(this.homeDir, '.oh-my-zsh', 'custom', 'completions', '_openspec'), isOhMyZsh: true, }; } else { // Standard Zsh completions directory return { path: path.join(this.homeDir, '.zsh', 'completions', '_openspec'), isOhMyZsh: false, }; } } /** * Backup an existing completion file if it exists * * @param targetPath - Path to the file to backup * @returns Path to the backup file, or undefined if no backup was needed */ async backupExistingFile(targetPath) { try { await fs.access(targetPath); // File exists, create a backup const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const backupPath = `${targetPath}.backup-${timestamp}`; await fs.copyFile(targetPath, backupPath); return backupPath; } catch { // File doesn't exist, no backup needed return undefined; } } /** * Get the path to .zshrc file * * @returns Path to .zshrc */ getZshrcPath() { return path.join(this.homeDir, '.zshrc'); } /** * Generate .zshrc configuration content * * @param completionsDir - Directory containing completion scripts * @returns Configuration content */ generateZshrcConfig(completionsDir) { return [ '# OpenSpec shell completions configuration', `fpath=("${completionsDir}" $fpath)`, 'autoload -Uz compinit', 'compinit', ].join('\n'); } /** * Configure .zshrc to enable completions * Only applies to standard Zsh (not Oh My Zsh) * * @param completionsDir - Directory containing completion scripts * @returns true if configured successfully, false otherwise */ async configureZshrc(completionsDir) { // Check if auto-configuration is disabled if (process.env.OPENSPEC_NO_AUTO_CONFIG === '1') { return false; } try { const zshrcPath = this.getZshrcPath(); const config = this.generateZshrcConfig(completionsDir); // Check write permissions const canWrite = await FileSystemUtils.canWriteFile(zshrcPath); if (!canWrite) { return false; } // Use marker-based update await FileSystemUtils.updateFileWithMarkers(zshrcPath, config, this.ZSHRC_MARKERS.start, this.ZSHRC_MARKERS.end); return true; } catch (error) { // Fail gracefully - don't break installation console.debug(`Unable to configure .zshrc for completions: ${error.message}`); return false; } } /** * Check if .zshrc has OpenSpec configuration markers * * @returns true if .zshrc exists and has markers */ async hasZshrcConfig() { try { const zshrcPath = this.getZshrcPath(); const content = await fs.readFile(zshrcPath, 'utf-8'); return content.includes(this.ZSHRC_MARKERS.start) && content.includes(this.ZSHRC_MARKERS.end); } catch { return false; } } /** * Check if fpath configuration is needed for a given directory * Used to verify if Oh My Zsh (or other) completions directory is already in fpath * * @param completionsDir - Directory to check for in fpath * @returns true if configuration is needed, false if directory is already referenced */ async needsFpathConfig(completionsDir) { try { const zshrcPath = this.getZshrcPath(); const content = await fs.readFile(zshrcPath, 'utf-8'); // Check if fpath already includes this directory return !content.includes(completionsDir); } catch (error) { // If we can't read .zshrc, assume config is needed console.debug(`Unable to read .zshrc to check fpath config: ${error instanceof Error ? error.message : String(error)}`); return true; } } /** * Remove .zshrc configuration * Used during uninstallation * * @returns true if removed successfully, false otherwise */ async removeZshrcConfig() { try { const zshrcPath = this.getZshrcPath(); // Check if file exists try { await fs.access(zshrcPath); } catch { // File doesn't exist, nothing to remove return true; } // Read file content const content = await fs.readFile(zshrcPath, 'utf-8'); // Check if markers exist if (!content.includes(this.ZSHRC_MARKERS.start) || !content.includes(this.ZSHRC_MARKERS.end)) { // Markers don't exist, nothing to remove return true; } // Remove content between markers (including markers) const lines = content.split('\n'); const startIndex = lines.findIndex((line) => line.trim() === this.ZSHRC_MARKERS.start); const endIndex = lines.findIndex((line) => line.trim() === this.ZSHRC_MARKERS.end); if (startIndex === -1 || endIndex === -1 || endIndex < startIndex) { // Invalid marker placement return false; } // Remove lines between markers (inclusive) lines.splice(startIndex, endIndex - startIndex + 1); // Remove trailing empty lines at the start if the markers were at the top while (lines.length > 0 && lines[0].trim() === '') { lines.shift(); } // Write back await fs.writeFile(zshrcPath, lines.join('\n'), 'utf-8'); return true; } catch (error) { // Fail gracefully console.debug(`Unable to remove .zshrc configuration: ${error.message}`); return false; } } /** * Install the completion script * * @param completionScript - The completion script content to install * @returns Installation result with status and instructions */ async install(completionScript) { try { const { path: targetPath, isOhMyZsh } = await this.getInstallationPath(); // Check if already installed with same content let isUpdate = false; try { const existingContent = await fs.readFile(targetPath, 'utf-8'); if (existingContent === completionScript) { // Already installed and up to date return { success: true, installedPath: targetPath, isOhMyZsh, message: 'Completion script is already installed (up to date)', instructions: [ 'The completion script is already installed and up to date.', 'If completions are not working, try: exec zsh', ], }; } // File exists but content is different - this is an update isUpdate = true; } catch (error) { // File doesn't exist or can't be read, proceed with installation console.debug(`Unable to read existing completion file at ${targetPath}: ${error.message}`); } // Ensure the directory exists const targetDir = path.dirname(targetPath); await fs.mkdir(targetDir, { recursive: true }); // Backup existing file if updating const backupPath = isUpdate ? await this.backupExistingFile(targetPath) : undefined; // Write the completion script await fs.writeFile(targetPath, completionScript, 'utf-8'); // Auto-configure .zshrc let zshrcConfigured = false; if (isOhMyZsh) { // For Oh My Zsh, verify that custom/completions is in fpath // If not, add it to .zshrc const needsConfig = await this.needsFpathConfig(targetDir); if (needsConfig) { zshrcConfigured = await this.configureZshrc(targetDir); } } else { // Standard Zsh always needs .zshrc configuration zshrcConfigured = await this.configureZshrc(targetDir); } // Generate instructions (only if .zshrc wasn't auto-configured) let instructions = zshrcConfigured ? undefined : this.generateInstructions(isOhMyZsh, targetPath); // Add fpath guidance for Oh My Zsh installations if (isOhMyZsh) { const fpathGuidance = this.generateOhMyZshFpathGuidance(targetDir); if (fpathGuidance) { instructions = instructions ? [...instructions, '', ...fpathGuidance] : fpathGuidance; } } // Determine appropriate message based on update status let message; if (isUpdate) { message = backupPath ? 'Completion script updated successfully (previous version backed up)' : 'Completion script updated successfully'; } else { message = isOhMyZsh ? 'Completion script installed successfully for Oh My Zsh' : zshrcConfigured ? 'Completion script installed and .zshrc configured successfully' : 'Completion script installed successfully for Zsh'; } return { success: true, installedPath: targetPath, backupPath, isOhMyZsh, zshrcConfigured, message, instructions, }; } catch (error) { return { success: false, isOhMyZsh: false, message: `Failed to install completion script: ${error instanceof Error ? error.message : String(error)}`, }; } } /** * Generate Oh My Zsh fpath verification guidance * * @param completionsDir - Custom completions directory path * @returns Array of guidance strings, or undefined if not needed */ generateOhMyZshFpathGuidance(completionsDir) { return [ 'Note: Oh My Zsh typically auto-loads completions from custom/completions.', `Verify that ${completionsDir} is in your fpath by running:`, ' echo $fpath | grep "custom/completions"', '', 'If not found, completions may not work. Restart your shell to ensure changes take effect.', ]; } /** * Generate user instructions for enabling completions * * @param isOhMyZsh - Whether Oh My Zsh is being used * @param installedPath - Path where the script was installed * @returns Array of instruction strings */ generateInstructions(isOhMyZsh, installedPath) { if (isOhMyZsh) { return [ 'Completion script installed to Oh My Zsh completions directory.', 'Restart your shell or run: exec zsh', 'Completions should activate automatically.', ]; } else { const completionsDir = path.dirname(installedPath); const zshrcPath = path.join(this.homeDir, '.zshrc'); return [ 'Completion script installed to ~/.zsh/completions/', '', 'To enable completions, add the following to your ~/.zshrc file:', '', ` # Add completions directory to fpath`, ` fpath=(${completionsDir} $fpath)`, '', ' # Initialize completion system', ' autoload -Uz compinit', ' compinit', '', 'Then restart your shell or run: exec zsh', '', `Check if these lines already exist in ${zshrcPath} before adding.`, ]; } } /** * Uninstall the completion script * * @returns true if uninstalled successfully, false otherwise */ async uninstall() { try { const { path: targetPath, isOhMyZsh } = await this.getInstallationPath(); // Try to remove completion script let scriptRemoved = false; try { await fs.access(targetPath); await fs.unlink(targetPath); scriptRemoved = true; } catch { // Script not installed } // Try to remove .zshrc configuration (only for standard Zsh) let zshrcWasPresent = false; let zshrcCleaned = false; if (!isOhMyZsh) { zshrcWasPresent = await this.hasZshrcConfig(); if (zshrcWasPresent) { zshrcCleaned = await this.removeZshrcConfig(); } } if (!scriptRemoved && !zshrcWasPresent) { return { success: false, message: 'Completion script is not installed', }; } const messages = []; if (scriptRemoved) { messages.push(`Completion script removed from ${targetPath}`); } if (zshrcCleaned && !isOhMyZsh) { messages.push('Removed OpenSpec configuration from ~/.zshrc'); } return { success: true, message: messages.join('. '), }; } catch (error) { return { success: false, message: `Failed to uninstall completion script: ${error instanceof Error ? error.message : String(error)}`, }; } } /** * Check if completion script is currently installed * * @returns true if the completion script exists */ async isInstalled() { try { const { path: targetPath } = await this.getInstallationPath(); await fs.access(targetPath); return true; } catch { return false; } } /** * Get information about the current installation * * @returns Installation status information */ async getInstallationInfo() { const installed = await this.isInstalled(); if (!installed) { return { installed: false }; } const { path: targetPath, isOhMyZsh } = await this.getInstallationPath(); return { installed: true, path: targetPath, isOhMyZsh, }; } } //# sourceMappingURL=zsh-installer.js.map