hackages
Version:
CLI tool for learning software development concepts through test-driven development
1,022 lines (998 loc) โข 45.4 kB
JavaScript
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}`));
}
}