diff --git a/.changeset/gorgeous-keys-swim.md b/.changeset/gorgeous-keys-swim.md new file mode 100644 index 0000000..a3911fa --- /dev/null +++ b/.changeset/gorgeous-keys-swim.md @@ -0,0 +1,5 @@ +--- +"eth-tech-tree": patch +--- + +Major UI overhaul. Everything should still work similarly to last release. diff --git a/README.md b/README.md index 3fa81cf..20a43e7 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,24 @@ -> โš ๏ธ Ethereum Development Tech Tree is currently under heavy construction. - # ETH Development Tech Tree -Test your skills and find some new ones by completing challenges. - -There are three different types of nodes on the tree: -- [x] Challenges: A repository that poses a problem that you must solve with Solidity. You deploy your contract and submit your contract address so we can test it to ensure your solution works, allowing you to progress. -- [ ] Quizzes: Links to source material that will help you to master a topic that will be encountered in later challenges. -- [ ] Capstone Projects: These are large scale projects that stretch your knowledge about the ecosystem. A description of the project is provided but it is up to you to fulfill the description. +Test your skills and find some new ones by completing medium to hard Solidity challenges. ## Quick Start Run the following command to use the NPM package ```bash npx eth-tech-tree ``` -The CLI visualizes several categories which contain challenges. Navigate with your arrow keys and hit enter to view options for a challenge. Follow the instructions in your CLI to complete challenges fill out your Ethereum dev tech skills. +The CLI visualizes several categories which contain challenges. Navigate with your arrow keys and hit enter to view the challenge description and see options. Follow the instructions in your CLI to complete challenges and fill out your Ethereum development tech tree. + +You can also run individual commands without the tree visualization. + +Set up a challenge: +```bash + npx eth-tech-tree setup CHALLENGE_NAME INSTALL_LOCATION +``` + +Submit a challenge: +```bash + npx eth-tech-tree submit CHALLENGE_NAME CONTRACT_ADDRESS +``` ## Development Clone and `cd` into the repo then run this CLI application with the following commands @@ -21,8 +26,4 @@ Clone and `cd` into the repo then run this CLI application with the following co - `yarn build` - `yarn cli` -## TODO -- [ ] Show users how many challenges they have completed in a category -- [ ] Show users where they rank on a leaderboard -- [ ] Onchain NFT mint or attestations showing a user has completed certain challenges -- [ ] Enable Gas Efficiency CTF element \ No newline at end of file +Also consider contributing new challenges here: https://github.com/BuidlGuidl/eth-tech-tree-challenges \ No newline at end of file diff --git a/package.json b/package.json index 71673a1..03272b8 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,6 @@ "license": "MIT", "devDependencies": { "@rollup/plugin-typescript": "11.1.0", - "@types/inquirer": "9.0.3", "@types/ncp": "2.0.5", "@types/node": "18.16.0", "rollup": "3.21.0", @@ -42,6 +41,7 @@ }, "dependencies": { "@changesets/cli": "^2.26.2", + "@inquirer/prompts": "^7.1.0", "@types/terminal-kit": "^2.5.6", "ansi-escapes": "^7.0.0", "arg": "5.0.2", @@ -50,12 +50,11 @@ "dotenv": "^16.4.5", "execa": "7.1.1", "handlebars": "^4.7.7", - "inquirer": "9.2.0", - "inquirer-tree-prompt": "^1.1.2", "listr2": "^8.2.5", "merge-packages": "^0.1.6", "ncp": "^2.0.0", "pkg-install": "1.0.0", + "semver": "^7.6.3", "terminal-kit": "^3.1.1" }, "packageManager": "yarn@3.5.0" diff --git a/rollup.config.js b/rollup.config.js index 360d88b..e621979 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -9,4 +9,5 @@ export default { sourcemap: true, }, plugins: [autoExternal(), typescript({ exclude: ["challenges/**"] })], + external: ["@inquirer/core"], }; diff --git a/src/actions/setup-challenge.ts b/src/actions/setup-challenge.ts index de05cf3..b2a9018 100644 --- a/src/actions/setup-challenge.ts +++ b/src/actions/setup-challenge.ts @@ -1,15 +1,18 @@ import { execa } from "execa"; +import semver, { Range } from 'semver'; import ncp from "ncp"; import path from "path"; import fs from "fs"; import { createFirstGitCommit } from "../tasks/create-first-git-commit"; import { fetchChallenges } from "../modules/api"; -import { loadChallenges } from "../utils/stateManager"; +import { loadChallenges } from "../utils/state-manager"; import { IChallenge } from "../types"; import { BASE_REPO, BASE_BRANCH, BASE_COMMIT } from "../config"; import { DefaultRenderer, Listr, ListrTaskWrapper, SimpleRenderer } from "listr2"; import chalk from "chalk"; +type RequiredDependency = "node" | "git" | "yarn" | "foundryup"; + // Sidestep for ncp issue https://github.com/AvianFlu/ncp/issues/127 const copy = (source: string, destination: string, options?: ncp.Options) => new Promise((resolve, reject) => { ncp(source, destination, options || {}, (err) => { @@ -57,6 +60,10 @@ export const setupChallenge = async (name: string, installLocation: string) => { } const tasks = new Listr([ + { + title: 'Checking for required dependencies', + task: () => checkUserDependencies() + }, { title: 'Setting up base repository', task: () => setupBaseRepo(targetDir) @@ -84,11 +91,39 @@ export const setupChallenge = async (name: string, installLocation: string) => { console.log(chalk.green("Challenge setup completed successfully.")); console.log(""); console.log(chalk.cyan(`Now open this repository in your favorite code editor and look at the readme for instructions: ${targetDir}`)); - } catch (error) { - console.error(chalk.red("An error occurred during challenge setup:"), error); + } catch (error: any) { + console.error(chalk.red("An error occurred during challenge setup:"), error.message); + } +} + +const checkDependencyInstalled = async (name: RequiredDependency) => { + try { + await execa(name, ["--help"]); + } catch(_) { + throw new Error(`${name} is required. Please install to continue.`); } } +const checkDependencyVersion = async (name: RequiredDependency, requiredVersion: string | Range) => { + try { + const userVersion = (await execa(name, ["--version"])).stdout; + if (!semver.satisfies(userVersion, requiredVersion)) { + throw new Error(`${name} version requirement of ${requiredVersion} not met. Please update to continue.`); + } + } catch(_) { + throw new Error(`${name} ${requiredVersion} is required. Please install to continue.`); + } +} + +export const checkUserDependencies = async () => { + await Promise.all([ + checkDependencyVersion("node", ">=18.17.0"), + checkDependencyInstalled("git"), + checkDependencyInstalled("yarn"), + checkDependencyInstalled("foundryup"), + ]) +} + const setupBaseRepo = async (targetDir: string): Promise => { await execa("git", ["clone", "--branch", BASE_BRANCH, "--single-branch", BASE_REPO, targetDir]); await execa("git", ["checkout", BASE_COMMIT], { cwd: targetDir }); diff --git a/src/actions/submit-challenge.ts b/src/actions/submit-challenge.ts index 5a836f5..4ed3e6b 100644 --- a/src/actions/submit-challenge.ts +++ b/src/actions/submit-challenge.ts @@ -1,23 +1,18 @@ -import inquirer from "inquirer"; -import { loadUserState } from "../utils/stateManager"; +import { loadUserState } from "../utils/state-manager"; import { submitChallengeToServer } from "../modules/api"; import chalk from "chalk"; +import { input } from "@inquirer/prompts"; export async function submitChallenge(name: string, contractAddress?: string) { const { address: userAddress } = loadUserState(); if (!contractAddress) { // Prompt the user for the contract address - const questions = [ - { - type: "input", - name: "address", - message: "Completed challenge contract address on Sepolia:", - validate: (value: string) => /^0x[a-fA-F0-9]{40}$/.test(value), - }, - ]; - const answers = await inquirer.prompt(questions); - const { address } = answers; - contractAddress = address; + const question = { + message: "Completed challenge contract address on Sepolia:", + validate: (value: string) => /^0x[a-fA-F0-9]{40}$/.test(value), + }; + const answer = await input(question); + contractAddress = answer; } console.log("Submitting challenge..."); diff --git a/src/cli.ts b/src/cli.ts index ed94be8..8ee5652 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,11 +1,11 @@ import { promptForMissingUserState } from "./tasks/prompt-for-missing-user-state"; import { renderIntroMessage } from "./tasks/render-intro-message"; import type { Args, IUser } from "./types"; -import { startVisualization } from "./utils/tree"; -import { loadUserState, saveChallenges } from "./utils/stateManager"; +import { loadUserState, saveChallenges } from "./utils/state-manager"; import { fetchChallenges } from "./modules/api"; import { parseCommandArgumentsAndOptions, promptForMissingCommandArgs } from "./tasks/parse-command-arguments-and-options"; import { handleCommand } from "./tasks/handle-command"; +import { TechTree } from "."; @@ -19,7 +19,9 @@ export async function cli(args: Args) { await renderIntroMessage(); await init(userState); // Navigate tree - await startVisualization(); + const techTree = new TechTree(); + + await techTree.start(); } } diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..179e03d --- /dev/null +++ b/src/index.ts @@ -0,0 +1,425 @@ +import { existsSync, rmSync } from "fs"; +import { confirm } from "@inquirer/prompts"; +import { IUserChallenge, IChallenge, TreeNode, IUser, Actions } from "./types"; +import chalk from "chalk"; +import { loadChallenges, loadUserState, saveUserState } from "./utils/state-manager"; +import { getUser } from "./modules/api"; +import { setupChallenge, submitChallenge } from "./actions"; +import select from './utils/global-context-select-list'; +import { ProgressView } from "./utils/progress-view"; +import { calculatePoints } from "./utils/helpers"; + +type GlobalChoice = { + value: string; + key: string; +} + +export class TechTree { + private globalTree: TreeNode; + private userState: IUser; + private challenges: IChallenge[]; + private history: { node: TreeNode, selection: string }[] = []; + private globalChoices: GlobalChoice[]; + private nodeLabel: string = "Main Menu"; + + constructor() { + this.userState = loadUserState(); + this.challenges = loadChallenges(); + this.globalTree = this.buildTree(); + this.globalChoices = [ + { value: 'quit', key: 'q' }, + { value: 'help', key: 'h' }, + { value: 'progress', key: 'p' }, + { value: 'back', key: 'escape' }, + { value: 'back', key: 'backspace' }, + ]; + + this.listenForQuit(); + } + + listenForQuit(): void { + process.stdin.setRawMode(true); + process.stdin.on('keypress', (_, key) => { + if ((key.ctrl && key.name === 'c')) { + this.quit(); + } + }); + } + + async start(): Promise { + await this.navigate(); + } + + async navigate(node?: TreeNode, selection?: string): Promise { + if (!node) { + this.globalTree = this.buildTree(); + node = Object.assign({}, this.globalTree); + } + + // Handle navigation nodes + const { choices, actions } = this.getChoicesAndActions(node); + + const directionsPrompt = { + message: this.getMessage(node), + globalChoices: this.globalChoices, + choices, + loop: false, + default: selection, + pageSize: this.getMaxViewHeight() - 3, + theme: { + helpMode: "always" as "never" | "always" | "auto" | undefined, + prefix: "" + } + }; + + try { + this.nodeLabel = node.label; + this.clearView(); + this.printMenu(); + const { answer } = await select(directionsPrompt); + if (!this.globalChoices.find(choice => choice.value === answer)) { + const selectedAction = actions[answer]; + // Only save new history if the action is not a global choice + this.history.push({ node, selection: answer }); + await selectedAction(); + } else { + const selectedAction = this.getGlobalChoiceAction(answer); + await selectedAction(); + } + } catch (error) { + // Do nothing + // console.log(error); + } + } + + getGlobalChoiceAction(selectedActionLabel: string): Function { + if (selectedActionLabel === 'quit') { + return () => this.quit(); + } else if (selectedActionLabel === 'back') { + return () => this.goBack(); + } else if (selectedActionLabel === 'help') { + return () => this.printHelp(); + } else if (selectedActionLabel === 'progress') { + return () => this.printProgress(); + } + throw new Error(`Invalid global choice: ${selectedActionLabel}`); + } + + async goBack(): Promise { + if (this.history.length > 0) { + const { node, selection } = this.history.pop() as { node: TreeNode, selection: string }; + await this.navigate(node, selection); + } else { + await this.navigate(); + } + } + + getMessage(node: TreeNode): string { + // Default messages based on node type + if (node.type === "challenge") { + return this.getChallengeMessage(node); + } else if (node.message) { + return node.message; + } else if (node.children.find(child => child.type === "challenge")) { + return "Select a challenge"; + } else { + return "Select a category"; + } + } + + getChallengeMessage(node: TreeNode): string { + const { installLocation } = this.userState; + return `${chalk.bold(node.label)} +${node.message} +${node.completed ? ` +๐Ÿ† Challenge Completed` : node.installed ? ` +Open up the challenge in your favorite code editor and follow the instructions in the README: + +๐Ÿ“‚ Challenge Location: ${installLocation}/${node.name}` : ""} +`; + } + + buildTree(): TreeNode { + const userChallenges = this.userState?.challenges || []; + const tree: TreeNode[] = []; + const tags = this.challenges.reduce((acc: string[], challenge: IChallenge) => { + return Array.from(new Set(acc.concat(challenge.tags))); + }, []); + + for (let tag of tags) { + const filteredChallenges = this.challenges.filter((challenge: IChallenge) => challenge.tags.includes(tag) && challenge.enabled); + let completedCount = 0; + const transformedChallenges = filteredChallenges.map((challenge: IChallenge) => { + const { label, name, level, type, childrenNames, enabled: unlocked, description } = challenge; + const parentName = this.challenges.find((c: IChallenge) => c.childrenNames?.includes(name))?.name; + const completed = userChallenges.find((c: IUserChallenge) => c.challengeName === name)?.status === "success"; + if (completed) { + completedCount++; + } + + return { label, name, level, type, actions: this.getChallengeActions(challenge as unknown as TreeNode), completed, installed: this.challengeIsInstalled(challenge as unknown as TreeNode), childrenNames, parentName, unlocked, message: description }; + }); + const nestedChallenges = this.recursiveNesting(transformedChallenges); + + tree.push({ + type: "header", + label: `${tag} ${chalk.green(`(${completedCount}/${filteredChallenges.length})`)}`, + name: `${tag.toLowerCase()}`, + children: nestedChallenges, + recursive: true + }); + } + // Remove any categories without challenges + const enabledCategories = tree.filter((category: TreeNode) => category.children.length > 0); + const mainMenu: TreeNode = { + label: "Main Menu", + name: "main-menu", + type: "header", + children: enabledCategories, + }; + + return mainMenu; + } + + getChoicesAndActions(node: TreeNode): { choices: { name: string, value: string }[], actions: Actions } { + const choices: { name: string, value: string }[] = []; + let actions: Actions = {}; + + if (!node.recursive) { + if (node.type !== "challenge") { + choices.push(...node.children.map(child => ({ name: this.getNodeLabel(child), value: child.label }))); + for (const child of node.children) { + + actions[child.label] = () => this.navigate(child); + } + if (node.children.length === 0) { + choices.push({ name: "Back", value: "back" }); + actions["Back"] = () => this.goBack(); + } + } else { + actions = node.actions as Actions; + choices.push(...Object.keys(node.actions as Actions).map(action => ({ name: action, value: action }))); + } + return { choices, actions }; + } + + const getChoicesAndActionsRecursive = (node: TreeNode, isLast: boolean = false, depth: string = "") => { + if (node.type !== "header") { + if (!isLast) { + depth += "โ”œโ”€"; + } else { + depth += "โ””โ”€"; + } + } + choices.push({ name: this.getNodeLabel(node, depth), value: node.label }); + actions[node.label] = () => this.navigate(node); + // Replace characters in the continuing pattern + if (depth.length) { + depth = depth.replace(/โ”œโ”€/g, "โ”‚ "); + depth = depth.replace(/โ””โ”€/g, " "); + } + // Add spaces so that the labels are spaced out + const depthDivisor = node.type === "header" ? 5 : 2; + depth += Array(Math.floor(node.label.length / depthDivisor)).fill(" ").join(""); + node.children.forEach((child, i, siblings) => getChoicesAndActionsRecursive(child, i === siblings.length - 1, depth)); + }; + + node.children.forEach((child, i, siblings) => getChoicesAndActionsRecursive(child, i === siblings.length - 1)); + + return { choices, actions }; + } + + getNodeLabel(node: TreeNode, depth: string = ""): string { + const { label, type, completed, unlocked } = node; + const isHeader = type === "header"; + const isChallenge = type === "challenge"; + const isQuiz = type === "quiz"; + const isCapstoneProject = type === "capstone-project"; + + + if (isHeader) { + return `${depth}${chalk.blue(label)}`; + } else if (!unlocked) { + return `${depth}${chalk.dim(chalk.dim(label))}`; + } else if (isChallenge) { + return `${depth}${label} ${completed ? "๐Ÿ†" : ""}`; + } else if (isQuiz) { + return `${depth}${label} ๐Ÿ“œ`; + } else if (isCapstoneProject) { + return `${depth}${label} ๐Ÿ’ป`; + } else { + return `${depth}${label}`; + } + } + + findNode(globalTree: TreeNode, name: string): TreeNode | undefined { + // Descend the tree until the node is found + if (globalTree.name === name) { + return globalTree; + } + for (const child of globalTree.children) { + const node = this.findNode(child, name); + if (node) { + return node; + } + } + } + + recursiveNesting(challenges: any[], parentName: string | undefined = undefined): TreeNode[] { + const tree: TreeNode[] = []; + for (let challenge of challenges) { + if (challenge.parentName === parentName) { + // Recursively call recursiveNesting for each child + challenge.children = this.recursiveNesting(challenges, challenge.name); + tree.push(challenge); + } + } + return tree; + } + + challengeIsInstalled(challenge: TreeNode): boolean { + const { installLocation } = this.userState; + const targetDir = `${installLocation}/${challenge.name}`; + return existsSync(targetDir); + } + + getChallengeActions(challenge: TreeNode): Actions { + const actions: Actions = {}; + const { address, installLocation } = this.userState; + const { type, name } = challenge; + if (!this.challengeIsInstalled(challenge)) { + actions["Setup Challenge Repository"] = async () => { + this.clearView(); + await setupChallenge(name, installLocation); + // Rebuild the tree + this.globalTree = this.buildTree(); + // Wait for enter key + await this.pressEnterToContinue(); + this.history.pop(); // Remove the old node from history since it has different actions + // Return to challenge menu + const challengeNode = this.findNode(this.globalTree, name) as TreeNode; + await this.navigate(challengeNode); + }; + } else { + actions["Reset Challenge"] = async () => { + this.clearView(); + const targetDir = `${installLocation}/${name}`; + console.log(`Removing ${targetDir}...`); + rmSync(targetDir, { recursive: true, force: true }); + console.log(`Installing fresh copy of challenge...`); + await setupChallenge(name, installLocation); + this.globalTree = this.buildTree(); + await this.pressEnterToContinue(); + this.history.pop(); // Remove the old node from history since it has different actions + // Return to challenge menu + const challengeNode = this.findNode(this.globalTree, name) as TreeNode; + await this.navigate(challengeNode); + }; + actions["Submit Completed Challenge"] = async () => { + this.clearView(); + // Submit the challenge + await submitChallenge(name); + // Fetch users challenge state from the server + const newUserState = await getUser(address); + this.userState.challenges = newUserState.challenges; + // Save the new user state locally + await saveUserState(this.userState); + // Rebuild the tree + this.globalTree = this.buildTree(); + // Wait for enter key + await this.pressEnterToContinue(); + this.history.pop(); // Remove the old node from history since it has different actions + // Return to challenge menu + const challengeNode = this.findNode(this.globalTree, name) as TreeNode; + await this.navigate(challengeNode); + }; + } + return actions; + }; + + async pressEnterToContinue(customMessage?: string) { + await confirm({ + message: typeof customMessage === "string" ? customMessage : 'Press Enter to continue...', + theme: { + prefix: "", + } + }); + } + + private clearView(): void { + process.stdout.moveCursor(0, this.getMaxViewHeight()); + console.clear(); + } + + private printMenu(): void { + const border = chalk.blue("โ”€"); + const borderLeft = chalk.blue("โ—โ”€"); + const borderRight = chalk.blue("โ”€โ—"); + const currentViewName = this.nodeLabel || "Main Menu"; + const user = this.userState.ens || this.userState.address; + const completedChallenges = this.userState.challenges + .filter(c => c.status === "success") + .map(c => ({ + challenge: this.challenges.find(ch => ch.name === c.challengeName), + completion: c + })) + .filter(c => c.challenge); + const points = calculatePoints(completedChallenges); + + + const width = process.stdout.columns; + const userInfo = `${chalk.green(user)} ${chalk.yellow(`(${points} points)`)}`; + const topMenuText = chalk.bold(`${borderLeft}${currentViewName}${new Array(width - (this.stripAnsi(currentViewName).length + this.stripAnsi(userInfo).length + 4)).fill(border).join('')}${userInfo}${borderRight}`); + const bottomMenuText = chalk.bold(`${borderLeft}${chalk.bgBlue(``)} to quit | ${chalk.bgBlue(``)} to go back | ${chalk.bgBlue(`

