UNPKG

hackages

Version:

CLI tool for learning software development concepts through test-driven development

1,022 lines (998 loc) โ€ข 45.4 kB
import fs from "fs"; import path from "path"; import { printInfo, printError, printSuccess } from "../utils/console.js"; import chalk from "chalk"; import { execSync } from "child_process"; import open from "open"; import { getTechnologyConfigWithRepository } from "./tech.service.js"; export function createDirectories() { const testDir = path.join(process.cwd(), "tests"); const srcDir = path.join(process.cwd(), "src"); if (!fs.existsSync(testDir)) { fs.mkdirSync(testDir, { recursive: true }); } if (!fs.existsSync(srcDir)) { fs.mkdirSync(srcDir, { recursive: true }); } } export function generateImplementationTemplate(tech, goal) { const templates = { javascript: `// ${goal} - Implementation // TODO: Implement the functions to make the tests pass // Read the instructions and test file to understand what functions you need to create // Your implementation goes here... `, typescript: `// ${goal} - Implementation // TODO: Implement the functions to make the tests pass // Read the instructions and test file to understand what functions you need to create // Your implementation goes here... export {}; `, python: `# ${goal} - Implementation # TODO: Implement the functions to make the tests pass # Read the instructions and test file to understand what functions you need to create # Your implementation goes here... `, java: `// ${goal} - Implementation // TODO: Implement the functions to make the tests pass // Read the instructions and test file to understand what functions you need to create public class Exercise { // Your implementation goes here... } `, }; return templates[tech] || templates.javascript; } export function createExerciseFiles(goal, exerciseContent, techConfig) { printInfo("๐Ÿ“ Creating project structure..."); createDirectories(); // Create test file const testFileName = `exercise${techConfig.extension}`; const testFilePath = path.join(process.cwd(), "tests", testFileName); printInfo(`๐Ÿ“ Creating test file: tests/${testFileName}`); fs.writeFileSync(testFilePath, exerciseContent.testContent); // Create implementation file const srcFileName = `exercise${techConfig.srcExt}`; const srcFilePath = path.join(process.cwd(), "src", srcFileName); const srcContent = generateImplementationTemplate(techConfig.tech, goal); printInfo(`๐Ÿ’ป Creating implementation file: src/${srcFileName}`); fs.writeFileSync(srcFilePath, srcContent); return { testFilePath, srcFilePath, testFileName, srcFileName, }; } export function getImplementationFile() { // Check current directory first const currentSrcDir = path.join(process.cwd(), "src"); const possibleFiles = [ "exercise.js", "exercise.ts", "exercise.jsx", "exercise.tsx", "exercise-1.js", "exercise-1.ts", "exercise-1.jsx", "exercise-1.tsx", "exercise-2.js", "exercise-2.ts", "exercise-2.jsx", "exercise-2.tsx", "exercise-3.js", "exercise-3.ts", "exercise-3.jsx", "exercise-3.tsx", ]; // Check in current directory for (const file of possibleFiles) { const filePath = path.join(currentSrcDir, file); if (fs.existsSync(filePath)) { const content = fs.readFileSync(filePath, "utf-8"); return content; } } // Check in subdirectories (cloned repositories) const subdirs = fs.readdirSync(process.cwd(), { withFileTypes: true }) .filter(dirent => dirent.isDirectory()) .map(dirent => dirent.name); for (const subdir of subdirs) { const subdirSrcPath = path.join(process.cwd(), subdir, "src"); if (fs.existsSync(subdirSrcPath)) { for (const file of possibleFiles) { const filePath = path.join(subdirSrcPath, file); if (fs.existsSync(filePath)) { const content = fs.readFileSync(filePath, "utf-8"); return content; } } } } return null; } /** * Gets the absolute path to the latest (highest numbered) exercise source file * @returns Object containing the file path and filename, or null if no files found */ export function getLatestSourceFilePath() { // Check current directory first const currentSrcDir = path.join(process.cwd(), "src"); if (fs.existsSync(currentSrcDir)) { try { const files = fs.readdirSync(currentSrcDir); const codeFiles = files.filter(file => file.endsWith('.js') || file.endsWith('.ts') || file.endsWith('.jsx') || file.endsWith('.tsx')); if (codeFiles.length > 0) { const latestFile = getLatestExerciseFile(codeFiles); if (latestFile) { const filePath = path.join(currentSrcDir, latestFile); return { filePath, fileName: latestFile }; } } } catch (error) { console.error("Error reading src directory:", error); } } // Check in subdirectories (cloned repositories) const subdirs = fs.readdirSync(process.cwd(), { withFileTypes: true }) .filter(dirent => dirent.isDirectory()) .map(dirent => dirent.name); for (const subdir of subdirs) { const subdirSrcPath = path.join(process.cwd(), subdir, "src"); if (fs.existsSync(subdirSrcPath)) { try { const files = fs.readdirSync(subdirSrcPath); const codeFiles = files.filter(file => file.endsWith('.js') || file.endsWith('.ts') || file.endsWith('.jsx') || file.endsWith('.tsx')); if (codeFiles.length > 0) { const latestFile = getLatestExerciseFile(codeFiles); if (latestFile) { const filePath = path.join(subdirSrcPath, latestFile); return { filePath, fileName: latestFile }; } } } catch (error) { console.error(`Error reading src directory in ${subdir}:`, error); } } } return null; } /** * Gets the latest (highest numbered) exercise file from the src directory for code review * @returns Object containing the latest exercise file with its content, or null if no files found */ export function getAllSourceFiles() { // Check current directory first const currentSrcDir = path.join(process.cwd(), "src"); if (fs.existsSync(currentSrcDir)) { try { const files = fs.readdirSync(currentSrcDir); const codeFiles = files.filter(file => file.endsWith('.js') || file.endsWith('.ts') || file.endsWith('.jsx') || file.endsWith('.tsx')); if (codeFiles.length > 0) { const latestFile = getLatestExerciseFile(codeFiles); if (latestFile) { const filePath = path.join(currentSrcDir, latestFile); const content = fs.readFileSync(filePath, "utf-8"); return { [latestFile]: content }; } } } catch (error) { console.error("Error reading src directory:", error); } } // Check in subdirectories (cloned repositories) const subdirs = fs.readdirSync(process.cwd(), { withFileTypes: true }) .filter(dirent => dirent.isDirectory()) .map(dirent => dirent.name); for (const subdir of subdirs) { const subdirSrcPath = path.join(process.cwd(), subdir, "src"); if (fs.existsSync(subdirSrcPath)) { try { const files = fs.readdirSync(subdirSrcPath); const codeFiles = files.filter(file => file.endsWith('.js') || file.endsWith('.ts') || file.endsWith('.jsx') || file.endsWith('.tsx')); if (codeFiles.length > 0) { const latestFile = getLatestExerciseFile(codeFiles); if (latestFile) { const filePath = path.join(subdirSrcPath, latestFile); const content = fs.readFileSync(filePath, "utf-8"); return { [latestFile]: content }; } } } catch (error) { console.error(`Error reading src directory in ${subdir}:`, error); } } } return null; } /** * Extracts the exercise number from a filename * @param filename The filename (e.g., "exercise-3.ts", "exercise.js") * @returns The exercise number if found, or 1 as default */ export function getExerciseNumberFromFilename(filename) { const match = filename.match(/^exercise-(\d+)/); if (match) { return parseInt(match[1], 10); } // If no number found (e.g., "exercise.ts"), default to 1 return 1; } /** * Finds the latest exercise file based on exercise number * Returns the file with the highest exercise number, or the first file if no numbered exercises found * @param files Array of file names * @returns The latest exercise file name, or null if no valid files */ function getLatestExerciseFile(files) { if (files.length === 0) { return null; } // Find all numbered exercise files (exercise-1.ts, exercise-2.js, etc.) const numberedExercises = files .map(file => { const match = file.match(/^exercise-(\d+)/); if (match) { return { file, number: parseInt(match[1], 10) }; } return null; }) .filter((item) => item !== null); // If we have numbered exercises, return the one with the highest number if (numberedExercises.length > 0) { const latest = numberedExercises.reduce((prev, current) => current.number > prev.number ? current : prev); return latest.file; } // If no numbered exercises, check for non-numbered exercise files (exercise.ts, exercise.js) const nonNumberedExercises = files.filter(file => /^exercise\.(js|ts|jsx|tsx)$/.test(file)); if (nonNumberedExercises.length > 0) { // Return the first non-numbered exercise file found return nonNumberedExercises[0]; } // If no exercise files found, return the first file (fallback) return files[0]; } export function exerciseFilesExist() { // Check current directory const currentSrcExists = fs.existsSync(path.join(process.cwd(), "src", "exercise.js")) || fs.existsSync(path.join(process.cwd(), "src", "exercise.ts")) || fs.existsSync(path.join(process.cwd(), "src", "exercise-1.js")) || fs.existsSync(path.join(process.cwd(), "src", "exercise-1.ts")) || fs.existsSync(path.join(process.cwd(), "src", "exercise-2.js")) || fs.existsSync(path.join(process.cwd(), "src", "exercise-2.ts")) || fs.existsSync(path.join(process.cwd(), "src", "exercise-3.js")) || fs.existsSync(path.join(process.cwd(), "src", "exercise-3.ts")); const currentTestExists = fs.existsSync(path.join(process.cwd(), "tests", "exercise.spec.js")) || fs.existsSync(path.join(process.cwd(), "tests", "exercise.spec.ts")) || fs.existsSync(path.join(process.cwd(), "tests", "exercise-1.spec.js")) || fs.existsSync(path.join(process.cwd(), "tests", "exercise-1.spec.ts")) || fs.existsSync(path.join(process.cwd(), "tests", "exercise-2.spec.js")) || fs.existsSync(path.join(process.cwd(), "tests", "exercise-2.spec.ts")) || fs.existsSync(path.join(process.cwd(), "tests", "exercise-3.spec.js")) || fs.existsSync(path.join(process.cwd(), "tests", "exercise-3.spec.ts")); if (currentSrcExists && currentTestExists) { return true; } // Check in subdirectories (cloned repositories) const subdirs = fs.readdirSync(process.cwd(), { withFileTypes: true }) .filter(dirent => dirent.isDirectory()) .map(dirent => dirent.name); for (const subdir of subdirs) { const subdirPath = path.join(process.cwd(), subdir); const subdirSrcPath = path.join(subdirPath, "src"); const subdirTestPath = path.join(subdirPath, "tests"); const subdirSrcExists = fs.existsSync(path.join(subdirSrcPath, "exercise.js")) || fs.existsSync(path.join(subdirSrcPath, "exercise.ts")) || fs.existsSync(path.join(subdirSrcPath, "exercise-1.js")) || fs.existsSync(path.join(subdirSrcPath, "exercise-1.ts")) || fs.existsSync(path.join(subdirSrcPath, "exercise-2.js")) || fs.existsSync(path.join(subdirSrcPath, "exercise-2.ts")) || fs.existsSync(path.join(subdirSrcPath, "exercise-3.js")) || fs.existsSync(path.join(subdirSrcPath, "exercise-3.ts")); const subdirTestExists = fs.existsSync(path.join(subdirTestPath, "exercise.spec.js")) || fs.existsSync(path.join(subdirTestPath, "exercise.spec.ts")) || fs.existsSync(path.join(subdirTestPath, "exercise-1.spec.js")) || fs.existsSync(path.join(subdirTestPath, "exercise-1.spec.ts")) || fs.existsSync(path.join(subdirTestPath, "exercise-2.spec.js")) || fs.existsSync(path.join(subdirTestPath, "exercise-2.spec.ts")) || fs.existsSync(path.join(subdirTestPath, "exercise-3.spec.js")) || fs.existsSync(path.join(subdirTestPath, "exercise-3.spec.ts")); if (subdirSrcExists && subdirTestExists) { return true; } } return false; } /** * Elegantly displays feedback in the terminal, supporting markdown-like headers, code blocks, and lists. * @param feedback The feedback string to display */ export function displayFeedback(feedback) { // Split into lines for processing const lines = feedback.split(/\r?\n/); let inCodeBlock = false; let codeBlockBuffer = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]; // Handle code blocks if (line.trim().startsWith('```')) { inCodeBlock = !inCodeBlock; if (!inCodeBlock) { // Print code block console.log(chalk.bgBlackBright.whiteBright(codeBlockBuffer.join('\n'))); codeBlockBuffer = []; } continue; } if (inCodeBlock) { codeBlockBuffer.push(line); continue; } // Headers if (/^#{1,6} /.test(line)) { const headerLevel = line?.match(/^#+/)?.[0]?.length || 0; const headerText = line.replace(/^#+ /, ''); let styled; if (headerLevel === 1) styled = chalk.bold.bgBlue.white(`\n${headerText}\n`); else if (headerLevel === 2) styled = chalk.bold.blue(`\n${headerText}\n`); else if (headerLevel === 3) styled = chalk.bold.cyan(headerText); else styled = chalk.cyan(headerText); console.log(styled); continue; } // Lists if (/^\s*[-*+] /.test(line)) { const item = line.replace(/^\s*[-*+] /, 'โ€ข '); console.log(chalk.yellow(item)); continue; } if (/^\s*\d+\. /.test(line)) { // Numbered list const item = line.replace(/^\s*(\d+\.) /, (m, n) => chalk.magenta(n + ' ')); console.log(item); continue; } // Blockquotes if (/^> /.test(line)) { const quote = line.replace(/^> /, ''); console.log(chalk.gray.italic('โ ' + quote)); continue; } // Emphasis let formatted = line .replace(/\*\*(.*?)\*\*/g, (m, p1) => chalk.bold(p1)) .replace(/\*(.*?)\*/g, (m, p1) => chalk.italic(p1)) .replace(/`([^`]+)`/g, (m, p1) => chalk.bgBlack.whiteBright(p1)); // Emojis and special icons formatted = formatted .replace(/๐ŸŽฏ/g, chalk.green('๐ŸŽฏ')) .replace(/โœ…/g, chalk.green('โœ…')) .replace(/โŒ/g, chalk.red('โŒ')) .replace(/๐Ÿš€/g, chalk.yellow('๐Ÿš€')) .replace(/๐Ÿ’ก/g, chalk.cyan('๐Ÿ’ก')) .replace(/๐Ÿ”ฅ/g, chalk.red('๐Ÿ”ฅ')) .replace(/๐Ÿ“/g, chalk.blue('๐Ÿ“')) .replace(/๐Ÿ“/g, chalk.blue('๐Ÿ“')) .replace(/\n{2,}/g, '\n'); // Print non-empty lines, add spacing for sections if (formatted.trim() === '' && (i === 0 || lines[i - 1].trim() === '')) { continue; } if (formatted.trim() === '') { console.log(''); } else { console.log(formatted); } } } /** * Saves feedback to a markdown file in the user's ~/.hackages directory. * The file is unique per learning session (by identifier). * Code blocks in the feedback are tagged with the technology for syntax highlighting. * * @param feedback The feedback string to save * @param technology The technology name (e.g., 'typescript', 'javascript') for code block highlighting * @param identifier A unique identifier for the learning session (e.g., exercise name, timestamp, or hash) * @returns The path to the saved markdown file */ export function saveFeedbackToMarkdown(feedback, technology, identifier) { // Get home directory cross-platform const homeDir = process.env.HOME || process.env.USERPROFILE || ""; const hackagesDir = path.join(homeDir, ".hackages"); if (!fs.existsSync(hackagesDir)) { fs.mkdirSync(hackagesDir, { recursive: true }); } // Prepare filename (safe, unique) const safeId = identifier.replace(/[^a-zA-Z0-9_-]/g, "_"); const fileName = `feedback_${safeId}.md`; const filePath = path.join(hackagesDir, fileName); // Enhance code blocks with technology for syntax highlighting let inCodeBlock = false; const lines = feedback.split(/\r?\n/); const processedLines = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (line.trim().startsWith('```')) { if (!inCodeBlock) { processedLines.push(`\`\`\`${technology}`); // Open code block with language inCodeBlock = true; } else { processedLines.push('```'); // Close code block inCodeBlock = false; } continue; } processedLines.push(line); } // Write to file fs.writeFileSync(filePath, processedLines.join('\n'), { encoding: 'utf8' }); return filePath; } /** * Detects if the user prefers SSH or HTTPS for Git operations * @returns 'ssh' or 'https' based on user's git configuration */ function detectGitProtocol() { try { // Check if user has SSH keys configured const sshKeyCheck = execSync('ssh-add -l', { stdio: 'pipe' }).toString(); if (sshKeyCheck.includes('no identities') || sshKeyCheck.includes('The agent has no identities')) { return 'https'; } // Check git remote origin to see what protocol is being used try { const remoteOrigin = execSync('git remote get-url origin', { stdio: 'pipe' }).toString().trim(); if (remoteOrigin.startsWith('git@')) { return 'ssh'; } else if (remoteOrigin.startsWith('https://')) { return 'https'; } } catch { // No git repo or no origin, default to HTTPS } return 'https'; } catch { // If SSH check fails, default to HTTPS return 'https'; } } /** * Clones a repository template for the given technology * @param techConfig Technology configuration with repository URLs * @param exerciseName Name for the exercise directory * @returns Path to the cloned repository */ export function cloneRepositoryTemplate(techConfig, exerciseName) { if (!techConfig.repository) { throw new Error(`No repository template available for technology: ${techConfig.tech}`); } const protocol = detectGitProtocol(); const repoUrl = protocol === 'ssh' ? techConfig.repository.ssh : techConfig.repository.https; // Create a safe directory name const safeExerciseName = exerciseName.replace(/[^a-zA-Z0-9_-]/g, '_').toLowerCase(); const targetDir = path.join(process.cwd(), safeExerciseName); // Remove existing directory if it exists if (fs.existsSync(targetDir)) { fs.rmSync(targetDir, { recursive: true, force: true }); } printInfo(`Cloning ${techConfig.tech} template repository...`); try { execSync(`git clone ${repoUrl} "${targetDir}"`, { stdio: 'pipe', cwd: process.cwd() }); // Remove .git directory to make it a fresh start const gitDir = path.join(targetDir, '.git'); if (fs.existsSync(gitDir)) { fs.rmSync(gitDir, { recursive: true, force: true }); } printSuccess(`โœ“ Repository template cloned to: ${safeExerciseName}`); return targetDir; } catch (error) { printError(`โŒ Failed to clone repository: ${error}`); throw error; } } /** * Generates instructions content for the exercise * @param goal Learning goal * @param exerciseContent Generated exercise content * @param techConfig Technology configuration * @returns Instructions content in MDX format */ function generateInstructionsContent(goal, exerciseContent, techConfig) { return `# ${goal} ## ๐ŸŽฏ Learning Objective In this exercise, you'll learn about **${goal.toLowerCase()}** using ${techConfig.tech}. ## ๐Ÿ“‹ Exercise Overview This exercise focuses on implementing functions that will make the tests pass. The tests are already written for you - your job is to understand what the tests expect and implement the functions accordingly. ## ๐Ÿงช Test Cases The test file contains the following test cases: \`\`\`${techConfig.tech.toLowerCase()} ${exerciseContent.testContent} \`\`\` ## ๐Ÿ’ป Implementation You need to implement the functions in \`src/exercise${techConfig.srcExt}\`. The file is already created with a basic template. ## ๐Ÿš€ Getting Started 1. **Read the instructions and tests first** - They tell you exactly what your functions should do 2. **Understand the requirements** - Each test case describes the expected behavior 3. **Implement step by step** - Start with the simplest function and work your way up 4. **Run tests frequently** - Use \`npm test\` to check your progress ## ๐Ÿ’ก Tips - Focus on making one test pass at a time - Don't worry about perfect code initially - get it working first - Read the error messages carefully - they often give helpful hints - If you're stuck, try to understand what the test is actually testing ## ๐ŸŽ‰ Success Criteria You'll know you've completed the exercise when all tests pass with \`npm test\`. Good luck! ๐Ÿš€ `; } /** * Creates exercise files in a cloned repository template * @param goal Learning goal * @param exerciseContent Generated exercise content * @param techConfig Technology configuration * @param repoPath Path to the cloned repository * @returns Exercise files information */ export function createExerciseFilesInRepository(goal, exerciseContent, techConfig, repoPath, exerciseNumber) { printInfo("Setting up exercise files in repository..."); // Use correct file extensions based on technology const testFileExt = techConfig.extension.replace('.spec', ''); const srcFileExt = techConfig.srcExt; // Write to existing test file (exercise-x.spec.js or exercise-x.spec.ts) const testFilePath = path.join(repoPath, "tests", `exercise-${exerciseNumber}.spec${testFileExt}`); if (!fs.existsSync(testFilePath)) { printError(`โŒ Test file not found: ${testFilePath}`); throw new Error(`Test file not found in template: ${testFilePath}`); } printSuccess(`โœ“ Writing test content to: tests/exercise-${exerciseNumber}.spec${testFileExt}`); fs.writeFileSync(testFilePath, exerciseContent.testContent); // Write to existing implementation file (exercise-1.js or exercise-1.ts) const srcFilePath = path.join(repoPath, "src", `exercise-${exerciseNumber}${srcFileExt}`); if (!fs.existsSync(srcFilePath)) { printError(`โŒ Implementation file not found: ${srcFilePath}`); throw new Error(`Implementation file not found in template: ${srcFilePath}`); } const srcContent = generateImplementationTemplate(techConfig.tech, goal); printSuccess(`โœ“ Writing implementation template to: src/exercise-${exerciseNumber}${srcFileExt}`); fs.writeFileSync(srcFilePath, srcContent); // Write instructions to existing instructions file const instructionsDir = path.join(repoPath, "instructions"); const instructionsFilePath = path.join(instructionsDir, `exercise-${exerciseNumber}.mdx`); if (!fs.existsSync(instructionsFilePath)) { printError(`โŒ Instructions file not found: ${instructionsFilePath}`); throw new Error(`Instructions file not found in template: ${instructionsFilePath}`); } const instructionsContent = generateInstructionsContent(goal, exerciseContent, techConfig); printSuccess(`โœ“ Writing instructions to: instructions/exercise-${exerciseNumber}.mdx`); fs.writeFileSync(instructionsFilePath, instructionsContent); return { testFilePath, srcFilePath, testFileName: `exercise-${exerciseNumber}.spec${testFileExt}`, srcFileName: `exercise-${exerciseNumber}${srcFileExt}`, }; } function getCurrentLearningGoal() { const learningJson = fs.readFileSync(path.join(process.env.HOME || process.env.USERPROFILE || "", ".hackages", "learning.json"), "utf8"); const learning = JSON.parse(learningJson); return learning.current; } export function getLearningInformation() { const currentLearningGoal = getCurrentLearningGoal(); const learningJson = fs.readFileSync(path.join(process.env.HOME || process.env.USERPROFILE || "", ".hackages", "learning.json"), "utf8"); const learning = JSON.parse(learningJson); return learning[currentLearningGoal]; } /** * Find the directory containing exercise files (either current directory or a subdirectory) * @returns Path to the directory containing exercise files, or current directory if not found */ export function findExerciseDirectory() { // Check current directory first const currentSrcExists = fs.existsSync(path.join(process.cwd(), "src", "exercise.js")) || fs.existsSync(path.join(process.cwd(), "src", "exercise.ts")) || fs.existsSync(path.join(process.cwd(), "src", "exercise-1.js")) || fs.existsSync(path.join(process.cwd(), "src", "exercise-1.ts")) || fs.existsSync(path.join(process.cwd(), "src", "exercise-2.js")) || fs.existsSync(path.join(process.cwd(), "src", "exercise-2.ts")) || fs.existsSync(path.join(process.cwd(), "src", "exercise-3.js")) || fs.existsSync(path.join(process.cwd(), "src", "exercise-3.ts")); const currentTestExists = fs.existsSync(path.join(process.cwd(), "tests", "exercise.spec.js")) || fs.existsSync(path.join(process.cwd(), "tests", "exercise.spec.ts")) || fs.existsSync(path.join(process.cwd(), "tests", "exercise-1.spec.js")) || fs.existsSync(path.join(process.cwd(), "tests", "exercise-1.spec.ts")) || fs.existsSync(path.join(process.cwd(), "tests", "exercise-2.spec.js")) || fs.existsSync(path.join(process.cwd(), "tests", "exercise-2.spec.ts")) || fs.existsSync(path.join(process.cwd(), "tests", "exercise-3.spec.js")) || fs.existsSync(path.join(process.cwd(), "tests", "exercise-3.spec.ts")); if (currentSrcExists && currentTestExists) { return process.cwd(); } // Check in subdirectories (cloned repositories) const subdirs = fs.readdirSync(process.cwd(), { withFileTypes: true }) .filter(dirent => dirent.isDirectory()) .map(dirent => dirent.name); for (const subdir of subdirs) { const subdirPath = path.join(process.cwd(), subdir); const subdirSrcPath = path.join(subdirPath, "src"); const subdirTestPath = path.join(subdirPath, "tests"); const subdirSrcExists = fs.existsSync(path.join(subdirSrcPath, "exercise.js")) || fs.existsSync(path.join(subdirSrcPath, "exercise.ts")) || fs.existsSync(path.join(subdirSrcPath, "exercise-1.js")) || fs.existsSync(path.join(subdirSrcPath, "exercise-1.ts")) || fs.existsSync(path.join(subdirSrcPath, "exercise-2.js")) || fs.existsSync(path.join(subdirSrcPath, "exercise-2.ts")) || fs.existsSync(path.join(subdirSrcPath, "exercise-3.js")) || fs.existsSync(path.join(subdirSrcPath, "exercise-3.ts")); const subdirTestExists = fs.existsSync(path.join(subdirTestPath, "exercise.spec.js")) || fs.existsSync(path.join(subdirTestPath, "exercise.spec.ts")) || fs.existsSync(path.join(subdirTestPath, "exercise-1.spec.js")) || fs.existsSync(path.join(subdirTestPath, "exercise-1.spec.ts")) || fs.existsSync(path.join(subdirTestPath, "exercise-2.spec.js")) || fs.existsSync(path.join(subdirTestPath, "exercise-2.spec.ts")) || fs.existsSync(path.join(subdirTestPath, "exercise-3.spec.js")) || fs.existsSync(path.join(subdirTestPath, "exercise-3.spec.ts")); if (subdirSrcExists && subdirTestExists) { return subdirPath; } } return process.cwd(); } /** * Saves feedback to the template folder's feedback directory * @param feedback The feedback string to save * @param exerciseNumber The exercise number (default: 1) * @returns Path to the saved feedback file */ export function saveFeedbackToTemplate(feedback, exerciseNumber = 1, selectedTech) { const exerciseDir = findExerciseDirectory(); if (exerciseDir === process.cwd()) { // Not in a cloned repository, save to .hackages instead return saveFeedbackToMarkdown(feedback, selectedTech, `exercise-${exerciseNumber}`); } const feedbackDir = path.join(exerciseDir, "feedback"); // Create feedback directory if it doesn't exist if (!fs.existsSync(feedbackDir)) { fs.mkdirSync(feedbackDir, { recursive: true }); } const feedbackFilePath = path.join(feedbackDir, `exercise-${exerciseNumber}.mdx`); // Enhance code blocks with TypeScript for syntax highlighting let inCodeBlock = false; const lines = feedback.split(/\r?\n/); const processedLines = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (line.trim().startsWith('```')) { if (!inCodeBlock) { // TODO: get the correct language from the feedback processedLines.push(`\`\`\`${selectedTech.toLowerCase()}`); // Open code block with language inCodeBlock = true; } else { processedLines.push('```'); // Close code block inCodeBlock = false; } continue; } processedLines.push(line); } // Write to file fs.writeFileSync(feedbackFilePath, processedLines.join('\n'), { encoding: 'utf8' }); printInfo(`๐Ÿ“ Feedback saved to: feedback/exercise-${exerciseNumber}.mdx`); return feedbackFilePath; } /** * Opens the feedback HTML page in the default browser * @param htmlFilePath Path to the HTML file */ export function openFeedbackHTML(htmlFilePath) { try { open(htmlFilePath); printInfo(`๐ŸŒ Opening feedback page in browser...`); } catch (error) { printError(`โŒ Failed to open HTML file: ${error}`); printInfo(`๐Ÿ’ก You can manually open: ${htmlFilePath}`); } } /** * Finds the next available exercise number by scanning existing exercise files * @param exerciseDir Directory containing exercise files * @param techConfig Technology configuration to determine file extensions * @returns Next available exercise number */ export function getNextExerciseNumber(exerciseDir, techConfig) { const srcDir = path.join(exerciseDir, "src"); const testDir = path.join(exerciseDir, "tests"); const existingNumbers = new Set(); // Check src directory for existing exercises if (fs.existsSync(srcDir)) { try { const srcFiles = fs.readdirSync(srcDir); srcFiles.forEach(file => { const match = file.match(/^exercise-(\d+)/); if (match) { existingNumbers.add(parseInt(match[1], 10)); } }); } catch (error) { // Directory might not exist or be readable } } // Check tests directory for existing exercises if (fs.existsSync(testDir)) { try { const testFiles = fs.readdirSync(testDir); testFiles.forEach(file => { const match = file.match(/^exercise-(\d+)\.spec\./); if (match) { existingNumbers.add(parseInt(match[1], 10)); } }); } catch (error) { // Directory might not exist or be readable } } // If no exercises found, start with exercise-1 if (existingNumbers.size === 0) { return 1; } // Find the highest number and add 1 const maxNumber = Math.max(...Array.from(existingNumbers)); return maxNumber + 1; } /** * Checks if an exercise with the given number already exists * @param exerciseDir Directory containing exercise files * @param exerciseNumber Exercise number to check * @param techConfig Technology configuration * @returns true if exercise exists, false otherwise */ function exerciseExists(exerciseDir, exerciseNumber, techConfig) { const testFilePath = path.join(exerciseDir, "tests", `exercise-${exerciseNumber}${techConfig.extension}`); const srcFilePath = path.join(exerciseDir, "src", `exercise-${exerciseNumber}${techConfig.srcExt}`); const instructionsFilePath = path.join(exerciseDir, "instructions", `exercise-${exerciseNumber}.mdx`); return fs.existsSync(testFilePath) || fs.existsSync(srcFilePath) || fs.existsSync(instructionsFilePath); } /** * Creates the next exercise in the same repository * @param learningGoal The learning goal for the next exercise * @param exerciseNumber The exercise number to create */ export async function createNextExercise(learningGoal, exerciseNumber) { const exerciseDir = findExerciseDirectory(); // Check if we're in an exercise directory (either current directory or subdirectory) const isInExerciseDir = exerciseDir !== process.cwd() || fs.existsSync(path.join(process.cwd(), "src", "exercise-1.ts")) || fs.existsSync(path.join(process.cwd(), "src", "exercise-1.js")) || fs.existsSync(path.join(process.cwd(), "tests", "exercise-1.spec.ts")) || fs.existsSync(path.join(process.cwd(), "tests", "exercise-1.spec.js")); if (!isInExerciseDir) { printError(`โŒ Current directory: ${process.cwd()}`); printError(`โŒ Exercise directory found: ${exerciseDir}`); printError(`โŒ Looking for exercise files in: ${path.join(process.cwd(), "src")}`); throw new Error("No exercise directory found. Please make sure you're in an exercise directory with exercise files (src/exercise-1.ts, tests/exercise-1.spec.ts)."); } printInfo(`๐Ÿ“ Creating next exercise in: ${exerciseDir}`); // Get technology configuration const techConfig = getTechnologyConfigWithRepository(learningGoal.selectedTech.toLowerCase()); // Check if exercise already exists if (exerciseExists(exerciseDir, exerciseNumber, techConfig)) { printError(`โŒ Exercise ${exerciseNumber} already exists!`); throw new Error(`Exercise ${exerciseNumber} already exists. Please remove existing files or use a different exercise number.`); } // Generate exercise content with Claude const { generateLearningExercise } = await import("./claude.service.js"); const exerciseContent = await generateLearningExercise(learningGoal, exerciseNumber); // Create test file const testFilePath = path.join(exerciseDir, "tests", `exercise-${exerciseNumber}${techConfig.extension}`); printInfo(`๐Ÿ“ Creating test file: tests/exercise-${exerciseNumber}${techConfig.extension}`); fs.writeFileSync(testFilePath, exerciseContent.testContent); // Create implementation file const srcFilePath = path.join(exerciseDir, "src", `exercise-${exerciseNumber}${techConfig.srcExt}`); const srcContent = generateImplementationTemplate(techConfig.tech, learningGoal.topic); printInfo(`๐Ÿ’ป Creating implementation file: src/exercise-${exerciseNumber}${techConfig.srcExt}`); fs.writeFileSync(srcFilePath, srcContent); // Create instructions file const instructionsDir = path.join(exerciseDir, "instructions"); const instructionsFilePath = path.join(instructionsDir, `exercise-${exerciseNumber}.mdx`); if (!fs.existsSync(instructionsDir)) { fs.mkdirSync(instructionsDir, { recursive: true }); } const instructionsContent = generateInstructionsContent(learningGoal.topic, exerciseContent, techConfig); printInfo(`๐Ÿ“– Creating instructions: instructions/exercise-${exerciseNumber}.mdx`); fs.writeFileSync(instructionsFilePath, instructionsContent); } /** * Checks if there's previous feedback available for the current exercise * @returns Array of learning suggestions if feedback exists, null otherwise */ export function getPreviousLearningSuggestions() { const exerciseDir = findExerciseDirectory(); if (exerciseDir === process.cwd()) { return null; } // Check for feedback files in the exercise directory const feedbackDir = path.join(exerciseDir, "feedback"); if (!fs.existsSync(feedbackDir)) { return null; } // Look for feedback files (exercise-1.mdx, exercise-2.mdx, etc.) const feedbackFiles = fs.readdirSync(feedbackDir) .filter(file => file.startsWith('exercise-') && file.endsWith('.mdx')) .sort((a, b) => { const numA = parseInt(a.match(/exercise-(\d+)/)?.[1] || '0'); const numB = parseInt(b.match(/exercise-(\d+)/)?.[1] || '0'); return numB - numA; // Sort by exercise number, newest first }); if (feedbackFiles.length === 0) { return null; } // Read the most recent feedback file const latestFeedbackFile = feedbackFiles[0]; const feedbackPath = path.join(feedbackDir, latestFeedbackFile); try { const feedbackContent = fs.readFileSync(feedbackPath, 'utf8'); return extractLearningSuggestions(feedbackContent); } catch (error) { printError(`โŒ Error reading feedback file: ${error}`); return null; } } /** * Extracts learning suggestions from feedback text * @param feedback The feedback string * @returns Array of learning suggestions */ function extractLearningSuggestions(feedback) { const suggestions = []; // Find the "Next Learning Steps" section const nextLearningStepsMatch = feedback.match(/### Next Learning Steps\s*\n([\s\S]*?)(?=\n### |$)/); if (nextLearningStepsMatch && nextLearningStepsMatch[1]) { const nextLearningSection = nextLearningStepsMatch[1]; // Look for numbered list items with bold titles in this section only const numberedListPattern = /^\d+\.\s+\*\*([^*]+)\*\*:/gm; const matches = nextLearningSection.match(numberedListPattern); if (matches) { matches.forEach(match => { // Extract the title from the bold text const titleMatch = match.match(/\*\*([^*]+)\*\*:/); if (titleMatch && titleMatch[1]) { const title = titleMatch[1].trim(); if (title && !suggestions.includes(title)) { suggestions.push(title); } } }); } } // If no suggestions found, provide default ones if (suggestions.length === 0) { return [ "Explore Different Number Types", "Learn About Function Overloading", "Error Handling", "Generic Functions", "Advanced TypeScript Features" ]; } return suggestions.slice(0, 3); // Limit to 3 suggestions for menu } /** * Handles the next learning flow with prompts and exercise generation * @param suggestions Array of learning suggestions */ export async function handleNextLearning(suggestions) { const { getLearningInformation } = await import("./file-manager.js"); const { createNextExercise, getNextExerciseNumber, findExerciseDirectory } = await import("./file-manager.js"); const { getTechnologyConfigWithRepository } = await import("./tech.service.js"); const prompts = await import("prompts"); const chalk = await import("chalk"); console.log("\n" + chalk.default.cyan("=".repeat(50))); console.log(chalk.default.bold.yellow("๐Ÿš€ Continue Your Learning Journey")); console.log(chalk.default.cyan("=".repeat(50))); console.log(chalk.default.gray("Choose your next learning step:\n")); const choices = [ ...suggestions.map((suggestion, index) => ({ title: `${index + 1}. ${suggestion}`, value: suggestion })), { title: `${suggestions.length + 1}. Write your next learning goal`, value: "custom" } ]; const response = await prompts.default({ type: "select", name: "nextStep", message: "Select your next learning step:", choices, initial: 0, }); if (!response.nextStep) { console.log(chalk.default.gray("\n๐Ÿ‘‹ Happy learning! Come back when you're ready for more.")); return; } let nextLearningGoal; if (response.nextStep === "custom") { const customResponse = await prompts.default({ type: "text", name: "customGoal", message: "Enter your next learning goal:", validate: (value) => value.length > 0 ? true : "Please enter a learning goal", }); if (!customResponse.customGoal) { console.log(chalk.default.gray("\n๐Ÿ‘‹ Happy learning! Come back when you're ready for more.")); return; } nextLearningGoal = customResponse.customGoal; } else { nextLearningGoal = response.nextStep; } console.log("\n" + chalk.default.yellow("๐Ÿ”„ Generating your next exercise...")); try { // Get current learning information const learningInformation = getLearningInformation(); // Create new learning goal for the next exercise const nextGoal = { ...learningInformation, id: learningInformation.id + "-next", topic: nextLearningGoal, }; // Get technology configuration to determine file extensions const techConfig = getTechnologyConfigWithRepository(learningInformation.selectedTech.toLowerCase()); // Find the next available exercise number const exerciseDir = findExerciseDirectory(); const nextExerciseNumber = getNextExerciseNumber(exerciseDir, techConfig); // Generate next exercise (will auto-detect number if not provided, but we calculate it for display) await createNextExercise(nextGoal, nextExerciseNumber); console.log("\n" + chalk.default.bold.green("๐ŸŽ‰ Your next exercise is ready!")); console.log(chalk.default.cyan("=".repeat(50))); console.log(chalk.default.bold("๐Ÿ“ Next exercise created:")); console.log(chalk.default.green(` โœ“ tests/exercise-${nextExerciseNumber}${techConfig.extension}`), chalk.default.dim("(Your test cases)")); console.log(chalk.default.green(` โœ“ src/exercise-${nextExerciseNumber}${techConfig.srcExt}`), chalk.default.dim("(Your implementation file)")); console.log(chalk.default.green(` โœ“ instructions/exercise-${nextExerciseNumber}.mdx`), chalk.default.dim("(Exercise instructions)")); console.log("\n" + chalk.default.bold.yellow("๐Ÿš€ Next steps:")); console.log(chalk.default.white("1. Read the instructions and test file to understand what to implement")); console.log(chalk.default.white(`2. Open src/exercise-${nextExerciseNumber}${techConfig.srcExt} and start coding`)); console.log(chalk.default.white("3. Run 'npx hackages test' to validate your implementation")); console.log(chalk.default.white("4. Run 'npx hackages review' to get feedback and recommendations for next steps")); } catch (error) { console.log(chalk.default.red(`โŒ Error generating next exercise: ${error}`)); } }