UNPKG

aiwf

Version:

AI Workflow Framework for Claude Code with multi-language support (Korean/English)

1,270 lines (1,124 loc) β€’ 41.2 kB
import fs from 'fs/promises'; import path from 'path'; import os from 'os'; import ora from 'ora'; import chalk from 'chalk'; import prompts from 'prompts'; import { fetchContent, downloadFile, downloadDirectory, GITHUB_RAW_URL, GITHUB_CONTENT_PREFIX } from './file-downloader.js'; import { backupCommandsAndDocs, restoreFromBackup, getCurrentBackupDir, setBackupDir } from './backup-manager.js'; import { getMessages } from '../utils/messages.js'; import { validateInstallation, displaySpecCompliantValidationResults } from './validator.js'; import { rollbackTool } from './rollback-manager.js'; import { detectLanguage, loadUserLanguageConfig, saveUserLanguageConfig, getInstallationLanguagePath, SUPPORTED_LANGUAGES, DEFAULT_LANGUAGE } from '../utils/language-utils.js'; import { FILES, TOOL_DIRS, AIWF_DIRS, BASE_DIRS, getAllAiwfDirectories, getAllClaudeMdFiles } from '../utils/paths.js'; /** * Check if AIWF is already installed * @returns {Promise<boolean>} Installation status */ async function checkExistingInstallation() { const flagExists = await fs .access(FILES.INSTALLED_FLAG) .then(() => true) .catch(() => false); return flagExists; } /** * Check if existing project files exist * @returns {Promise<{hasProject: boolean, projectFiles: string[]}>} Project status */ async function checkExistingProject() { const projectFiles = []; let hasProject = false; // Check for key project files const keyFiles = [ FILES.PROJECT_MANIFEST, path.join(AIWF_DIRS.PROJECT_DOCS, 'README.md'), path.join(AIWF_DIRS.REQUIREMENTS, 'requirements.md'), path.join(AIWF_DIRS.SPRINTS, 'sprint-01.md'), path.join(AIWF_DIRS.GENERAL_TASKS, 'tasks.md'), path.join(AIWF_DIRS.ARCHITECTURAL_DECISIONS, 'decisions.md'), path.join(AIWF_DIRS.STATE_OF_PROJECT, 'current-state.md') ]; for (const file of keyFiles) { try { await fs.access(file); projectFiles.push(file); hasProject = true; } catch (error) { // File doesn't exist, continue } } // Check for any .md files in project directories const projectDirs = [ AIWF_DIRS.PROJECT_DOCS, AIWF_DIRS.REQUIREMENTS, AIWF_DIRS.SPRINTS, AIWF_DIRS.GENERAL_TASKS, AIWF_DIRS.ARCHITECTURAL_DECISIONS, AIWF_DIRS.STATE_OF_PROJECT ]; for (const dir of projectDirs) { try { const files = await fs.readdir(dir); const mdFiles = files.filter(file => file.endsWith('.md')); if (mdFiles.length > 0) { hasProject = true; projectFiles.push(...mdFiles.map(file => path.join(dir, file))); } } catch (error) { // Directory doesn't exist, continue } } return { hasProject, projectFiles }; } /** * Log with spinner for debug mode * @param {Object} spinner - Ora spinner instance * @param {string} message - Message to log * @param {boolean} debugLog - Debug mode flag */ function logWithSpinner(spinner, message, debugLog) { if (debugLog) console.log(chalk.gray(message)); if (spinner) spinner.text = message; } /** * Select language for installation * @param {Object} options - Installation options * @returns {Promise<string>} Selected language */ async function selectLanguage(options) { let selectedLanguage = DEFAULT_LANGUAGE; if (!options.force) { // 1. Check existing configuration const existingConfig = await loadUserLanguageConfig(); if (existingConfig && existingConfig.language) { selectedLanguage = existingConfig.language; console.log( chalk.blue( `🌐 κΈ°μ‘΄ μ–Έμ–΄ 섀정을 μ‚¬μš©ν•©λ‹ˆλ‹€ / Using existing language setting: ${selectedLanguage}` ) ); } else { // 2. Try auto-detection const detectedLang = await detectLanguage(); console.log( chalk.gray(`πŸ” μ‹œμŠ€ν…œ μ–Έμ–΄ 감지 / System language detected: ${detectedLang}`) ); // 3. Prompt user for language selection const languageResponse = await prompts({ type: 'select', name: 'language', message: 'Please select language / μ–Έμ–΄λ₯Ό μ„ νƒν•΄μ£Όμ„Έμš”:', choices: [ { title: 'English', value: 'en' }, { title: 'ν•œκ΅­μ–΄ (Korean)', value: 'ko' } ], initial: SUPPORTED_LANGUAGES.indexOf(detectedLang) !== -1 ? SUPPORTED_LANGUAGES.indexOf(detectedLang) : 0 }); if (languageResponse.language) { selectedLanguage = languageResponse.language; } else { selectedLanguage = detectedLang; } // 4. Save selected language try { await saveUserLanguageConfig(selectedLanguage, { autoDetect: true, fallback: DEFAULT_LANGUAGE }); console.log( chalk.green( `βœ… μ–Έμ–΄ 섀정이 μ €μž₯λ˜μ—ˆμŠ΅λ‹ˆλ‹€ / Language preference saved: ${selectedLanguage}` ) ); } catch (error) { console.warn( chalk.yellow( `⚠️ μ–Έμ–΄ μ„€μ • μ €μž₯ μ‹€νŒ¨ / Failed to save language preference: ${error.message}` ) ); } } } else { // Force mode: try auto-detection try { selectedLanguage = await detectLanguage(); } catch (error) { selectedLanguage = DEFAULT_LANGUAGE; } } return selectedLanguage; } /** * Handle existing installation * @param {Object} msg - Localized messages * @param {Object} options - Installation options * @returns {Promise<{continue: boolean, installType: string, preserveProject: boolean}>} Installation decision */ async function handleExistingInstallation(msg, options) { if (options.force) { return { continue: true, installType: 'reinstall', preserveProject: false }; } // First, ask about installation type const installResponse = await prompts({ type: 'select', name: 'action', message: msg.existingDetected, choices: [ { title: msg.updateOption, value: 'update' }, { title: msg.reinstallOption, value: 'reinstall' }, { title: msg.skipOption, value: 'skip' }, { title: msg.cancelOption, value: 'cancel' } ] }); if (installResponse.action === 'skip' || installResponse.action === 'cancel') { console.log(chalk.yellow(msg.installCancelled)); process.exit(0); } let preserveProject = true; // Default to preserve // If reinstalling, don't preserve project (complete fresh install) if (installResponse.action === 'reinstall') { preserveProject = false; } // Backup if updating if (installResponse.action === 'update') { await backupCommandsAndDocs(msg); } return { continue: true, installType: installResponse.action, preserveProject }; } /** * Backup project files * @param {string[]} projectFiles - List of project files to backup * @param {Object} msg - Localized messages * @returns {Promise<string>} Backup directory path */ async function backupProjectFiles(projectFiles, msg) { if (projectFiles.length === 0) { return null; } const backupDir = path.join(os.tmpdir(), `aiwf-project-backup-${Date.now()}`); await fs.mkdir(backupDir, { recursive: true }); console.log(chalk.cyan('πŸ’Ύ Backing up project files...')); for (const file of projectFiles) { try { const relativePath = path.relative(process.cwd(), file); const backupPath = path.join(backupDir, relativePath); const backupDirPath = path.dirname(backupPath); await fs.mkdir(backupDirPath, { recursive: true }); await fs.copyFile(file, backupPath); } catch (error) { console.log(chalk.yellow(`Warning: Could not backup ${file}: ${error.message}`)); } } console.log(chalk.green(`βœ… Project files backed up to: ${backupDir}`)); return backupDir; } /** * Restore project files from backup * @param {string} backupDir - Backup directory path * @param {Object} msg - Localized messages */ async function restoreProjectFiles(backupDir, msg) { if (!backupDir) { return; } console.log(chalk.cyan('πŸ”„ Restoring project files...')); try { const backupFiles = await fs.readdir(backupDir, { recursive: true }); for (const file of backupFiles) { const backupFilePath = path.join(backupDir, file); const stat = await fs.stat(backupFilePath); if (stat.isFile()) { const targetPath = path.join(process.cwd(), file); const targetDir = path.dirname(targetPath); await fs.mkdir(targetDir, { recursive: true }); await fs.copyFile(backupFilePath, targetPath); } } console.log(chalk.green(msg.projectPreserved)); // Clean up backup directory await fs.rm(backupDir, { recursive: true, force: true }); } catch (error) { console.log(chalk.yellow(`Warning: Could not restore some project files: ${error.message}`)); console.log(chalk.yellow(`Backup preserved at: ${backupDir}`)); } } /** * Remove project files * @param {string[]} projectFiles - List of project files to remove * @param {Object} msg - Localized messages */ async function removeProjectFiles(projectFiles, msg) { if (projectFiles.length === 0) { return; } console.log(chalk.cyan('πŸ—‘οΈ Removing existing project files...')); for (const file of projectFiles) { try { await fs.unlink(file); } catch (error) { // File might already be deleted, continue } } console.log(chalk.green(msg.projectOverwritten)); } /** * Create AIWF directory structure */ async function createDirectoryStructure() { const aiwfDirs = getAllAiwfDirectories(); // Create AIWF directories for (const dir of aiwfDirs) { await fs.mkdir(dir, { recursive: true }); } // Create tool-specific directories await fs.mkdir(TOOL_DIRS.CURSOR_RULES, { recursive: true }); await fs.mkdir(TOOL_DIRS.WINDSURF_RULES, { recursive: true }); await fs.mkdir(TOOL_DIRS.CLAUDE_AGENTS, { recursive: true }); } /** * Download manifest file * @param {string} languagePath - Language path * @param {Object} spinner - Ora spinner instance * @param {Object} msg - Localized messages * @param {boolean} debugLog - Debug mode flag */ async function downloadManifest(languagePath, spinner, msg, debugLog) { logWithSpinner(spinner, msg.downloading, debugLog); try { const manifestUrl = `${GITHUB_RAW_URL}/${GITHUB_CONTENT_PREFIX}/${languagePath}/.aiwf/00_PROJECT_MANIFEST.md`; await downloadFile(manifestUrl, FILES.PROJECT_MANIFEST); } catch (error) { // If manifest doesn't exist, that's okay } } /** * Download templates * @param {string} languagePath - Language path * @param {Object} spinner - Ora spinner instance * @param {Object} msg - Localized messages * @param {boolean} debugLog - Debug mode flag */ async function downloadTemplates(languagePath, spinner, msg, debugLog) { try { logWithSpinner(spinner, msg.downloadingTemplates, debugLog); // .aiwf 전체 디렉토리λ₯Ό λ™μ μœΌλ‘œ λ‹€μš΄λ‘œλ“œ logWithSpinner(spinner, `Downloading .aiwf directory structure...`, debugLog); await downloadDirectory( `${GITHUB_CONTENT_PREFIX}/${languagePath}/.aiwf`, AIWF_DIRS.ROOT, spinner, msg, languagePath, true // useDynamicFetch = true ); logWithSpinner(spinner, `Successfully downloaded .aiwf directory`, debugLog); } catch (error) { logWithSpinner(spinner, `Error downloading .aiwf: ${error.message}`, debugLog); // Fallback: κ°œλ³„ 디렉토리 λ‹€μš΄λ‘œλ“œ μ‹œλ„ try { logWithSpinner(spinner, `Trying individual directory downloads...`, debugLog); await downloadDirectory( `${GITHUB_CONTENT_PREFIX}/${languagePath}/.aiwf/98_PROMPTS`, AIWF_DIRS.PROMPTS, spinner, msg, languagePath, true ); await downloadDirectory( `${GITHUB_CONTENT_PREFIX}/${languagePath}/.aiwf/99_TEMPLATES`, AIWF_DIRS.TEMPLATES, spinner, msg, languagePath, true ); } catch (fallbackError) { logWithSpinner(spinner, msg.templatesNotFound, debugLog); } } } /** * Update CLAUDE.md documentation files * @param {string} languagePath - Language path * @param {Object} spinner - Ora spinner instance * @param {Object} msg - Localized messages * @param {boolean} debugLog - Debug mode flag */ async function updateDocumentation(languagePath, spinner, msg, debugLog) { logWithSpinner(spinner, msg.updatingDocs, debugLog); const claudeFiles = getAllClaudeMdFiles(); for (const claudeFile of claudeFiles) { try { const relativePath = claudeFile.replace(process.cwd() + '/', ''); const claudeUrl = `${GITHUB_RAW_URL}/${GITHUB_CONTENT_PREFIX}/${languagePath}/${relativePath}`; await downloadFile(claudeUrl, claudeFile); } catch (error) { // If CLAUDE.md doesn't exist, that's okay } } } /** * Update Claude commands and agents * @param {string} languagePath - Language path * @param {Object} spinner - Ora spinner instance * @param {Object} msg - Localized messages * @param {boolean} debugLog - Debug mode flag */ async function updateCommands(languagePath, spinner, msg, debugLog) { // Delete existing commands folder if it exists if (await fs.access(TOOL_DIRS.CLAUDE_COMMANDS).then(() => true).catch(() => false)) { logWithSpinner(spinner, msg.deletingOldCommands, debugLog); await fs.rm(TOOL_DIRS.CLAUDE_COMMANDS, { recursive: true, force: true }); } // Delete existing agents folder if it exists if (await fs.access(TOOL_DIRS.CLAUDE_AGENTS).then(() => true).catch(() => false)) { logWithSpinner(spinner, 'Deleting old agents...', debugLog); await fs.rm(TOOL_DIRS.CLAUDE_AGENTS, { recursive: true, force: true }); } await fs.mkdir(TOOL_DIRS.CLAUDE_COMMANDS, { recursive: true }); await fs.mkdir(TOOL_DIRS.CLAUDE_AGENTS, { recursive: true }); // Download commands logWithSpinner(spinner, msg.downloadingCommands, debugLog); try { await downloadDirectory( `${GITHUB_CONTENT_PREFIX}/${languagePath}/${TOOL_DIRS.CLAUDE_COMMANDS}`, TOOL_DIRS.CLAUDE_COMMANDS, spinner, msg, languagePath, true // commands도 동적 디렉터리 탐색 μ‚¬μš© (파일 리슀트 의쑴 제거) ); } catch (error) { logWithSpinner(spinner, msg.commandsNotFound, debugLog); } // Download agents logWithSpinner(spinner, 'Downloading agents...', debugLog); try { await downloadDirectory( `${GITHUB_CONTENT_PREFIX}/${languagePath}/${TOOL_DIRS.CLAUDE_AGENTS}`, TOOL_DIRS.CLAUDE_AGENTS, spinner, msg, languagePath, true // agents도 동적 디렉터리 탐색 μ‚¬μš© (파일 리슀트 의쑴 제거) ); logWithSpinner(spinner, 'Successfully downloaded agents', debugLog); } catch (error) { logWithSpinner(spinner, 'Agents not found or failed to download', debugLog); } // Download full .claude directory to include tools, workflow, and any other subfolders logWithSpinner(spinner, 'Downloading full .claude directory (tools, workflow, etc.)...', debugLog); try { await downloadDirectory( `${GITHUB_CONTENT_PREFIX}/${languagePath}/.claude`, '.claude', spinner, msg, languagePath, true // 전체 .claude 폴더 동적 λ‹€μš΄λ‘œλ“œ ); logWithSpinner(spinner, 'Successfully downloaded .claude directory', debugLog); } catch (error) { logWithSpinner(spinner, `Failed to download full .claude directory: ${error.message}`, debugLog); } } /** * Check if tool directories exist and handle them * @param {Object} spinner - Ora spinner instance * @param {Object} msg - Localized messages * @param {boolean} debugLog - Debug mode flag * @returns {Promise<Object>} Directory status info */ async function checkAndHandleExistingToolDirs(spinner, msg, debugLog) { const toolDirStatus = { cursor: { exists: false, hadFiles: false, backedUp: false }, windsurf: { exists: false, hadFiles: false, backedUp: false } }; // Check Cursor directory try { await fs.access(TOOL_DIRS.CURSOR_RULES); toolDirStatus.cursor.exists = true; const cursorFiles = await fs.readdir(TOOL_DIRS.CURSOR_RULES); const mdcFiles = cursorFiles.filter(file => file.endsWith('.mdc')); if (mdcFiles.length > 0) { toolDirStatus.cursor.hadFiles = true; logWithSpinner(spinner, `${msg.foundExistingCursor} (${mdcFiles.length} files)`, debugLog); // Backup existing files logWithSpinner(spinner, msg.backingUpToolFiles, debugLog); const backupDir = path.join(TOOL_DIRS.CURSOR_RULES, `backup_${Date.now()}`); await fs.mkdir(backupDir, { recursive: true }); for (const file of mdcFiles) { const srcPath = path.join(TOOL_DIRS.CURSOR_RULES, file); const backupPath = path.join(backupDir, file); await fs.copyFile(srcPath, backupPath); } toolDirStatus.cursor.backedUp = true; logWithSpinner(spinner, `${msg.toolBackupCreated}: ${backupDir}`, debugLog); // Remove existing files for (const file of mdcFiles) { await fs.unlink(path.join(TOOL_DIRS.CURSOR_RULES, file)); } } } catch (error) { // Directory doesn't exist, that's fine logWithSpinner(spinner, `Cursor rules directory not found, will create new`, debugLog); } // Check Windsurf directory try { await fs.access(TOOL_DIRS.WINDSURF_RULES); toolDirStatus.windsurf.exists = true; const windsurfFiles = await fs.readdir(TOOL_DIRS.WINDSURF_RULES); const mdFiles = windsurfFiles.filter(file => file.endsWith('.md')); if (mdFiles.length > 0) { toolDirStatus.windsurf.hadFiles = true; logWithSpinner(spinner, `${msg.foundExistingWindsurf} (${mdFiles.length} files)`, debugLog); // Backup existing files logWithSpinner(spinner, msg.backingUpToolFiles, debugLog); const backupDir = path.join(TOOL_DIRS.WINDSURF_RULES, `backup_${Date.now()}`); await fs.mkdir(backupDir, { recursive: true }); for (const file of mdFiles) { const srcPath = path.join(TOOL_DIRS.WINDSURF_RULES, file); const backupPath = path.join(backupDir, file); await fs.copyFile(srcPath, backupPath); } toolDirStatus.windsurf.backedUp = true; logWithSpinner(spinner, `${msg.toolBackupCreated}: ${backupDir}`, debugLog); // Remove existing files for (const file of mdFiles) { await fs.unlink(path.join(TOOL_DIRS.WINDSURF_RULES, file)); } } } catch (error) { // Directory doesn't exist, that's fine logWithSpinner(spinner, `Windsurf rules directory not found, will create new`, debugLog); } return toolDirStatus; } /** * Download and process rules * @param {Object} spinner - Ora spinner instance * @param {Object} msg - Localized messages * @param {boolean} debugLog - Debug mode flag */ async function downloadAndProcessRules(spinner, msg, debugLog) { // Check and handle existing tool directories first logWithSpinner(spinner, msg.checkingExistingTools, debugLog); const toolDirStatus = await checkAndHandleExistingToolDirs(spinner, msg, debugLog); // Try to download from GitHub first, fallback to local files const tmpRulesGlobal = path.join(os.tmpdir(), 'aiwf-rules-global'); const tmpRulesManual = path.join(os.tmpdir(), 'aiwf-rules-manual'); let useLocalGlobal = false; let useLocalManual = false; // Try to download rules/global from GitHub try { await downloadDirectory( `rules/global`, tmpRulesGlobal, spinner, debugLog ); logWithSpinner(spinner, `Downloaded rules/global from GitHub`, debugLog); } catch (error) { logWithSpinner(spinner, `Failed to download rules/global from GitHub, trying local files`, debugLog); // Fallback to local files const localRulesGlobal = path.join(process.cwd(), 'rules/global'); try { logWithSpinner(spinner, `Trying to access: ${localRulesGlobal}`, debugLog); await fs.access(localRulesGlobal); logWithSpinner(spinner, `Successfully accessed local rules/global`, debugLog); // Copy local files to temp directory await fs.mkdir(tmpRulesGlobal, { recursive: true }); const files = await fs.readdir(localRulesGlobal); logWithSpinner(spinner, `Found ${files.length} files in rules/global: ${files.join(', ')}`, debugLog); for (const file of files) { const srcPath = path.join(localRulesGlobal, file); const destPath = path.join(tmpRulesGlobal, file); await fs.copyFile(srcPath, destPath); logWithSpinner(spinner, `Copied: ${file}`, debugLog); } useLocalGlobal = true; logWithSpinner(spinner, `Using local rules/global directory`, debugLog); } catch (localError) { logWithSpinner(spinner, `Local rules/global error: ${localError.message}`, debugLog); logWithSpinner(spinner, msg.rulesGlobalNotFound, debugLog); } } // Try to download rules/manual from GitHub try { await downloadDirectory( `rules/manual`, tmpRulesManual, spinner, debugLog ); logWithSpinner(spinner, `Downloaded rules/manual from GitHub`, debugLog); } catch (error) { logWithSpinner(spinner, `Failed to download rules/manual from GitHub, trying local files`, debugLog); // Fallback to local files const localRulesManual = path.join(process.cwd(), 'rules/manual'); try { await fs.access(localRulesManual); // Copy local files to temp directory await fs.mkdir(tmpRulesManual, { recursive: true }); const files = await fs.readdir(localRulesManual); for (const file of files) { const srcPath = path.join(localRulesManual, file); const destPath = path.join(tmpRulesManual, file); await fs.copyFile(srcPath, destPath); } useLocalManual = true; logWithSpinner(spinner, `Using local rules/manual directory`, debugLog); } catch (localError) { logWithSpinner(spinner, msg.rulesManualNotFound, debugLog); } } // Process rules/global try { await fs.access(tmpRulesGlobal); await fs.mkdir(TOOL_DIRS.CURSOR_RULES, { recursive: true }); await fs.mkdir(TOOL_DIRS.WINDSURF_RULES, { recursive: true }); const files = await fs.readdir(tmpRulesGlobal); for (const file of files) { const srcPath = path.join(tmpRulesGlobal, file); const stat = await fs.stat(srcPath); if (!stat.isFile()) continue; // .cursor/rules: add .mdc extension and header const base = path.parse(file).name; const cursorTarget = path.join(TOOL_DIRS.CURSOR_RULES, base + '.mdc'); const content = await fs.readFile(srcPath, 'utf8'); const header = '---\ndescription: \nglobs: \nalwaysApply: true\n---\n'; await fs.writeFile(cursorTarget, header + content, 'utf8'); logWithSpinner( spinner, `[rules/global] ${file} -> ${TOOL_DIRS.CURSOR_RULES}/${base}.mdc`, debugLog ); // .windsurf/rules: keep extension, no header const windsurfTarget = path.join(TOOL_DIRS.WINDSURF_RULES, file); const windsurfContent = await fs.readFile(srcPath, 'utf8'); const windsurfHeader = '---\ntrigger: always_on\n---\n'; await fs.writeFile(windsurfTarget, windsurfHeader + windsurfContent, 'utf8'); logWithSpinner( spinner, `[rules/global] ${file} -> ${TOOL_DIRS.WINDSURF_RULES}/${file}`, debugLog ); } // Clean up temp folder (only if not using local files) if (!useLocalGlobal) { await fs.rm(tmpRulesGlobal, { recursive: true, force: true }); } } catch (error) { logWithSpinner(spinner, `No rules/global directory found`, debugLog); } // Process rules/manual try { await fs.access(tmpRulesManual); await fs.mkdir(TOOL_DIRS.CURSOR_RULES, { recursive: true }); await fs.mkdir(TOOL_DIRS.WINDSURF_RULES, { recursive: true }); const files = await fs.readdir(tmpRulesManual); for (const file of files) { const srcPath = path.join(tmpRulesManual, file); const stat = await fs.stat(srcPath); if (!stat.isFile()) continue; // .cursor/rules: add .mdc extension and header (alwaysApply: false) const base = path.parse(file).name; const cursorTarget = path.join(TOOL_DIRS.CURSOR_RULES, base + '.mdc'); const content = await fs.readFile(srcPath, 'utf8'); const header = '---\ndescription: \nglobs: \nalwaysApply: false\n---\n'; await fs.writeFile(cursorTarget, header + content, 'utf8'); logWithSpinner( spinner, `[rules/manual] ${file} -> ${TOOL_DIRS.CURSOR_RULES}/${base}.mdc`, debugLog ); // .windsurf/rules: keep extension, no header const windsurfTarget = path.join(TOOL_DIRS.WINDSURF_RULES, file); const windsurfContent = await fs.readFile(srcPath, 'utf8'); const windsurfHeader = '---\ntrigger: manual\n---\n'; await fs.writeFile(windsurfTarget, windsurfHeader + windsurfContent, 'utf8'); logWithSpinner( spinner, `[rules/manual] ${file} -> ${TOOL_DIRS.WINDSURF_RULES}/${file}`, debugLog ); } // Clean up temp folder (only if not using local files) if (!useLocalManual) { await fs.rm(tmpRulesManual, { recursive: true, force: true }); } } catch (error) { logWithSpinner(spinner, `No rules/manual directory found`, debugLog); } // Success message for tool rules installation logWithSpinner(spinner, msg.toolRulesInstalled, debugLog); // Display installation results for tool directories if (toolDirStatus.cursor.backedUp || toolDirStatus.windsurf.backedUp) { console.log(chalk.cyan(`\nπŸ“‚ Tool Directory Installation Results:`)); if (toolDirStatus.cursor.backedUp) { console.log(chalk.yellow(` β€’ Cursor: Existing rules backed up and replaced`)); } else if (toolDirStatus.cursor.exists) { console.log(chalk.green(` β€’ Cursor: Rules installed (directory was empty)`)); } else { console.log(chalk.green(` β€’ Cursor: Rules installed (new directory)`)); } if (toolDirStatus.windsurf.backedUp) { console.log(chalk.yellow(` β€’ Windsurf: Existing rules backed up and replaced`)); } else if (toolDirStatus.windsurf.exists) { console.log(chalk.green(` β€’ Windsurf: Rules installed (directory was empty)`)); } else { console.log(chalk.green(` β€’ Windsurf: Rules installed (new directory)`)); } } return toolDirStatus; } /** * Display installation summary * @param {boolean} hasExisting - Has existing installation * @param {Object} msg - Localized messages */ function displaySummary(hasExisting, msg) { if (hasExisting) { console.log(chalk.blue(`\n${msg.updateHistory}`)); console.log(chalk.gray(` β€’ ${TOOL_DIRS.CLAUDE_COMMANDS}${msg.updatedCommands}`)); console.log(chalk.gray(` β€’ ${msg.updatedDocs}`)); console.log(chalk.green(`\n${msg.workPreserved}`)); console.log(chalk.gray(` β€’ ${msg.allFilesPreserved}`)); console.log(chalk.gray(` β€’ ${msg.backupCreated}`)); } else { console.log(chalk.blue(`\n${msg.structureCreated}`)); console.log(chalk.gray(` ${AIWF_DIRS.ROOT}/ - ${msg.aiwfRoot}`)); console.log(chalk.gray(` ${TOOL_DIRS.CLAUDE_COMMANDS}/ - ${msg.claudeCommands}`)); console.log(chalk.green(`\n${msg.nextSteps}`)); console.log(chalk.white(` 1. ${msg.nextStep1}`)); console.log(chalk.white(` 2. ${msg.nextStep2}`)); console.log(chalk.white(` 3. ${msg.nextStep3}\n`)); console.log(chalk.blue(`\n${msg.gettingStarted}`)); console.log(chalk.gray(` 1. ${msg.startStep1}`)); console.log(chalk.gray(` 2. ${msg.startStep2}`)); console.log(chalk.gray(`\n${msg.checkDocs}`)); } } /** * Offer reinstallation options * @param {Object} validationResults - Validation results * @param {string} language - Language code * @returns {Promise<string>} Selected action */ async function offerReinstallationOptions(validationResults, language) { console.log( chalk.yellow( language === 'ko' ? '\n⚠️ 일뢀 μ„€μΉ˜κ°€ μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€. 무엇을 ν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?' : '\n⚠️ Some installations failed. What would you like to do?' ) ); // List failed tools const failedTools = validationResults.failed.map(({ tool }) => tool); if (failedTools.length > 0) { console.log( chalk.red( language === 'ko' ? `μ‹€νŒ¨ν•œ 도ꡬ: ${failedTools.join(', ')}` : `Failed tools: ${failedTools.join(', ')}` ) ); } const response = await prompts({ type: 'select', name: 'action', message: language === 'ko' ? 'μ–΄λ–€ μž‘μ—…μ„ μˆ˜ν–‰ν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?' : 'What would you like to do?', choices: [ { title: language === 'ko' ? '전체 μž¬μ„€μΉ˜' : 'Full reinstallation', value: 'reinstall' }, { title: language === 'ko' ? 'μ‹€νŒ¨ν•œ λ„κ΅¬λ§Œ λ‘€λ°±' : 'Rollback failed tools only', value: 'rollback' }, { title: language === 'ko' ? 'ν˜„μž¬ μƒνƒœ μœ μ§€' : 'Keep current state', value: 'keep' } ] }); return response.action; } /** * μ‚¬μš©μž μ„€μΉ˜ μ˜΅μ…˜ 선택 * @param {Object} msg - μ§€μ—­ν™”λœ λ©”μ‹œμ§€ * @returns {Promise<Array>} μ„ νƒλœ μ„€μΉ˜ μ˜΅μ…˜ λͺ©λ‘ */ async function selectInstallationOptions(msg) { console.log(chalk.cyan(`\n${msg.installationOptions}`)); const installOptions = [ { title: msg.aiwfDocs, value: 'aiwf-docs', description: 'AIWF λ¬Έμ„œ 폴더' }, { title: msg.claudeCodeCommands, value: 'claude-code-commands', description: 'Claude Code λͺ…λ Ήμ–΄ 및 μ—μ΄μ „νŠΈ' }, { title: msg.windsurfRules, value: 'windsurf-rules', description: 'Windsurf κ·œμΉ™ 파일' }, { title: msg.cursorRules, value: 'cursor-rules', description: 'Cursor κ·œμΉ™ 파일' } ]; // 1단계: 전체 μ„€μΉ˜ vs κ°œλ³„ 선택 const installTypeResponse = await prompts({ type: 'select', name: 'installType', message: msg.installationOptions || 'μ„€μΉ˜ μ˜΅μ…˜μ„ μ„ νƒν•˜μ„Έμš”:', choices: [ { title: msg.selectAll || 'βœ… λͺ¨λ“  ν•­λͺ© μ„€μΉ˜ (ꢌμž₯)', value: 'all', description: 'λͺ¨λ“  AIWF μ»΄ν¬λ„ŒνŠΈλ₯Ό μ„€μΉ˜ν•©λ‹ˆλ‹€' }, { title: '🎯 κ°œλ³„ ν•­λͺ© 선택', value: 'custom', description: 'μ›ν•˜λŠ” ν•­λͺ©λ§Œ μ„ νƒν•˜μ—¬ μ„€μΉ˜ν•©λ‹ˆλ‹€' } ] }); // μ·¨μ†Œλœ 경우 if (!installTypeResponse.installType) { console.log(chalk.yellow(msg.noOptionsSelected || 'μ„€μΉ˜κ°€ μ·¨μ†Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.')); process.exit(0); } // 전체 μ„€μΉ˜ 선택 if (installTypeResponse.installType === 'all') { return installOptions.map(opt => opt.value); } // 2단계: κ°œλ³„ ν•­λͺ© 선택 const customResponse = await prompts({ type: 'multiselect', name: 'selectedOptions', message: 'μ„€μΉ˜ν•  ν•­λͺ©μ„ μ„ νƒν•˜μ„Έμš” (슀페이슀둜 선택/ν•΄μ œ, μ—”ν„°λ‘œ 확인):', choices: installOptions, hint: '↑↓ 이동, 슀페이슀 선택/ν•΄μ œ, μ—”ν„° 확인', instructions: false }); // μ·¨μ†Œλ˜κ±°λ‚˜ 아무것도 μ„ νƒν•˜μ§€ μ•Šμ€ 경우 if (!customResponse.selectedOptions || customResponse.selectedOptions.length === 0) { console.log(chalk.yellow(msg.noOptionsSelected || 'μ„ νƒλœ ν•­λͺ©μ΄ μ—†μŠ΅λ‹ˆλ‹€.')); process.exit(0); } return customResponse.selectedOptions; } /** * μ„ νƒλœ μ˜΅μ…˜λ³„ μ„€μΉ˜ μ‹€ν–‰ * @param {Array} selectedOptions - μ„ νƒλœ μ„€μΉ˜ μ˜΅μ…˜λ“€ * @param {string} languagePath - μ–Έμ–΄ 경둜 * @param {Object} msg - μ§€μ—­ν™”λœ λ©”μ‹œμ§€ * @param {boolean} debugLog - 디버그 둜그 μ—¬λΆ€ */ async function installSelectedOptions(selectedOptions, languagePath, msg, debugLog) { for (const option of selectedOptions) { const spinner = ora(`${msg.installingOption}${option}...`).start(); try { switch (option) { case 'aiwf-docs': await installAIWFDocs(languagePath, debugLog); break; case 'claude-code-commands': await installClaudeCodeCommands(languagePath, debugLog); break; case 'windsurf-rules': await installWindsurfRules(debugLog); break; case 'cursor-rules': await installCursorRules(debugLog); break; default: spinner.warn(`Unknown option: ${option}`); continue; } spinner.succeed(`${msg.optionInstalled}${option}`); } catch (error) { spinner.fail(`Failed to install ${option}: ${error.message}`); if (debugLog) { console.error(error); } } } } /** * AIWF λ¬Έμ„œ μ„€μΉ˜ */ async function installAIWFDocs(languagePath, debugLog) { await createDirectoryStructure(); const spinner = ora('Downloading AIWF files...').start(); const msg = { downloading: 'Downloading AIWF documentation...', downloadingTemplates: 'Downloading templates...', templatesNotFound: 'Templates not found' }; try { // λ§€λ‹ˆνŽ˜μŠ€νŠΈ λ‹€μš΄λ‘œλ“œ await downloadManifest(languagePath, spinner, msg, debugLog); // 전체 .aiwf 디렉토리 λ‹€μš΄λ‘œλ“œ await downloadTemplates(languagePath, spinner, msg, debugLog); spinner.succeed('AIWF documentation installed successfully'); } catch (error) { spinner.fail('Failed to install AIWF documentation'); if (debugLog) { console.error(error); } throw error; } } /** * Claude Code λͺ…λ Ήμ–΄ μ„€μΉ˜ */ async function installClaudeCodeCommands(languagePath, debugLog) { const GITHUB_CONTENT_LANGUAGE_PREFIX = `${GITHUB_CONTENT_PREFIX}/${languagePath}`; const dummySpinner = null; const dummyMsg = { downloadingCommands: 'Downloading Claude Code commands...' }; await updateCommands(languagePath, dummySpinner, dummyMsg, debugLog); } /** * Windsurf κ·œμΉ™ μ„€μΉ˜ */ async function installWindsurfRules(debugLog) { const spinner = ora('Installing Windsurf rules...').start(); const msg = { checkingExistingTools: 'Checking existing tools...', foundExistingCursor: 'Found existing Cursor rules', foundExistingWindsurf: 'Found existing Windsurf rules', backingUpToolFiles: 'Backing up tool files...', toolBackupCreated: 'Tool backup created', rulesGlobalNotFound: 'Rules global not found', rulesManualNotFound: 'Rules manual not found', toolRulesInstalled: 'Tool rules installed successfully' }; try { await downloadAndProcessRules(spinner, msg, debugLog); spinner.succeed('Windsurf rules installed successfully'); } catch (error) { spinner.fail('Failed to install Windsurf rules'); if (debugLog) { console.error(error); } throw error; } } /** * Cursor κ·œμΉ™ μ„€μΉ˜ */ async function installCursorRules(debugLog) { const spinner = ora('Installing Cursor rules...').start(); const msg = { checkingExistingTools: 'Checking existing tools...', foundExistingCursor: 'Found existing Cursor rules', foundExistingWindsurf: 'Found existing Windsurf rules', backingUpToolFiles: 'Backing up tool files...', toolBackupCreated: 'Tool backup created', rulesGlobalNotFound: 'Rules global not found', rulesManualNotFound: 'Rules manual not found', toolRulesInstalled: 'Tool rules installed successfully' }; try { await downloadAndProcessRules(spinner, msg, debugLog); spinner.succeed('Cursor rules installed successfully'); } catch (error) { spinner.fail('Failed to install Cursor rules'); if (debugLog) { console.error(error); } throw error; } } /** * Main installation function * @param {Object} options - Installation options */ export async function installAIWF(options = {}) { const debugLog = options.debugLog || false; // Select language const selectedLanguage = await selectLanguage(options); const msg = getMessages(selectedLanguage); console.log(chalk.blue.bold(msg.welcome)); console.log(chalk.gray(msg.description)); console.log(chalk.gray(msg.optimized)); // Set language path const languagePath = getInstallationLanguagePath(selectedLanguage); // Check existing installation const hasExisting = await checkExistingInstallation(); let installDecision = { continue: true, installType: 'fresh', preserveProject: false }; let projectBackupDir = null; if (hasExisting) { installDecision = await handleExistingInstallation(msg, options); if (!installDecision.continue) return; // If reinstalling and preserving project, backup project files if (installDecision.installType === 'reinstall' && installDecision.preserveProject) { const { hasProject, projectFiles } = await checkExistingProject(); if (hasProject) { projectBackupDir = await backupProjectFiles(projectFiles, msg); } } } // μ„€μΉ˜ μ˜΅μ…˜ 선택 let selectedOptions = ['aiwf-docs', 'claude-code-commands', 'windsurf-rules', 'cursor-rules']; if (options.preselectedOptions) { // CLIμ—μ„œ 미리 μ„ νƒλœ μ˜΅μ…˜λ“€μ„ μ‚¬μš© selectedOptions = options.preselectedOptions; } else if (!options.force && !options.interactive) { // κΈ°λ³Έμ μœΌλ‘œλŠ” λŒ€ν™”ν˜• 선택을 μ‚¬μš© selectedOptions = await selectInstallationOptions(msg); } else if (options.interactive) { // --interactive ν”Œλž˜κ·Έκ°€ λͺ…μ‹œμ μœΌλ‘œ μ„€μ •λœ 경우 selectedOptions = await selectInstallationOptions(msg); } console.log(chalk.blue(`\nμ„€μΉ˜ν•  μ˜΅μ…˜: ${selectedOptions.join(', ')}`)); try { // μ„ νƒλœ μ˜΅μ…˜λ“€μ„ μ„€μΉ˜ await installSelectedOptions(selectedOptions, languagePath, msg, debugLog); // Restore project files if needed if (projectBackupDir) { await restoreProjectFiles(projectBackupDir, msg); } // Success message if (hasExisting && installDecision.installType === 'update') { console.log(chalk.green(`\nβœ… ${msg.updateSuccess}`)); } else { console.log(chalk.green(`\nβœ… ${msg.installSuccess}`)); } // Display summary displaySummary(hasExisting, msg); // Run validation based on selected options console.log( chalk.blue('\nπŸ” Installation Validation / μ„€μΉ˜ 검증을 μ‹œμž‘ν•©λ‹ˆλ‹€...') ); // Map selected options to validation tools const validationTools = []; if (selectedOptions.includes('claude-code-commands')) { validationTools.push('claude-code'); } if (selectedOptions.includes('cursor-rules')) { validationTools.push('cursor'); } if (selectedOptions.includes('windsurf-rules')) { validationTools.push('windsurf'); } if (validationTools.length > 0) { const validationResults = await validateInstallation( validationTools, selectedLanguage ); displaySpecCompliantValidationResults(validationResults, selectedLanguage); // Handle partial failures if (validationResults.failed.length > 0) { const action = await offerReinstallationOptions( validationResults, selectedLanguage ); if (action === 'reinstall') { console.log( chalk.blue('\nπŸ”„ μž¬μ„€μΉ˜λ₯Ό μ‹œμž‘ν•©λ‹ˆλ‹€... / Starting reinstallation...') ); // Retry installation once if (!options.isRetry) { return await installAIWF({ ...options, isRetry: true }); } } else if (action === 'rollback') { console.log( chalk.blue('\n↩️ μ‹€νŒ¨ν•œ 도ꡬλ₯Ό λ‘€λ°±ν•©λ‹ˆλ‹€... / Rolling back failed tools...') ); // Rollback failed tools const failedTools = validationResults.failed.map(({ tool }) => tool); const backupDir = getCurrentBackupDir(); for (const tool of failedTools) { if (backupDir) { await rollbackTool( tool, backupDir, selectedLanguage ); } } // Re-validate after rollback console.log( chalk.blue('\nπŸ” λ‘€λ°± ν›„ μž¬κ²€μ¦... / Re-validating after rollback...') ); const postRollbackValidation = await validateInstallation( validationTools, selectedLanguage ); displaySpecCompliantValidationResults(postRollbackValidation, selectedLanguage); } } } else { console.log(chalk.yellow('\n⚠️ 검증할 도ꡬ가 μ„ νƒλ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€.')); } console.log(chalk.green(msg.enjoy)); } catch (error) { if (hasExisting) { spinner.fail(chalk.red(msg.updateFailed)); await restoreFromBackup(spinner, msg); } else { spinner.fail(chalk.red(msg.installFailed)); } console.error(chalk.red(error.message)); process.exit(1); } // Create installation flag file await fs.writeFile(FILES.INSTALLED_FLAG, 'installed', 'utf8'); }