`)} view progress${new Array(width - 54).fill(border).join('')}${borderRight}`); + + // Save cursor position + process.stdout.write('\x1B7'); + + // Hide cursor while we work + process.stdout.write('\x1B[?25l'); + + // Print at top + process.stdout.cursorTo(0, 0); + process.stdout.clearLine(0); + process.stdout.write(topMenuText); + + // Print at bottom + process.stdout.cursorTo(0, this.getMaxViewHeight()); + process.stdout.clearLine(0); + process.stdout.write(bottomMenuText); + + // Move cursor to line 1 (just below the top menu) + process.stdout.cursorTo(0, 1); + + // Show cursor again + process.stdout.write('\x1B[?25h'); + } + + stripAnsi(text: string): string { + return text.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, ''); + } + + getMaxViewHeight(): number { + const maxRows = 20; + if (process.stdout.rows < maxRows) { + return process.stdout.rows; + } + return maxRows; + } + + quit(): void { + this.clearView(); + process.exit(0); + } + + printHelp(): void { + this.clearView(); + console.log("Help"); + } + + async printProgress(): Promise { + const progressView = new ProgressView(this.userState, this.challenges); + const progressTree = progressView.buildProgressTree(); + await this.navigate(progressTree); + } +} \ No newline at end of file diff --git a/src/tasks/parse-command-arguments-and-options.ts b/src/tasks/parse-command-arguments-and-options.ts index 5e1bab0..b70de80 100644 --- a/src/tasks/parse-command-arguments-and-options.ts +++ b/src/tasks/parse-command-arguments-and-options.ts @@ -1,7 +1,7 @@ import arg from "arg"; import { IUser } from "../types"; import fs from "fs"; -import inquirer from "inquirer"; +import { select, input } from "@inquirer/prompts"; import { isValidAddress } from "../utils/helpers"; import { promptForMissingUserState } from "./prompt-for-missing-user-state"; @@ -126,7 +126,11 @@ export async function promptForMissingCommandArgs(commands: CommandOptions, user }); } } - const answers = await inquirer.prompt(questions, cliAnswers); + const answers = []; + for (const question of questions) { + const answer = await input(question); + answers.push(answer); + } return { ...commands, diff --git a/src/tasks/prompt-for-missing-user-state.ts b/src/tasks/prompt-for-missing-user-state.ts index bc04702..9ac06c5 100644 --- a/src/tasks/prompt-for-missing-user-state.ts +++ b/src/tasks/prompt-for-missing-user-state.ts @@ -1,7 +1,7 @@ import { getUser, upsertUser } from "../modules/api"; import { IUser } from "../types"; -import inquirer from "inquirer"; -import { saveUserState } from "../utils/stateManager"; +import { input } from "@inquirer/prompts"; +import { saveUserState } from "../utils/state-manager"; import { isValidAddressOrENS, getDevice, checkValidPathOrCreate, isValidAddress } from "../utils/helpers"; // default values for unspecified args @@ -16,14 +16,12 @@ export async function promptForMissingUserState( let identifier = userState.address; if (!userState.address) { - const answer = await inquirer.prompt({ - type: "input", - name: "identifier", + const answer = await input({ message: "Your wallet address (or ENS):", validate: isValidAddressOrENS, }); - identifier = answer.identifier; + identifier = answer; } // Fetch the user data from the server - also handles ens resolution @@ -42,16 +40,14 @@ export async function promptForMissingUserState( // Prompt for install location if it doesn't exist on device if (!existingInstallLocation) { - const answer = await inquirer.prompt({ - type: "input", - name: "installLocation", + const answer = await input({ message: "Where would you like to download the challenges?", default: defaultOptions.installLocation, validate: checkValidPathOrCreate, }); // Create (or update) the user with their preferred install location for this device - user.location = answer.installLocation; + user.location = answer; user.device = userDevice; user = await upsertUser(user); } diff --git a/src/tasks/render-intro-message.ts b/src/tasks/render-intro-message.ts index 25cc577..cefc5d1 100644 --- a/src/tasks/render-intro-message.ts +++ b/src/tasks/render-intro-message.ts @@ -1,34 +1,63 @@ import chalk from "chalk"; import { wait } from "../utils/helpers"; +const MAX_VIEW_HEIGHT = 20; // Match the max height from index.ts + export async function renderIntroMessage() { + await checkTerminalSize(); console.clear(); - console.log(TITLE_TEXT); + const trimmedText = getTrimmedTitleText(); + console.log(trimmedText); await wait(1500); console.clear(); } -export const TITLE_TEXT = ` -${chalk.green(`โ—โ”€โ”€โ”ฌโ”€โ”€โ—โ”€โ”€โ”ฌโ”€โ”€โ—โ”€โ”€โ”ฌโ”€โ”€โ—โ”€โ”€โ”ฌโ”€โ”€โ—โ”€โ”€โ”ฌโ”€โ”€โ—โ”€โ”€โ”ฌโ”€โ”€โ—โ”€โ”€โ”ฌโ”€โ”€โ—โ”€โ”€โ”ฌโ”€โ”€โ—โ”€โ”€โ”ฌโ”€โ”€โ—โ”€โ”€โ”ฌโ”€โ”€โ—โ”€โ”€โ”ฌโ”€โ”€โ—โ”€โ”€โ”ฌโ”€โ”€โ—`)} -${chalk.green(` โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ””โ”€โ”€โ— โ”‚ โ”‚ โ”‚ โ”‚ โ””โ”€โ”€โ—`)} -${chalk.green(` โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚`)} -${chalk.green(` โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ””โ”€โ”€โ—โ”€โ”€โ”ฌโ”€โ”€โ— โ”‚ โ”‚ โ”‚ โ””โ”€โ”€โ—โ”€โ”€โ”ฌโ”€โ”€โ—`)} -${chalk.green(` โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ””โ”€โ”€โ— โ”‚ โ”‚ โ”‚ โ”‚`)} -${chalk.green(` โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ””โ”€โ”€โ—`)} -${chalk.green(` โ”‚ โ”‚ โ”‚ โ”‚ โ””โ”€โ”€โ—โ”€โ”€โ”ฌโ”€โ”€โ—โ”€โ”€โ”ฌโ”€โ”€โ— โ”‚ โ”‚ โ””โ”€โ”€โ—โ”€โ”€โ”ฌโ”€โ”€โ—โ”€โ”€โ”ฌโ”€โ”€โ—`)} -${chalk.green(` โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ””โ”€โ”€โ— โ”‚ โ””โ”€โ”€โ—โ”€โ”€โ”ฌโ”€โ”€โ— โ”‚ โ””โ”€โ”€โ—`)} -${chalk.green(` โ”‚ โ”‚ โ”‚ โ”‚ โ””โ”€โ”€โ— โ”‚ โ””โ”€โ”€โ— โ””โ”€โ”€โ—`)} -${chalk.green(` โ”‚${chalk.blue("โ–—โ–„โ–„โ–„โ––โ–—โ–„โ–„โ–„โ––โ–—โ–– โ–—โ––")} โ”‚ ${chalk.blue("โ–—โ–„โ–„โ–„โ––โ–—โ–„โ–„โ–„โ–– โ–—โ–„โ–„โ––โ–—โ–– โ–—โ––")} โ”‚ ${chalk.blue("โ–—โ–„โ–„โ–„โ––โ–—โ–„โ–„โ–– โ–—โ–„โ–„โ–„โ––โ–—โ–„โ–„โ–„โ––")}โ”€โ”€โ”ฌโ”€โ”€โ—`)} -${chalk.green(` โ”‚${chalk.blue("โ–โ–Œ โ–ˆ โ–โ–Œ โ–โ–Œ")} โ”‚ ${chalk.blue(" โ–ˆ โ–โ–Œ โ–โ–Œ โ–โ–Œ โ–โ–Œ")} โ”‚ ${chalk.blue(" โ–ˆ โ–โ–Œ โ–โ–Œโ–โ–Œ โ–โ–Œ ")} โ””โ”€โ”€โ—`)} -${chalk.green(` โ”‚${chalk.blue("โ–โ–›โ–€โ–€โ–˜ โ–ˆ โ–โ–›โ–€โ–œโ–Œ")} โ”‚ ${chalk.blue(" โ–ˆ โ–โ–›โ–€โ–€โ–˜โ–โ–Œ โ–โ–›โ–€โ–œโ–Œ")} โ”‚ ${chalk.blue(" โ–ˆ โ–โ–›โ–€โ–šโ––โ–โ–›โ–€โ–€โ–˜โ–โ–›โ–€โ–€โ–˜")}โ”€โ”€โ”ฌโ”€โ”€โ—`)} -${chalk.green(` โ”‚${chalk.blue("โ–โ–™โ–„โ–„โ–– โ–ˆ โ–โ–Œ โ–โ–Œ")} โ”‚ ${chalk.blue(" โ–ˆ โ–โ–™โ–„โ–„โ––โ–โ–šโ–„โ–„โ––โ–โ–Œ โ–โ–Œ")} โ”‚ ${chalk.blue(" โ–ˆ โ–โ–Œ โ–โ–Œโ–โ–™โ–„โ–„โ––โ–โ–™โ–„โ–„โ––")} โ””โ”€โ”€โ—`)} -${chalk.green(` โ”‚ โ”‚ โ”‚ โ””โ”€โ”€โ— โ”‚`)} -${chalk.green(` โ”‚ โ”‚ โ””โ”€โ”€โ—โ”€โ”€โ”ฌโ”€โ”€โ—โ”€โ”€โ”ฌโ”€โ”€โ—โ”€โ”€โ”ฌโ”€โ”€โ—โ”€โ”€โ”ฌโ”€โ”€โ— โ””โ”€โ”€โ”€โ—โ”€โ”ฌโ”€โ”€โ—โ”€โ”€โ”ฌโ”€โ”€โ—โ”€โ”€โ”ฌโ”€โ”€โ—โ”€โ”€โ”ฌโ”€โ”€โ—`)} -${chalk.green(` โ”‚ โ””โ”€โ”€โ—โ”€โ”€โ”ฌโ”€โ”€โ— โ””โ”€โ”€โ— โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ””โ”€โ”€โ—`)} -${chalk.green(` โ”‚ โ””โ”€โ”€โ—โ”€โ”€โ”ฌโ”€โ”€โ— โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ””โ”€โ”€โ—`)} -${chalk.green(` โ”‚ โ”‚ โ”‚ โ”‚ โ””โ”€โ”€โ—โ”€โ”€โ”ฌโ”€โ”€โ— โ”‚ โ””โ”€โ”€โ—โ”€โ”€โ”ฌโ”€โ”€โ—`)} -${chalk.green(` โ””โ”€โ”€โ—โ”€โ”€โ”ฌโ”€โ”€โ—โ”€โ”€โ”ฌโ”€โ”€โ— โ””โ”€โ”€โ— โ”‚ โ”‚ โ”‚ โ”‚ โ””โ”€โ”€โ—โ”€โ”€โ”ฌโ”€โ”€โ—`)} -${chalk.green(` โ”‚ โ”‚ โ”‚ โ”‚ โ””โ”€โ”€โ— โ”‚ โ”‚`)} -${chalk.green(` โ””โ”€โ”€โ— โ””โ”€โ”€โ— โ”‚ โ”‚ โ””โ”€โ”€โ—โ”€โ”€โ”ฌโ”€โ”€โ—โ”€โ”€โ”ฌโ”€โ”€โ— โ””โ”€โ”€โ—`)} -${chalk.green(` โ”‚ โ”‚ โ”‚ โ””โ”€โ”€โ—`)} -`; \ No newline at end of file +function getTrimmedTitleText(): string { + const lines = TITLE_TEXT.split('\n').filter(line => line.length > 0); + const { columns } = process.stdout; + + // Calculate the width of the longest line (without ANSI escape codes) + const maxLineWidth = Math.max(...lines.map(line => + line.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '').length + )); + + // Calculate horizontal padding + const horizontalPadding = Math.max(0, Math.floor((columns - maxLineWidth) / 2)); + + // Calculate vertical padding within MAX_VIEW_HEIGHT + const verticalPadding = Math.max(0, Math.floor((MAX_VIEW_HEIGHT - lines.length) / 2)); + + // Add horizontal padding to each line + const centeredLines = lines.map(line => ' '.repeat(horizontalPadding) + line); + + // Add vertical padding + const verticallyPaddedLines = [ + ...Array(verticalPadding).fill(''), + ...centeredLines, + ...Array(verticalPadding).fill('') + ]; + + return verticallyPaddedLines.join('\n'); +} + +async function checkTerminalSize(): Promise { + const minRows = 16; + const minCols = 80; + const { rows, columns } = process.stdout; + + if (rows < minRows || columns < minCols) { + console.clear(); + console.error(`Terminal window too small. Minimum size required: ${minCols}x${minRows}`); + console.error(`Current size: ${columns}x${rows}`); + console.log("Please resize your terminal window and try again."); + await wait(5000); + process.exit(1); + } +} + +export const TITLE_TEXT = `${chalk.green('โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—')} +${chalk.green('โ•‘')}${chalk.blue(' โ–—โ–„โ–„โ–„โ––โ–—โ–„โ–„โ–„โ––โ–—โ–– โ–—โ–– โ–—โ–„โ–„โ–„โ––โ–—โ–„โ–„โ–„โ–– โ–—โ–„โ–„โ––โ–—โ–– โ–—โ–– โ–—โ–„โ–„โ–„โ––โ–—โ–„โ–„โ–– โ–—โ–„โ–„โ–„โ––โ–—โ–„โ–„โ–„โ–– ')}${chalk.green('โ•‘')} +${chalk.green('โ•‘')}${chalk.blue(' โ–โ–Œ โ–ˆ โ–โ–Œ โ–โ–Œ โ–ˆ โ–โ–Œ โ–โ–Œ โ–โ–Œ โ–โ–Œ โ–ˆ โ–โ–Œ โ–โ–Œโ–โ–Œ โ–โ–Œ ')}${chalk.green('โ•‘')} +${chalk.green('โ•‘')}${chalk.blue(' โ–โ–›โ–€โ–€โ–˜ โ–ˆ โ–โ–›โ–€โ–œโ–Œ โ–ˆ โ–โ–›โ–€โ–€โ–˜โ–โ–Œ โ–โ–›โ–€โ–œโ–Œ โ–ˆ โ–โ–›โ–€โ–šโ––โ–โ–›โ–€โ–€โ–˜โ–โ–›โ–€โ–€โ–˜ ')}${chalk.green('โ•‘')} +${chalk.green('โ•‘')}${chalk.blue(' โ–โ–™โ–„โ–„โ–– โ–ˆ โ–โ–Œ โ–โ–Œ โ–ˆ โ–โ–™โ–„โ–„โ––โ–โ–šโ–„โ–„โ––โ–โ–Œ โ–โ–Œ โ–ˆ โ–โ–Œ โ–โ–Œโ–โ–™โ–„โ–„โ––โ–โ–™โ–„โ–„โ–– ')}${chalk.green('โ•‘')} +${chalk.green('โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•')}`; \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index cf84cd7..a9b1ee6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -41,4 +41,23 @@ export interface IUser { installLocation: string; creationTimestamp: number; challenges: IUserChallenge[]; +} + +export type Actions = { + [label: string]: () => Promise; +} + +export type TreeNode = { + label: string; + name: string; + children: TreeNode[]; + type: "header" | "challenge" | "quiz" | "capstone-project"; + completed?: boolean; + installed?: boolean; + level?: number; + unlocked?: boolean; + actions?: Actions; + repo?: string; + message?: string; + recursive?: boolean; } \ No newline at end of file diff --git a/src/utils/global-context-select-list.ts b/src/utils/global-context-select-list.ts new file mode 100644 index 0000000..f7a8d9e --- /dev/null +++ b/src/utils/global-context-select-list.ts @@ -0,0 +1,188 @@ +import { + createPrompt, + useState, + useKeypress, + usePrefix, + usePagination, + useMemo, + isEnterKey, + isUpKey, + isDownKey, + Separator, + ValidationError, + makeTheme, + type Theme, +} from '@inquirer/core'; +import type { PartialDeep } from '@inquirer/type'; +import chalk from 'chalk'; + +type SelectTheme = { + icon: { cursor: string }; + style: { disabled: (text: string) => string }; +}; + +const selectTheme: SelectTheme = { + icon: { cursor: 'โฏ' }, + style: { disabled: (text: string) => chalk.dim(`- ${text}`) }, +}; + +type GlobalChoice = { + value: Value; + key: string; +} + +type Choice = { + value: Value; + name?: string; + description?: string; + disabled?: boolean | string; + type?: never; +}; + +type GlobalChoiceSelectConfig = { + message: string; + globalChoices: ReadonlyArray>; + choices: ReadonlyArray | Separator>; + pageSize?: number; + loop?: boolean; + default?: unknown; + theme?: PartialDeep>; +}; + +type GlobalChoiceSelectResult = { + answer: Value; +} + +type Item = Separator | Choice; + +function isSelectable(item: Item): item is Choice { + return !Separator.isSeparator(item) && !item.disabled; +} + +export default createPrompt( + ( + config: GlobalChoiceSelectConfig, + done: (result: GlobalChoiceSelectResult) => void + ): string => { + const { choices: items, loop = true, pageSize = 7 } = config; + const theme = makeTheme(selectTheme, config.theme); + const prefix = usePrefix({ theme }); + const [status, setStatus] = useState('pending'); + + const bounds = useMemo(() => { + const first = items.findIndex(isSelectable); + const last = items.findLastIndex(isSelectable); + + if (first < 0) { + throw new ValidationError( + '[select prompt] No selectable choices. All choices are disabled.', + ); + } + + return { first, last }; + }, [items]); + + const defaultItemIndex = useMemo(() => { + if (!('default' in config)) return -1; + return items.findIndex( + (item) => isSelectable(item) && item.value === config.default, + ); + }, [config.default, items]); + + const [active, setActive] = useState( + defaultItemIndex === -1 ? bounds.first : defaultItemIndex, + ); + + // Safe to assume the cursor position always point to a Choice. + const selectedChoice = items[active] as Choice; + + useKeypress((key, rl) => { + // Check for global choices first + const globalChoice = config.globalChoices.find(choice => { + if (!!choice.key.length) { + return choice.key.includes(key.name); + } else if (choice.key.includes(",")) { + return choice.key.split(",").includes(key.name); + } else if (choice.key.includes("ctrl+")) { + return key.ctrl && key.name === choice.key.split("+")[1]; + } + return choice.key === key.name; + }); + + if (globalChoice !== undefined) { + setStatus('done'); + done({ + answer: globalChoice.value, + }); + return; + } + + // Then check for visible choices + if (isEnterKey(key)) { + setStatus('done'); + done({ + answer: selectedChoice.value + }); + } else if (isUpKey(key) || isDownKey(key)) { + rl.clearLine(0); + if ( + loop || + (isUpKey(key) && active !== bounds.first) || + (isDownKey(key) && active !== bounds.last) + ) { + const offset = isUpKey(key) ? -1 : 1; + let next = active; + do { + next = (next + offset + items.length) % items.length; + } while (!isSelectable(items[next]!)); + setActive(next); + } + } + }); + + const message = theme.style.message(config.message, status); + + const page = usePagination>({ + items, + active, + renderItem({ item, isActive }: { item: Item; isActive: boolean }) { + if (Separator.isSeparator(item)) { + return ` ${item.separator}`; + } + + const line = item.name || item.value; + if (item.disabled) { + const disabledLabel = + typeof item.disabled === 'string' ? item.disabled : '(disabled)'; + return theme.style.disabled(`${line} ${disabledLabel}`); + } + + const color = isActive ? theme.style.highlight : (x: string) => x; + const cursor = isActive ? theme.icon.cursor : ` `; + return color(`${cursor} ${line}`); + }, + pageSize, + loop, + theme, + }); + + if (status === 'done') { + if (selectedChoice) { + const answer = + selectedChoice.name || + String(selectedChoice.value); + + return `${prefix} ${message} ${theme.style.answer(answer)}`; + } else { + // Hidden action was triggered + return `${prefix} ${message}`; + } + } + + const choiceDescription = selectedChoice?.description + ? `\n${selectedChoice.description}` + : ``; + + return `${[prefix, message].filter(Boolean).join(' ')}\n${page}${choiceDescription}${'\x1B[?25l'}`; + }, +); \ No newline at end of file diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index afad31c..5133356 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -1,19 +1,11 @@ -import inquirer from "inquirer"; import os from "os"; import fs from "fs"; +import { IChallenge } from "../types"; export function wait(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } -export async function pressEnterToContinue(customMessage?: string) { - await inquirer.prompt({ - name: 'continue', - type: 'input', - message: customMessage || 'Press Enter to continue...', - }); -} - export const checkValidPathOrCreate = async (path: string) => { try { const exists = fs.lstatSync(path).isDirectory(); @@ -47,4 +39,14 @@ export const getDevice = (): string => { const platform = os.platform(); const arch = os.arch(); return `${hostname}(${platform}:${arch})`; +} + +export const calculatePoints = (completedChallenges: Array<{ challenge: IChallenge | undefined, completion: any }>): number => { + const pointsPerLevel = [100, 150, 225, 300, 400, 500]; + return completedChallenges + .filter(c => c.challenge) + .reduce((total, { challenge }) => { + const points = pointsPerLevel[challenge!.level - 1] || 100; + return total + points; + }, 0); } \ No newline at end of file diff --git a/src/utils/progress-view.ts b/src/utils/progress-view.ts new file mode 100644 index 0000000..95d2753 --- /dev/null +++ b/src/utils/progress-view.ts @@ -0,0 +1,99 @@ +import { IUser, IChallenge, TreeNode } from "../types"; +import chalk from "chalk"; +import { calculatePoints } from "./helpers"; + +export class ProgressView { + constructor( + private userState: IUser, + private challenges: IChallenge[], + ) {} + + buildProgressTree(): TreeNode { + const completedChallenges = this.userState.challenges + .filter(c => c.status === "success") + .map(c => ({ + challenge: this.challenges.find(ch => ch.name === c.challengeName), + completion: c + })) + .filter(c => c.challenge); + + const points = calculatePoints(completedChallenges); + const completionRate = (completedChallenges.length / this.challenges.filter(c => c.enabled).length * 100).toFixed(1); + + // Create completed challenges node with all completed challenges as children + const challengeNodes: TreeNode[] = completedChallenges.map(({ challenge, completion }) => { + const children: TreeNode[] = []; + + // Add gas report node as a child if available + if (completion.gasReport && completion.gasReport.length > 0) { + children.push(this.buildGasReportNode(completion.gasReport)); + } + + return { + type: "header", + label: challenge!.label, + name: challenge!.name, + children, + message: this.buildChallengeMessage(challenge!, completion) + }; + }); + + // Create stats node + const statsNode: TreeNode = { + type: "header", + label: "Progress", + name: "stats", + children: [...challengeNodes], + message: this.buildStatsMessage(points, completionRate) + }; + + return statsNode; + } + + private buildStatsMessage(points: number, completionRate: string): string { + const totalChallenges = this.challenges.filter(c => c.enabled).length; + const completedChallenges = this.userState.challenges.filter(c => c.status === "success").length; + return `${chalk.bold("Your Progress")} + +Address: ${chalk.green(this.userState.ens || this.userState.address)} +${chalk.yellow(`Points Earned: ${points.toLocaleString()}`)} + +Challenges Completed: ${chalk.blue(`${completedChallenges}/${totalChallenges} (${completionRate}%)`)} +${completedChallenges ? "Details:" : ""}`; + } + + private buildChallengeMessage(challenge: IChallenge, completion: any): string { + let message = `${chalk.bold(challenge.label)}\n\n`; + message += `Description: ${challenge.description}\n\n`; + message += `Completion Date: ${chalk.blue(new Date(completion.timestamp).toLocaleString())}\n`; + + if (completion.contractAddress) { + message += `Contract Address: ${chalk.blue(completion.contractAddress)}\n`; + message += `Network: ${chalk.blue(completion.network)}\n`; + } + + return message; + } + + private buildGasReportNode(gasReport: Array<{ functionName: string; gasUsed: number }>): TreeNode { + const gasInfo = gasReport.sort((a, b) => b.gasUsed - a.gasUsed); + const totalGas = gasInfo.reduce((sum, g) => sum + g.gasUsed, 0); + + // Create individual gas entry nodes + const nodes: TreeNode[] = gasInfo.map(({ functionName, gasUsed }) => ({ + type: "header", + label: `${functionName}: ${chalk.yellow(`${gasUsed.toLocaleString()} gas`)}`, + name: `gas-entry-${functionName}`, + children: [], + message: `Function: ${functionName}\nGas Used: ${gasUsed.toLocaleString()} (${((gasUsed / totalGas) * 100).toFixed(1)}% of total)` + })); + + return { + type: "header", + label: "Gas Report", + name: "gas-report", + children: nodes, + message: `Total Gas Used: ${chalk.bold(totalGas.toLocaleString())}\nDetailed breakdown:` + }; + } +} \ No newline at end of file diff --git a/src/utils/stateManager.ts b/src/utils/state-manager.ts similarity index 100% rename from src/utils/stateManager.ts rename to src/utils/state-manager.ts diff --git a/src/utils/tree.ts b/src/utils/tree.ts deleted file mode 100644 index dcd8a54..0000000 --- a/src/utils/tree.ts +++ /dev/null @@ -1,343 +0,0 @@ -import inquirer from "inquirer"; -import chalk from "chalk"; -import { loadChallenges, loadUserState, saveUserState } from "./stateManager"; -import { testChallenge, submitChallenge, setupChallenge } from "../actions"; -import { IChallenge, IUser, IUserChallenge } from "../types"; -import fs from "fs"; -import { pressEnterToContinue } from "./helpers"; -import { getUser } from "../modules/api"; - -type Action = { - label: string; - action: () => Promise; -} - -type TreeNode = { - label: string; - name: string; - children: TreeNode[]; - type: "header" | "challenge" | "quiz" | "capstone-project"; - completed?: boolean; - level?: number; - unlocked?: boolean; - actions?: Action[]; - repo?: string; - message?: string; - recursive?: boolean; -} - -function getNodeLabel(node: TreeNode, depth: string = ""): string { - const { label, level, type, completed, unlocked } = node; - const isHeader = type === "header"; - const isChallenge = type === "challenge"; - const isQuiz = type === "quiz"; - const isCapstoneProject = type === "capstone-project"; - - - if (isHeader) { - return `${depth} ${chalk.blue(label)}`; - } else if (!unlocked) { - return `${depth} ${chalk.dim(label)}`; - } else if (isChallenge) { - return `${depth} ${label} ${completed ? "๐Ÿ†" : ""}`; - } else if (isQuiz) { - return `${depth} ${label} ๐Ÿ“œ`; - } else if (isCapstoneProject) { - return`${depth} ${label} ๐Ÿ’ป`; - } else { - return `${depth} ${label}`; - } -} - -async function selectNode(node: TreeNode): Promise { - console.clear(); - - const header = findHeader(globalTree, node) as TreeNode; - // IF: type === challenge - // Show description of challenge - // Show menu for the following options: - // download repository - Use create-eth to download repository using extensions - // - Show instructions for completing the challenge including a simple command to test their code - // submit project, check if project passes tests then send proof of completion to the BG server, if it passes, mark the challenge as completed - if (node.type !== "header" && !node.unlocked) { - console.log("This challenge doesn't exist yet. ๐Ÿค” Consider contributing to the project here: https://github.com/BuidlGuidl/eth-tech-tree-challenges"); - await pressEnterToContinue(); - console.clear(); - await startVisualization(header); - } else if (node.type === "challenge") { - const backAction: Action = { - label: "โคด๏ธ", - action: async () => { - console.clear(); - await startVisualization(header); - } - } - const actions = [backAction].concat((node.actions as Action[]).map(action => action)); - const choices = actions.map(action => action.label); - const message = `${chalk.red(node.label)} -${node.message} -`; - const actionPrompt = { - type: "list", - name: "selectedAction", - message, - choices, - default: 1 - }; - const { selectedAction } = await inquirer.prompt([actionPrompt]); - const selectedActionIndex = actions.findIndex(action => action.label === selectedAction); - if (selectedActionIndex !== undefined && selectedActionIndex >= 0) { - await actions[selectedActionIndex].action(); - } - } - - // IF: type === reference - // Show link to reference material - // Provide option to mark as completed - - // IF: type === personal-challenge - // Show description of challenge - -} - -let globalTree: TreeNode; - -export async function startVisualization(currentNode?: TreeNode): Promise { - if (!currentNode) { - globalTree = buildTree(); - currentNode = Object.assign({}, globalTree); - } - - function getChoicesAndActions(node: TreeNode): { choices: string[], actions: TreeNode[] } { - const choices: string[] = []; - const actions: TreeNode[] = []; - - if (!node.recursive) { - choices.push(...node.children.map(child => getNodeLabel(child))); - actions.push(...node.children); - return { choices, actions }; - } - - const getChoicesAndActionsRecursive = (node: TreeNode, isLast: boolean = false, depth: string = "") => { - if (node.type !== "header") { - if (!isLast) { - depth += "โ”œโ”€"; - } else { - depth += "โ””โ”€"; - } - } - choices.push(getNodeLabel(node, depth)); - actions.push(node); - // Replace characters in the continuing pattern - if (depth.length) { - depth = depth.replace(/โ”œโ”€/g, "โ”‚ "); - depth = depth.replace(/โ””โ”€/g, " "); - } - // Add spaces so that the labels are spaced out - const depthDivisor = node.type === "header" ? 5 : 2; - depth += Array(Math.floor(node.label.length / depthDivisor)).fill(" ").join(""); - node.children.forEach((child, i, siblings) => getChoicesAndActionsRecursive(child, i === siblings.length - 1, depth)); - }; - - getChoicesAndActionsRecursive(node); - - return { choices, actions }; - } - - const { choices, actions } = getChoicesAndActions(currentNode); - const parent = findParent(globalTree, currentNode) as TreeNode; - let defaultChoice = 0; - // Add a back option if not at the root - if (parent) { - choices.unshift(" โคด๏ธ"); - actions.unshift(parent); - defaultChoice = 1; - } - const directionsPrompt = { - type: "list", - loop: false, - name: "selectedNodeIndex", - message: parent ? "Select a challenge" : "Select a category", - choices, - default: defaultChoice - }; - const answers = await inquirer.prompt([directionsPrompt]); - const selectedIndex = choices.indexOf(answers.selectedNodeIndex); - const selectedNode = actions[selectedIndex]; - await selectNode(selectedNode); - if (selectedNode.type === "header") { - await startVisualization(selectedNode); - } -} - -function findParent(allNodes: TreeNode, targetNode: TreeNode): TreeNode | undefined { - if (allNodes.children.includes(targetNode)) { - return allNodes; - } else { - for (const childNode of allNodes.children) { - const parent = findParent(childNode, targetNode); - if (parent) return parent; - } - return undefined; - } -} - -function findHeader(allNodes: TreeNode, targetNode: TreeNode): TreeNode | undefined { - let parent = findParent(allNodes, targetNode); - while (true) { - if (!parent) { - return allNodes; - } - if (parent?.type === "header") { - return parent; - } - parent = findParent(allNodes, parent); - } -} - -// Nesting Magic - Recursive function to build nested tree structure -function nestingMagic(challenges: any[], parentName: string | undefined = undefined): TreeNode[] { - const tree: TreeNode[] = []; - for (let challenge of challenges) { - if (challenge.parentName === parentName) { - // Recursively call NestingMagic for each child - challenge.children = nestingMagic(challenges, challenge.name); - tree.push(challenge); - } - } - return tree; -} - -export function buildTree(): TreeNode { - const userState = loadUserState(); - const { challenges: userChallenges } = userState; - const tree: TreeNode[] = []; - const challenges = loadChallenges(); - const tags = challenges.reduce((acc: string[], challenge: any) => { - return Array.from(new Set(acc.concat(challenge.tags))); - }, []); - - for (let tag of tags) { - const filteredChallenges = challenges.filter((challenge: IChallenge) => challenge.tags.includes(tag)); - let completedCount = 0; - const transformedChallenges = filteredChallenges.map((challenge: IChallenge) => { - const { label, name, level, type, childrenNames, enabled: unlocked, description } = challenge; - const parentName = challenges.find((c: any) => c.childrenNames?.includes(name))?.name; - const completed = userChallenges.find((c: IUserChallenge) => c.challengeName === name)?.status === "success"; - if (completed) { - completedCount++; - } - // Build selection actions - const actions: Action[] = getActions(userState, challenge); - - return { label, name, level, type, actions, completed, childrenNames, parentName, unlocked, message: description }; - }); - const nestedChallenges = nestingMagic(transformedChallenges); - - const sortedByUnlocked = nestedChallenges.sort((a: TreeNode, b: TreeNode) => {return a.unlocked ? -1 : 1}); - - tree.push({ - type: "header", - label: `${tag} ${chalk.green(`(${completedCount}/${filteredChallenges.length})`)}`, - name: `${tag.toLowerCase()}`, - children: sortedByUnlocked, - recursive: true - }); - } - // Remove any categories without challenges - const enabledCategories = tree.filter((category: TreeNode) => category.children.length > 0); - const mainMenu: TreeNode = { - label: "Main Menu", - name: "main-menu", - type: "header", - children: enabledCategories, - }; - - return mainMenu; -} - -function getActions(userState: IUser, challenge: IChallenge): Action[] { - const actions: Action[] = []; - const { address, installLocation } = userState; - const { type, name } = challenge; - if (type === "challenge") { - const targetDir = `${installLocation}/${name}`; - if (!fs.existsSync(targetDir)) { - actions.push({ - label: "Setup Challenge Repository", - action: async () => { - console.clear(); - await setupChallenge(name, installLocation); - // Rebuild the tree - globalTree = buildTree(); - // Wait for enter key - await pressEnterToContinue(); - // Return to challenge menu - const challengeNode = findNode(globalTree, name) as TreeNode; - await selectNode(challengeNode); - } - }); - } else { - actions.push({ - label: "Test Challenge", - action: async () => { - console.clear(); - await testChallenge(name); - // Wait for enter key - await pressEnterToContinue(); - // Return to challenge menu - const challengeNode = findNode(globalTree, name) as TreeNode; - await selectNode(challengeNode); - } - }); - actions.push({ - label: "Submit Completed Challenge", - action: async () => { - console.clear(); - // Submit the challenge - await submitChallenge(name); - // Fetch users challenge state from the server - const newUserState = await getUser(address); - userState.challenges = newUserState.challenges; - // Save the new user state locally - await saveUserState(userState); - // Rebuild the tree - globalTree = buildTree(); - // Wait for enter key - await pressEnterToContinue(); - // Return to challenge menu - const challengeNode = findNode(globalTree, name) as TreeNode; - await selectNode(challengeNode); - } - }); - } - } else if (type === "quiz") { - actions.push({ - label: "Mark as Read", - action: async () => { - console.log("Marking as read..."); - } - }); - } else if (type === "capstone-project") { - actions.push({ - label: "Submit Project", - action: async () => { - console.log("Submitting project..."); - } - }); - } - return actions; -}; - -function findNode(globalTree: TreeNode, name: string): TreeNode | undefined { - // Descend the tree until the node is found - if (globalTree.name === name) { - return globalTree; - } - for (const child of globalTree.children) { - const node = findNode(child, name); - if (node) { - return node; - } - } -} diff --git a/yarn.lock b/yarn.lock index 8609896..1fa55e3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -299,6 +299,191 @@ __metadata: languageName: node linkType: hard +"@inquirer/checkbox@npm:^4.0.2": + version: 4.0.2 + resolution: "@inquirer/checkbox@npm:4.0.2" + dependencies: + "@inquirer/core": ^10.1.0 + "@inquirer/figures": ^1.0.8 + "@inquirer/type": ^3.0.1 + ansi-escapes: ^4.3.2 + yoctocolors-cjs: ^2.1.2 + peerDependencies: + "@types/node": ">=18" + checksum: 3c7d125346e44a74303b4df6a6756325a196ce1e540c1612bbd899a6fcba24ad9999390bcf5a524a37fe8034d24963cb110b7a66dfadd33ef0c3c8d8d8eff6be + languageName: node + linkType: hard + +"@inquirer/confirm@npm:^5.0.2": + version: 5.0.2 + resolution: "@inquirer/confirm@npm:5.0.2" + dependencies: + "@inquirer/core": ^10.1.0 + "@inquirer/type": ^3.0.1 + peerDependencies: + "@types/node": ">=18" + checksum: 4e775b80b689adeb0b2852ed79b368ef23a82fe3d5f580a562f4af7cdf002a19e0ec1b3b95acc6d49427a72c0fcb5b6548e0cdcafe2f0d3f3d6a923e04aabd0c + languageName: node + linkType: hard + +"@inquirer/core@npm:^10.1.0": + version: 10.1.0 + resolution: "@inquirer/core@npm:10.1.0" + dependencies: + "@inquirer/figures": ^1.0.8 + "@inquirer/type": ^3.0.1 + ansi-escapes: ^4.3.2 + cli-width: ^4.1.0 + mute-stream: ^2.0.0 + signal-exit: ^4.1.0 + strip-ansi: ^6.0.1 + wrap-ansi: ^6.2.0 + yoctocolors-cjs: ^2.1.2 + checksum: c52be9ef04497a2b82ed6b1258ebd24ad0950b4b83a96e6fbde1a801eeced4e4b32ed5b2217eac98e504cc1d16ddc8d9d39243c96bdb5390ff13629b28c96591 + languageName: node + linkType: hard + +"@inquirer/editor@npm:^4.1.0": + version: 4.1.0 + resolution: "@inquirer/editor@npm:4.1.0" + dependencies: + "@inquirer/core": ^10.1.0 + "@inquirer/type": ^3.0.1 + external-editor: ^3.1.0 + peerDependencies: + "@types/node": ">=18" + checksum: b529b1d6614df4fd2d9336231c7e4bcb4a708a43004e26ff89022976155b2079b8c48f7adf0b9237d9ab5040dbca2e38c8fbcbbd7c02d6837c43eb3d39ab68c9 + languageName: node + linkType: hard + +"@inquirer/expand@npm:^4.0.2": + version: 4.0.2 + resolution: "@inquirer/expand@npm:4.0.2" + dependencies: + "@inquirer/core": ^10.1.0 + "@inquirer/type": ^3.0.1 + yoctocolors-cjs: ^2.1.2 + peerDependencies: + "@types/node": ">=18" + checksum: 9c5f2e01bf12684bddb4ce88c6afbf41bf8b9057ac17565173b2225d691aad0e044be204e21ecedd5cc76907af76ab34678a2d519c890cf0ea3a1a83473faa12 + languageName: node + linkType: hard + +"@inquirer/figures@npm:^1.0.8": + version: 1.0.8 + resolution: "@inquirer/figures@npm:1.0.8" + checksum: 24c5c70f49a5f0e9d38f5552fb6936c258d2fc545f6a4944b17ba357c9ca4a729e8cffd77666971554ebc2a57948cfe5003331271a259c406b3f2de0e9c559b7 + languageName: node + linkType: hard + +"@inquirer/input@npm:^4.0.2": + version: 4.0.2 + resolution: "@inquirer/input@npm:4.0.2" + dependencies: + "@inquirer/core": ^10.1.0 + "@inquirer/type": ^3.0.1 + peerDependencies: + "@types/node": ">=18" + checksum: 06ec535b90ebfc230b616df6f5058fdea9fdd00c9b965448c5d569fea7d76b67e8ad6427e0aa16d2b31c107f0e9a5ef93ac3224da59ff2e11b5fecfa2987e10e + languageName: node + linkType: hard + +"@inquirer/number@npm:^3.0.2": + version: 3.0.2 + resolution: "@inquirer/number@npm:3.0.2" + dependencies: + "@inquirer/core": ^10.1.0 + "@inquirer/type": ^3.0.1 + peerDependencies: + "@types/node": ">=18" + checksum: 51ae9b28607d1932768362cac1690e16db9f1fc8919c1c3c3faff165d035ec87e451a360c8458f3a9599879505641fe1946df6098c534d4439de98825bed0934 + languageName: node + linkType: hard + +"@inquirer/password@npm:^4.0.2": + version: 4.0.2 + resolution: "@inquirer/password@npm:4.0.2" + dependencies: + "@inquirer/core": ^10.1.0 + "@inquirer/type": ^3.0.1 + ansi-escapes: ^4.3.2 + peerDependencies: + "@types/node": ">=18" + checksum: 69dc3986098cdfb4ed73653b690f7db7d88e483fdd9026b7308d1f6ff1208e5a7599f8c49910735e1fd8008fb367a9bf8f0ff80e7551831c6de15733980277af + languageName: node + linkType: hard + +"@inquirer/prompts@npm:^7.1.0": + version: 7.1.0 + resolution: "@inquirer/prompts@npm:7.1.0" + dependencies: + "@inquirer/checkbox": ^4.0.2 + "@inquirer/confirm": ^5.0.2 + "@inquirer/editor": ^4.1.0 + "@inquirer/expand": ^4.0.2 + "@inquirer/input": ^4.0.2 + "@inquirer/number": ^3.0.2 + "@inquirer/password": ^4.0.2 + "@inquirer/rawlist": ^4.0.2 + "@inquirer/search": ^3.0.2 + "@inquirer/select": ^4.0.2 + peerDependencies: + "@types/node": ">=18" + checksum: b44f4b3ce923bb1824be37676aa85a54fe921bc1147ffa57df9a8b83d0b0bc2a9e257109170e216121add82f4d45a4556f37360ede0083f3e7e553fc93b26b77 + languageName: node + linkType: hard + +"@inquirer/rawlist@npm:^4.0.2": + version: 4.0.2 + resolution: "@inquirer/rawlist@npm:4.0.2" + dependencies: + "@inquirer/core": ^10.1.0 + "@inquirer/type": ^3.0.1 + yoctocolors-cjs: ^2.1.2 + peerDependencies: + "@types/node": ">=18" + checksum: 942cd87d217659ecb304c266a8ec186e7de03a523ed2eaac5fe8458ab7b48ac884f1383e41c83231af1b68c1a2e448448234495ab06477ce0900421723c15d1d + languageName: node + linkType: hard + +"@inquirer/search@npm:^3.0.2": + version: 3.0.2 + resolution: "@inquirer/search@npm:3.0.2" + dependencies: + "@inquirer/core": ^10.1.0 + "@inquirer/figures": ^1.0.8 + "@inquirer/type": ^3.0.1 + yoctocolors-cjs: ^2.1.2 + peerDependencies: + "@types/node": ">=18" + checksum: 1fc45dc6ca97a604ebf51739f6cf3c5fe48365cb2dccf5451d43ddf2dc5b661c2a4574c422128485cf53d6a49918cf8ea00f91562f8e4c2358937cddbc882bca + languageName: node + linkType: hard + +"@inquirer/select@npm:^4.0.2": + version: 4.0.2 + resolution: "@inquirer/select@npm:4.0.2" + dependencies: + "@inquirer/core": ^10.1.0 + "@inquirer/figures": ^1.0.8 + "@inquirer/type": ^3.0.1 + ansi-escapes: ^4.3.2 + yoctocolors-cjs: ^2.1.2 + peerDependencies: + "@types/node": ">=18" + checksum: 2d099c64e97453f6124d0d0074dd363280914488d53298dcd4c2b83a38569b20e49b4a260eed5b44d4c929c0fa36283f4063bf8f6755fe9b3bb1d373cecc54e8 + languageName: node + linkType: hard + +"@inquirer/type@npm:^3.0.1": + version: 3.0.1 + resolution: "@inquirer/type@npm:3.0.1" + peerDependencies: + "@types/node": ">=18" + checksum: af412f1e7541d43554b02199ae71a2039a1bff5dc51ceefd87de9ece55b199682733b28810fb4b6cb3ed4a159af4cc4a26d4bb29c58dd127e7d9dbda0797d8e7 + languageName: node + linkType: hard + "@manypkg/find-root@npm:^1.1.0": version: 1.1.0 resolution: "@manypkg/find-root@npm:1.1.0" @@ -454,16 +639,6 @@ __metadata: languageName: node linkType: hard -"@types/inquirer@npm:9.0.3": - version: 9.0.3 - resolution: "@types/inquirer@npm:9.0.3" - dependencies: - "@types/through": "*" - rxjs: ^7.2.0 - checksum: 729a0deefddf95434090d8f6adc120c6de4a023cefd63fb1c3b1d1cfd1abbef4caef87af47589cdbeb16177037e48a0ffc397c39e845d2b7b5dd820eb7b80862 - languageName: node - linkType: hard - "@types/is-ci@npm:^3.0.0": version: 3.0.0 resolution: "@types/is-ci@npm:3.0.0" @@ -547,15 +722,6 @@ __metadata: languageName: node linkType: hard -"@types/through@npm:*": - version: 0.0.30 - resolution: "@types/through@npm:0.0.30" - dependencies: - "@types/node": "*" - checksum: 9578470db0b527c26e246a1220ae9bffc6bf47f20f89c54aac467c083ab1f7e16c00d9a7b4bb6cb4e2dfae465027270827e5908a6236063f6214625e50585d78 - languageName: node - linkType: hard - "@voxpelli/semver-set@npm:^3.0.0": version: 3.0.0 resolution: "@voxpelli/semver-set@npm:3.0.0" @@ -616,6 +782,15 @@ __metadata: languageName: node linkType: hard +"ansi-escapes@npm:^4.3.2": + version: 4.3.2 + resolution: "ansi-escapes@npm:4.3.2" + dependencies: + type-fest: ^0.21.3 + checksum: 93111c42189c0a6bed9cdb4d7f2829548e943827ee8479c74d6e0b22ee127b2a21d3f8b5ca57723b8ef78ce011fbfc2784350eb2bde3ccfccf2f575fa8489815 + languageName: node + linkType: hard + "ansi-escapes@npm:^6.0.0": version: 6.2.0 resolution: "ansi-escapes@npm:6.2.0" @@ -1034,15 +1209,6 @@ __metadata: languageName: node linkType: hard -"cli-cursor@npm:^3.1.0": - version: 3.1.0 - resolution: "cli-cursor@npm:3.1.0" - dependencies: - restore-cursor: ^3.1.0 - checksum: 2692784c6cd2fd85cfdbd11f53aea73a463a6d64a77c3e098b2b4697a20443f430c220629e1ca3b195ea5ac4a97a74c2ee411f3807abf6df2b66211fec0c0a29 - languageName: node - linkType: hard - "cli-cursor@npm:^4.0.0": version: 4.0.0 resolution: "cli-cursor@npm:4.0.0" @@ -1095,6 +1261,13 @@ __metadata: languageName: node linkType: hard +"cli-width@npm:^4.1.0": + version: 4.1.0 + resolution: "cli-width@npm:4.1.0" + checksum: 0a79cff2dbf89ef530bcd54c713703ba94461457b11e5634bd024c78796ed21401e32349c004995954e06f442d82609287e7aabf6a5f02c919a1cf3b9b6854ff + languageName: node + linkType: hard + "cliui@npm:^6.0.0": version: 6.0.0 resolution: "cliui@npm:6.0.0" @@ -1610,8 +1783,8 @@ __metadata: resolution: "eth-tech-tree@workspace:." dependencies: "@changesets/cli": ^2.26.2 + "@inquirer/prompts": ^7.1.0 "@rollup/plugin-typescript": 11.1.0 - "@types/inquirer": 9.0.3 "@types/ncp": 2.0.5 "@types/node": 18.16.0 "@types/terminal-kit": ^2.5.6 @@ -1622,14 +1795,13 @@ __metadata: dotenv: ^16.4.5 execa: 7.1.1 handlebars: ^4.7.7 - inquirer: 9.2.0 - inquirer-tree-prompt: ^1.1.2 listr2: ^8.2.5 merge-packages: ^0.1.6 ncp: ^2.0.0 pkg-install: 1.0.0 rollup: 3.21.0 rollup-plugin-auto-external: 2.0.0 + semver: ^7.6.3 terminal-kit: ^3.1.1 tslib: 2.5.0 typescript: 5.0.4 @@ -1749,15 +1921,6 @@ __metadata: languageName: node linkType: hard -"figures@npm:^3.2.0": - version: 3.2.0 - resolution: "figures@npm:3.2.0" - dependencies: - escape-string-regexp: ^1.0.5 - checksum: 85a6ad29e9aca80b49b817e7c89ecc4716ff14e3779d9835af554db91bac41c0f289c418923519392a1e582b4d10482ad282021330cd045bb7b80c84152f2a2b - languageName: node - linkType: hard - "figures@npm:^5.0.0": version: 5.0.0 resolution: "figures@npm:5.0.0" @@ -2322,19 +2485,6 @@ __metadata: languageName: node linkType: hard -"inquirer-tree-prompt@npm:^1.1.2": - version: 1.1.2 - resolution: "inquirer-tree-prompt@npm:1.1.2" - dependencies: - chalk: ^2.4.2 - cli-cursor: ^3.1.0 - figures: ^3.2.0 - lodash: ^4.17.21 - rxjs: ^6.5.2 - checksum: 06a78f5d5aa0670a030701db632b7161cb70d8f965bb61581debd26e89ed656c6caae381f7b576eb3b924e866aea45f18f901f4f872be0fa45b40051b93f043a - languageName: node - linkType: hard - "inquirer@npm:9.2.0": version: 9.2.0 resolution: "inquirer@npm:9.2.0" @@ -3268,6 +3418,13 @@ __metadata: languageName: node linkType: hard +"mute-stream@npm:^2.0.0": + version: 2.0.0 + resolution: "mute-stream@npm:2.0.0" + checksum: d2e4fd2f5aa342b89b98134a8d899d8ef9b0a6d69274c4af9df46faa2d97aeb1f2ce83d867880d6de63643c52386579b99139801e24e7526c3b9b0a6d1e18d6c + languageName: node + linkType: hard + "ncp@npm:2.0.0, ncp@npm:^2.0.0": version: 2.0.0 resolution: "ncp@npm:2.0.0" @@ -3949,16 +4106,6 @@ __metadata: languageName: node linkType: hard -"restore-cursor@npm:^3.1.0": - version: 3.1.0 - resolution: "restore-cursor@npm:3.1.0" - dependencies: - onetime: ^5.1.0 - signal-exit: ^3.0.2 - checksum: f877dd8741796b909f2a82454ec111afb84eb45890eb49ac947d87991379406b3b83ff9673a46012fca0d7844bb989f45cc5b788254cf1a39b6b5a9659de0630 - languageName: node - linkType: hard - "restore-cursor@npm:^4.0.0": version: 4.0.0 resolution: "restore-cursor@npm:4.0.0" @@ -4055,7 +4202,7 @@ __metadata: languageName: node linkType: hard -"rxjs@npm:^6.3.3, rxjs@npm:^6.5.2": +"rxjs@npm:^6.3.3": version: 6.6.7 resolution: "rxjs@npm:6.6.7" dependencies: @@ -4064,7 +4211,7 @@ __metadata: languageName: node linkType: hard -"rxjs@npm:^7.2.0, rxjs@npm:^7.8.0": +"rxjs@npm:^7.8.0": version: 7.8.0 resolution: "rxjs@npm:7.8.0" dependencies: @@ -4157,6 +4304,15 @@ __metadata: languageName: node linkType: hard +"semver@npm:^7.6.3": + version: 7.6.3 + resolution: "semver@npm:7.6.3" + bin: + semver: bin/semver.js + checksum: 4110ec5d015c9438f322257b1c51fe30276e5f766a3f64c09edd1d7ea7118ecbc3f379f3b69032bacf13116dc7abc4ad8ce0d7e2bd642e26b0d271b56b61a7d8 + languageName: node + linkType: hard + "set-blocking@npm:^2.0.0": version: 2.0.0 resolution: "set-blocking@npm:2.0.0" @@ -4754,6 +4910,13 @@ __metadata: languageName: node linkType: hard +"type-fest@npm:^0.21.3": + version: 0.21.3 + resolution: "type-fest@npm:0.21.3" + checksum: e6b32a3b3877f04339bae01c193b273c62ba7bfc9e325b8703c4ee1b32dc8fe4ef5dfa54bf78265e069f7667d058e360ae0f37be5af9f153b22382cd55a9afe0 + languageName: node + linkType: hard + "type-fest@npm:^0.6.0": version: 0.6.0 resolution: "type-fest@npm:0.6.0" @@ -5155,3 +5318,10 @@ __metadata: checksum: f77b3d8d00310def622123df93d4ee654fc6a0096182af8bd60679ddcdfb3474c56c6c7190817c84a2785648cdee9d721c0154eb45698c62176c322fb46fc700 languageName: node linkType: hard + +"yoctocolors-cjs@npm:^2.1.2": + version: 2.1.2 + resolution: "yoctocolors-cjs@npm:2.1.2" + checksum: 1c474d4b30a8c130e679279c5c2c33a0d48eba9684ffa0252cc64846c121fb56c3f25457fef902edbe1e2d7a7872130073a9fc8e795299d75e13fa3f5f548f1b + languageName: node + linkType: hard