@fission-ai/openspec
Version:
AI-native system for spec-driven development
449 lines • 16.8 kB
JavaScript
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