├── .changeset ├── README.md ├── calm-eggs-bathe.md ├── config.json ├── cyan-adults-exercise.md ├── cyan-points-kick.md ├── eight-lizards-listen.md ├── fair-ants-cheer.md ├── fair-parrots-drive.md ├── famous-bananas-turn.md ├── fuzzy-hats-ring.md ├── fuzzy-suits-notice.md ├── gold-ants-hide.md ├── gorgeous-keys-swim.md ├── heavy-apples-appear.md ├── honest-glasses-bathe.md ├── mean-bees-divide.md ├── modern-drinks-visit.md ├── nice-keys-flow.md ├── real-bees-explain.md ├── shy-bats-brake.md ├── slimy-jars-swim.md ├── small-falcons-smash.md ├── thin-years-matter.md ├── tough-papayas-roll.md └── twelve-frogs-behave.md ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .yarn └── releases │ └── yarn-3.5.0.cjs ├── .yarnrc ├── .yarnrc.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENCE ├── README.md ├── bin └── eth-tech-tree.js ├── challenges.json ├── contributors ├── DEVELOPER-GUIDE.md └── RFC-extensions.md ├── example.env ├── package.json ├── rollup.config.js ├── src ├── actions │ ├── index.ts │ ├── remove-storage.ts │ ├── setup-challenge.ts │ ├── submit-challenge.ts │ └── test-challenge.ts ├── cli.ts ├── config.ts ├── index.ts ├── modules │ └── api.ts ├── tasks │ ├── create-first-git-commit.ts │ ├── handle-command.ts │ ├── parse-command-arguments-and-options.ts │ ├── prompt-for-missing-user-state.ts │ └── render-intro-message.ts ├── types.ts └── utils │ ├── constants.ts │ ├── global-context-select-list.ts │ ├── helpers.ts │ ├── leaderboard-view.ts │ ├── progress-view.ts │ └── state-manager.ts ├── tsconfig.json └── yarn.lock /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/calm-eggs-bathe.md: -------------------------------------------------------------------------------- 1 | --- 2 | "eth-tech-tree": patch 3 | --- 4 | 5 | intitial commit changes 6 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.1/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": true, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "cli", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /.changeset/cyan-adults-exercise.md: -------------------------------------------------------------------------------- 1 | --- 2 | "eth-tech-tree": patch 3 | --- 4 | 5 | add new reset command 6 | -------------------------------------------------------------------------------- /.changeset/cyan-points-kick.md: -------------------------------------------------------------------------------- 1 | --- 2 | "eth-tech-tree": patch 3 | --- 4 | 5 | Changing the back end API's URL 6 | -------------------------------------------------------------------------------- /.changeset/eight-lizards-listen.md: -------------------------------------------------------------------------------- 1 | --- 2 | "eth-tech-tree": patch 3 | --- 4 | 5 | increasing the timeout for ncp operation 6 | -------------------------------------------------------------------------------- /.changeset/fair-ants-cheer.md: -------------------------------------------------------------------------------- 1 | --- 2 | "eth-tech-tree": patch 3 | --- 4 | 5 | update commit 6 | -------------------------------------------------------------------------------- /.changeset/fair-parrots-drive.md: -------------------------------------------------------------------------------- 1 | --- 2 | "eth-tech-tree": patch 3 | --- 4 | 5 | sort challenges, grey out challenges that don't exist, other styling changes 6 | -------------------------------------------------------------------------------- /.changeset/famous-bananas-turn.md: -------------------------------------------------------------------------------- 1 | --- 2 | "eth-tech-tree": patch 3 | --- 4 | 5 | Updating release workflow to commit package version changes 6 | -------------------------------------------------------------------------------- /.changeset/fuzzy-hats-ring.md: -------------------------------------------------------------------------------- 1 | --- 2 | "eth-tech-tree": minor 3 | --- 4 | 5 | using create-eth extensions for challenges, bug fixes with coloring of menus and view height 6 | -------------------------------------------------------------------------------- /.changeset/fuzzy-suits-notice.md: -------------------------------------------------------------------------------- 1 | --- 2 | "eth-tech-tree": patch 3 | --- 4 | 5 | added logging to the git commit script 6 | -------------------------------------------------------------------------------- /.changeset/gold-ants-hide.md: -------------------------------------------------------------------------------- 1 | --- 2 | "eth-tech-tree": minor 3 | --- 4 | 5 | Added leaderboard view 6 | -------------------------------------------------------------------------------- /.changeset/gorgeous-keys-swim.md: -------------------------------------------------------------------------------- 1 | --- 2 | "eth-tech-tree": patch 3 | --- 4 | 5 | Major UI overhaul. Everything should still work similarly to last release. 6 | -------------------------------------------------------------------------------- /.changeset/heavy-apples-appear.md: -------------------------------------------------------------------------------- 1 | --- 2 | "eth-tech-tree": patch 3 | --- 4 | 5 | Update validation messages when submitting a completed CA for a challenge, Add searchable list when setting up or submitting with command, few extra bug-fixes/tweaks 6 | -------------------------------------------------------------------------------- /.changeset/honest-glasses-bathe.md: -------------------------------------------------------------------------------- 1 | --- 2 | "eth-tech-tree": patch 3 | --- 4 | 5 | updated colors for readability 6 | -------------------------------------------------------------------------------- /.changeset/mean-bees-divide.md: -------------------------------------------------------------------------------- 1 | --- 2 | "eth-tech-tree": patch 3 | --- 4 | 5 | Readme adjustments, Challenge formats revised 6 | -------------------------------------------------------------------------------- /.changeset/modern-drinks-visit.md: -------------------------------------------------------------------------------- 1 | --- 2 | "eth-tech-tree": patch 3 | --- 4 | 5 | downgrade create-eth version 6 | -------------------------------------------------------------------------------- /.changeset/nice-keys-flow.md: -------------------------------------------------------------------------------- 1 | --- 2 | "eth-tech-tree": patch 3 | --- 4 | 5 | Ask user for confirmation before resetting their challenge 6 | -------------------------------------------------------------------------------- /.changeset/real-bees-explain.md: -------------------------------------------------------------------------------- 1 | --- 2 | "eth-tech-tree": patch 3 | --- 4 | 5 | update lockfile 6 | -------------------------------------------------------------------------------- /.changeset/shy-bats-brake.md: -------------------------------------------------------------------------------- 1 | --- 2 | "eth-tech-tree": patch 3 | --- 4 | 5 | ui improvements 6 | -------------------------------------------------------------------------------- /.changeset/slimy-jars-swim.md: -------------------------------------------------------------------------------- 1 | --- 2 | "eth-tech-tree": patch 3 | --- 4 | 5 | add version flag 6 | -------------------------------------------------------------------------------- /.changeset/small-falcons-smash.md: -------------------------------------------------------------------------------- 1 | --- 2 | "eth-tech-tree": patch 3 | --- 4 | 5 | downgrading create-eth due to bug in latest version 6 | -------------------------------------------------------------------------------- /.changeset/thin-years-matter.md: -------------------------------------------------------------------------------- 1 | --- 2 | "eth-tech-tree": patch 3 | --- 4 | 5 | adjusted forge install script 6 | -------------------------------------------------------------------------------- /.changeset/tough-papayas-roll.md: -------------------------------------------------------------------------------- 1 | --- 2 | "eth-tech-tree": patch 3 | --- 4 | 5 | added better feedback during challenge setup 6 | -------------------------------------------------------------------------------- /.changeset/twelve-frogs-behave.md: -------------------------------------------------------------------------------- 1 | --- 2 | "eth-tech-tree": patch 3 | --- 4 | 5 | Fixed a small bug when showing tree history, Also removed the (Y/n) that showed when prompted with "Press Enter to continue" 6 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - cli 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | jobs: 11 | release: 12 | name: Release 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout Repo 16 | uses: actions/checkout@v3 17 | 18 | - name: Setup Node.js 16.x 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: 16.x 22 | 23 | - name: Install Dependencies 24 | run: yarn 25 | 26 | - name: Create Release Pull Request or Publish to npm 27 | id: changesets 28 | uses: changesets/action@v1 29 | with: 30 | publish: yarn changeset:release 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | 3 | # Dependency directories 4 | node_modules/ 5 | 6 | # yarn 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/sdks 12 | !.yarn/versions 13 | 14 | # macos files 15 | .DS_Store 16 | 17 | # editor files 18 | .vscode/* 19 | 20 | # ETT CLI state files 21 | storage/* 22 | 23 | 24 | .vscode/* 25 | 26 | .env* 27 | temp_* 28 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | --save-exact true -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | enableColors: true 2 | 3 | nodeLinker: node-modules 4 | 5 | yarnPath: .yarn/releases/yarn-3.5.0.cjs 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BuidlGuidl/eth-tech-tree/c0a9a9485c8aa59d658603a1d53cd374ff2d31ea/CHANGELOG.md -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Welcome to Scaffold-ETH 2 Contributing Guide 2 | 3 | Thank you for investing your time in contributing to Scaffold-ETH 2! 4 | 5 | This guide aims to provide an overview of the contribution workflow to help us make the contribution process effective for everyone involved. 6 | 7 | ## About the Project 8 | 9 | Scaffold-ETH 2 is CLI tool to create a minimal repo providing builders with a starter kit to build decentralized applications on Ethereum. 10 | 11 | Read the [README](README.md) to get an overview of the project. 12 | 13 | ### Vision 14 | 15 | The goal of Scaffold-ETH 2 is to provide the primary building blocks for a decentralized application. 16 | 17 | The repo can be forked to include integrations and more features. 18 | 19 | ### Project Status 20 | 21 | The project is under active development. 22 | 23 | You can view the open Issues, follow the development process and contribute to the project. 24 | 25 | ## Getting started 26 | 27 | You can contribute to this repo in many ways: 28 | 29 | - Solve open issues 30 | - Report bugs or feature requests 31 | - Improve the documentation 32 | 33 | Contributions are made via Issues and Pull Requests (PRs). A few general guidelines for contributions: 34 | 35 | - Search for existing Issues and PRs before creating your own. 36 | - Contributions should only fix/add the functionality in the issue OR address style issues, not both. 37 | - If you're running into an error, please give context. Explain what you're trying to do and how to reproduce the error. 38 | - Please use the same formatting in the code repository. You can configure your IDE to do it by using the prettier / linting config files included in each package. 39 | - If applicable, please edit the README.md file to reflect the changes. 40 | 41 | ### Issues 42 | 43 | Issues should be used to report problems, request a new feature, or discuss potential changes before a PR is created. 44 | 45 | #### Solve an issue 46 | 47 | Scan through our [existing issues](https://github.com/scaffold-eth/scaffold-eth-2/issues) to find one that interests you. 48 | 49 | If a contributor is working on the issue, they will be assigned to the individual. If you find an issue to work on, you are welcome to assign it to yourself and open a PR with a fix for it. 50 | 51 | #### Create a new issue 52 | 53 | If a related issue doesn't exist, you can open a new issue. 54 | 55 | Some tips to follow when you are creating an issue: 56 | 57 | - Provide as much context as possible. Over-communicate to give the most details to the reader. 58 | - Include the steps to reproduce the issue or the reason for adding the feature. 59 | - Screenshots, videos etc., are highly appreciated. 60 | 61 | ### Pull Requests 62 | 63 | #### Pull Request Process 64 | 65 | We follow the ["fork-and-pull" Git workflow](https://github.com/susam/gitpr) 66 | 67 | 1. Fork the repo 68 | 2. Clone the project 69 | 3. Create a new branch with a descriptive name 70 | 4. Commit your changes to the new branch 71 | 5. Add [changeset](#changeset) if applicable 72 | 6. Push changes to your fork 73 | 7. Open a PR in our repository and tag one of the maintainers to review your PR 74 | 75 | Here are some tips for a high-quality pull request: 76 | 77 | - Create a title for the PR that accurately defines the work done. 78 | - Structure the description neatly to make it easy to consume by the readers. For example, you can include bullet points and screenshots instead of having one large paragraph. 79 | - Add the link to the issue if applicable. 80 | - Have a good commit message that summarises the work done. 81 | 82 | Once you submit your PR: 83 | 84 | - We may ask questions, request additional information or ask for changes to be made before a PR can be merged. Please note that these are to make the PR clear for everyone involved and aims to create a frictionless interaction process. 85 | - As you update your PR and apply changes, mark each conversation resolved. 86 | 87 | Once the PR is approved, we'll "squash-and-merge" to keep the git commit history clean. 88 | 89 | ### Changeset 90 | 91 | When adding new features or fixing bugs, we'll need to bump the package versions. We use [Changesets](https://github.com/changesets/changesets) to do this. 92 | 93 | > Note: Only changes to the codebase that affect the public API or existing behavior (e.g. bugs) need changesets. 94 | 95 | Each changeset defines the version should be a major/minor/patch release, as well as providing release notes that will be added to the changelog upon release. 96 | 97 | To create a new changeset, run `yarn changeset add`. This will run the Changesets CLI, prompting you for details about the change. You’ll be able to edit the file after it’s created — don’t worry about getting everything perfect up front. 98 | 99 | Since we’re currently in beta, all changes should be marked as a minor/patch release to keep us within the `v0.x` range. 100 | 101 | Even though you can technically use any markdown formatting you like, headings should be avoided since each changeset will ultimately be nested within a bullet list. Instead, bold text should be used as section headings. 102 | 103 | If your PR is making changes to an area that already has a changeset (e.g. there’s an existing changeset covering theme API changes but you’re making further changes to the same API), you should update the existing changeset in your PR rather than creating a new one. 104 | 105 | ## Developer Guide 106 | 107 | You can find a detailed guide on [`contributors/DEVELOPER-GUIDE.md`](contributors/DEVELOPER-GUIDE.md) 108 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 BuidlGuidl 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ETH Development Tech Tree 2 | Test your skills and find some new ones by completing medium to hard Solidity challenges. 3 | 4 | ## Quick Start 5 | Run the following command to use the NPM package 6 | ```bash 7 | npx eth-tech-tree 8 | ``` 9 | 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. 10 | 11 | You can also run individual commands without the tree visualization. 12 | 13 | Set up a challenge: 14 | ```bash 15 | npx eth-tech-tree setup CHALLENGE_NAME INSTALL_LOCATION 16 | ``` 17 | 18 | Submit a challenge: 19 | ```bash 20 | npx eth-tech-tree submit CHALLENGE_NAME CONTRACT_ADDRESS 21 | ``` 22 | 23 | Reset your user state (it will re-prompt you for your address and install location): 24 | ```bash 25 | npx eth-tech-tree reset 26 | ``` 27 | 28 | ## Development 29 | Clone and `cd` into the repo then run this CLI application with the following commands 30 | - `yarn install` 31 | - `yarn build` 32 | - `yarn cli` 33 | 34 | Also consider contributing new challenges here: https://github.com/BuidlGuidl/eth-tech-tree-challenges -------------------------------------------------------------------------------- /bin/eth-tech-tree.js: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | import { cli } from "../dist/cli.js"; 3 | 4 | cli(process.argv); 5 | -------------------------------------------------------------------------------- /challenges.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "type": "challenge", 4 | "level": 1, 5 | "name": "token-voting", 6 | "label": "Token Voting", 7 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 8 | "tags": ["Governance"], 9 | "testFileName":"Voting.t.sol", 10 | "childrenNames": ["dao-governance-proposals-and-voting"] 11 | }, 12 | { 13 | "type": "reference", 14 | "level": 1, 15 | "name": "the-dao-for-context", 16 | "label": "The DAO (for context)", 17 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 18 | "tags": ["Governance"] 19 | }, 20 | { 21 | "type": "challenge", 22 | "level": 1, 23 | "name": "multisend", 24 | "label": "Multisend", 25 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 26 | "testFileName":"Multisend.t.sol", 27 | "tags": ["DeFi"], 28 | "childrenNames": ["eth-streaming"] 29 | }, 30 | { 31 | "type": "challenge", 32 | "level": 1, 33 | "name": "eth-streaming", 34 | "label": "ETH Stream", 35 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 36 | "testFileName":"EthStreaming.sol", 37 | "tags": ["DeFi"], 38 | "childrenNames": ["custom-stream"] 39 | }, 40 | { 41 | "type": "challenge", 42 | "level": 1, 43 | "name": "custom-stream", 44 | "label": "Advanced Streams", 45 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 46 | "tags": ["DeFi"] 47 | 48 | }, 49 | { 50 | "type": "challenge", 51 | "level": 1, 52 | "name": "token-wrapper-weth", 53 | "label": "Token Wrapper - WETH", 54 | "testFileName":"WrappedETH.t.sol", 55 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 56 | "tags": ["DeFi"], 57 | "childrenNames": ["rebasing-token"] 58 | }, 59 | { 60 | "type": "challenge", 61 | "level": 1, 62 | "name": "rebasing-token", 63 | "label": "Rebasing token", 64 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 65 | "tags": ["DeFi"], 66 | "childrenNames": ["multisend"] 67 | }, 68 | { 69 | "type": "reference", 70 | "level": 1, 71 | "name": "timing-incentives", 72 | "label": "Timing + incentives", 73 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 74 | "tags": ["DeFi"] 75 | }, 76 | { 77 | "type": "reference", 78 | "level": 1, 79 | "name": "handle-tokens-in-wallet", 80 | "label": "Handle tokens in wallet", 81 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 82 | "tags": ["DeFi", "Wallets"] 83 | }, 84 | { 85 | "type": "challenge", 86 | "level": 3, 87 | "name": "token-curated-registry", 88 | "label": "Token-Curated Registry", 89 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 90 | "tags": ["General"] 91 | }, 92 | { 93 | "type": "reference", 94 | "level": 1, 95 | "name": "testing-contracts-fuzzing", 96 | "label": "Testing contracts - fuzzing", 97 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 98 | "tags": ["Security"] 99 | }, 100 | { 101 | "type": "challenge", 102 | "level": 1, 103 | "name": "reentrancy", 104 | "label": "Reentrancy", 105 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 106 | "tags": ["Security"] 107 | }, 108 | { 109 | "type": "personal-challenge", 110 | "level": 1, 111 | "name": "build-your-own-eth-cli", 112 | "label": "Build your own eth CLI", 113 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 114 | "tags": ["General"] 115 | }, 116 | { 117 | "type": "reference", 118 | "level": 1, 119 | "name": "merkle-trees", 120 | "label": "Merkle Trees", 121 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 122 | "tags": ["General", "NFTs"] 123 | }, 124 | { 125 | "type": "personal-challenge", 126 | "level": 1, 127 | "name": "subgraphs", 128 | "label": "Subgraphs", 129 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 130 | "tags": ["General", "NFTs"] 131 | }, 132 | { 133 | "type": "personal-challenge", 134 | "level": 1, 135 | "name": "indexer", 136 | "label": "Indexer", 137 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 138 | "tags": ["General", "NFTs"] 139 | }, 140 | { 141 | "type": "challenge", 142 | "level": 2, 143 | "name": "svg-nfts", 144 | "label": "SVG NFTs", 145 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 146 | "tags": ["NFTs"] 147 | }, 148 | { 149 | "type": "challenge", 150 | "level": 1, 151 | "name": "gated-nft", 152 | "label": "Gated NFT", 153 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 154 | "tags": ["NFTs"] 155 | }, 156 | { 157 | "type": "challenge", 158 | "level": 1, 159 | "name": "name-system", 160 | "label": "Name system", 161 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 162 | "tags": ["NFTs"] 163 | }, 164 | { 165 | "type": "challenge", 166 | "level": 1, 167 | "name": "erc1155", 168 | "label": "ERC1155", 169 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 170 | "tags": ["NFTs"] 171 | }, 172 | { 173 | "type": "challenge", 174 | "level": 1, 175 | "name": "dead-mans-switch", 176 | "label": "Dead Mans Switch", 177 | "testFileName": "DeadMansSwitchTest.t.sol", 178 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 179 | "tags": ["Wallets"], 180 | "childrenNames": ["social-recovery"] 181 | }, 182 | { 183 | "type": "reference", 184 | "level": 1, 185 | "name": "addresses-hierarchical-derivation", 186 | "label": "Addresses (Hierarchical Derivation)", 187 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 188 | "tags": ["Wallets"] 189 | }, 190 | { 191 | "type": "challenge", 192 | "level": 2, 193 | "name": "moloch-rage-quit", 194 | "label": "Moloch Rage quit", 195 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 196 | "tags": ["Governance"], 197 | "childrenNames": ["oz-governor"] 198 | }, 199 | { 200 | "type": "challenge", 201 | "level": 2, 202 | "name": "dao-governance-proposals-and-voting", 203 | "label": "DAO governance proposals and Voting", 204 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 205 | "testFileName": "Governance.t.sol", 206 | "tags": ["Governance"], 207 | "childrenNames": ["moloch-rage-quit", "offchain-voting"] 208 | }, 209 | { 210 | "type": "challenge", 211 | "level": 2, 212 | "name": "oz-governor", 213 | "label": "OZ Governor", 214 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 215 | "tags": ["Governance"] 216 | }, 217 | { 218 | "type": "reference", 219 | "level": 2, 220 | "name": "voting-types", 221 | "label": "Voting types", 222 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 223 | "tags": ["Governance"] 224 | }, 225 | { 226 | "type": "challenge", 227 | "level": 2, 228 | "name": "atomic-swaps", 229 | "label": "Atomic Swaps", 230 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 231 | "tags": ["DeFi"] 232 | }, 233 | { 234 | "type": "reference", 235 | "level": 2, 236 | "name": "oracles", 237 | "label": "Oracles", 238 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 239 | "tags": ["DeFi"] 240 | }, 241 | { 242 | "type": "reference", 243 | "level": 2, 244 | "name": "liquidity-provision", 245 | "label": "Liquidity Provision", 246 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 247 | "tags": ["DeFi"] 248 | }, 249 | { 250 | "type": "reference", 251 | "level": 2, 252 | "name": "vaults", 253 | "label": "Vaults", 254 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 255 | "tags": ["DeFi"] 256 | }, 257 | { 258 | "type": "challenge", 259 | "level": 2, 260 | "name": "contract-factory", 261 | "label": "Contract Factory", 262 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 263 | "tags": ["General"] 264 | }, 265 | { 266 | "type": "challenge", 267 | "level": 2, 268 | "name": "create2", 269 | "label": "CREATE2", 270 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 271 | "tags": ["General"] 272 | }, 273 | { 274 | "type": "challenge", 275 | "level": 2, 276 | "name": "gas-golf", 277 | "label": "Gas Golf (Would be multiple challenges, maybe even a second tech tree)", 278 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 279 | "tags": ["General"] 280 | }, 281 | { 282 | "type": "challenge", 283 | "level": 2, 284 | "name": "sign-msg-verify-ecrecover", 285 | "label": "sign msg verify + ecrecover", 286 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 287 | "tags": ["General"] 288 | }, 289 | { 290 | "type": "reference", 291 | "level": 2, 292 | "name": "evm-storage", 293 | "label": "EVM Storage", 294 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 295 | "tags": ["General"] 296 | }, 297 | { 298 | "type": "reference", 299 | "level": 2, 300 | "name": "evm-op-codes", 301 | "label": "EVM OP Codes", 302 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 303 | "tags": ["General"] 304 | }, 305 | { 306 | "type": "reference", 307 | "level": 2, 308 | "name": "digital-identity", 309 | "label": "Digital Identity", 310 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 311 | "tags": ["General"] 312 | }, 313 | { 314 | "type": "reference", 315 | "level": 2, 316 | "name": "security-study-past-hacks", 317 | "label": "Security - study past hacks", 318 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 319 | "tags": ["General"] 320 | }, 321 | { 322 | "type": "personal-challenge", 323 | "level": 2, 324 | "name": "run-your-own-node", 325 | "label": "Run your own node", 326 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 327 | "tags": ["General"] 328 | }, 329 | { 330 | "type": "reference", 331 | "level": 2, 332 | "name": "flashbots", 333 | "label": "Flashbots", 334 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 335 | "tags": ["MEV"] 336 | }, 337 | { 338 | "type": "reference", 339 | "level": 2, 340 | "name": "dex-sandwich-attack", 341 | "label": "DEX Sandwich Attack", 342 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 343 | "tags": ["MEV"] 344 | }, 345 | { 346 | "type": "reference", 347 | "level": 2, 348 | "name": "front-running", 349 | "label": "Front running", 350 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 351 | "tags": ["MEV"] 352 | }, 353 | { 354 | "type": "reference", 355 | "level": 2, 356 | "name": "arbitrage", 357 | "label": "Arbitrage", 358 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 359 | "tags": ["MEV"] 360 | }, 361 | { 362 | "type": "challenge", 363 | "level": 2, 364 | "name": "merkle-nfts", 365 | "label": "Merkle NFTs", 366 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 367 | "tags": ["NFTs"] 368 | }, 369 | { 370 | "type": "challenge", 371 | "level": 2, 372 | "name": "nft-price-curve", 373 | "label": "NFT price curve", 374 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 375 | "tags": ["NFTs"] 376 | }, 377 | { 378 | "type": "challenge", 379 | "level": 2, 380 | "name": "nft-permissions", 381 | "label": "NFT Permissions", 382 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 383 | "tags": ["NFTs"] 384 | }, 385 | { 386 | "type": "personal-challenge", 387 | "level": 2, 388 | "name": "ipfs-node", 389 | "label": "IPFS Node", 390 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 391 | "tags": ["NFTs"] 392 | }, 393 | { 394 | "type": "challenge", 395 | "level": 2, 396 | "name": "social-recovery", 397 | "label": "Social Recovery", 398 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 399 | "tags": ["Wallets"], 400 | "childrenNames": ["multisig-extension"] 401 | }, 402 | { 403 | "type": "challenge", 404 | "level": 3, 405 | "name": "offchain-voting", 406 | "label": "Offchain Voting", 407 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 408 | "tags": ["Governance"] 409 | }, 410 | { 411 | "type": "challenge", 412 | "level": 3, 413 | "name": "lending-app", 414 | "label": "Lending App", 415 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 416 | "tags": ["DeFi"] 417 | }, 418 | { 419 | "type": "challenge", 420 | "level": 3, 421 | "name": "options", 422 | "label": "Options", 423 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 424 | "tags": ["DeFi"] 425 | }, 426 | { 427 | "type": "challenge", 428 | "level": 3, 429 | "name": "shorting", 430 | "label": "Shorting", 431 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 432 | "tags": ["DeFi"] 433 | }, 434 | { 435 | "type": "challenge", 436 | "level": 3, 437 | "name": "order-book", 438 | "label": "Order book", 439 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 440 | "tags": ["DeFi"] 441 | }, 442 | { 443 | "type": "challenge", 444 | "level": 3, 445 | "name": "prediction-market", 446 | "label": "Prediction market", 447 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 448 | "tags": ["DeFi"] 449 | }, 450 | { 451 | "type": "challenge", 452 | "level": 3, 453 | "name": "proxy-contracts", 454 | "label": "Proxy contracts", 455 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 456 | "tags": ["General"] 457 | }, 458 | { 459 | "type": "personal-challenge", 460 | "level": 3, 461 | "name": "mev-bot", 462 | "label": "MEV Bot", 463 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 464 | "tags": ["MEV"] 465 | }, 466 | { 467 | "type": "challenge", 468 | "level": 3, 469 | "name": "multisig-extension", 470 | "label": "Multisig Extension", 471 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 472 | "tags": ["Wallets"] 473 | }, 474 | { 475 | "type": "reference", 476 | "level": 3, 477 | "name": "zk", 478 | "label": "ZK", 479 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 480 | "tags": ["ZK"] 481 | }, 482 | { 483 | "type": "challenge", 484 | "level": 4, 485 | "name": "smart-wallets-bundlers-paymasters-split-this-into-several", 486 | "label": "Smart Wallets, Bundlers, Paymasters (Split this into several)", 487 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 488 | "tags": ["Wallets"] 489 | }, 490 | { 491 | "type": "challenge", 492 | "level": 4, 493 | "name": "privacy-mixer", 494 | "label": "Privacy Mixer", 495 | "repo": "https://github.com/BuidlGuidl/eth-tech-tree-challenges", 496 | "tags": ["ZK"] 497 | } 498 | ] 499 | -------------------------------------------------------------------------------- /contributors/DEVELOPER-GUIDE.md: -------------------------------------------------------------------------------- 1 | # Developer Guide 2 | 3 | This project is aimed to be consumed via `npx eth-tech-tree`. The code in this repo is the source used to generate the consumable script. 4 | 5 | The steps you should follow to make changes to the code are the following: 6 | 7 | 1. Clone and `cd` into the repo 8 | 2. Start a development server watching changes with `yarn dev`. This process will build the script as you save changes. 9 | 10 | Now there are two scenarios depending on if you want to change the CLI tool itself, or the project resulting from running the CLI. 11 | 12 | To avoid confusion when talking about this project or resulting projects, we'll refer to projects created via the CLI tool as "instance projects". 13 | 14 | ## Changes to the CLI 15 | 16 | The CLI tool source code can be found under `src/`. As you make changes in those files, the development server will compile the source and generate the script inside `bin/`. 17 | 18 | To run the script you can simply run 19 | 20 | ```bash 21 | yarn cli 22 | ``` 23 | 24 | This command will run the compiled script inside `bin/`. 25 | 26 | You can send any option or flag to the CLI command. For example, a handy command is `yarn cli -s` to tell the CLI to skip installing dependencies or `yarn cli [project_path]` example `yarn cli ../test-cli` this will skip the "Project Name" prompt and use the provided path where project instance will be created in. 27 | 28 | You may find it helpful to set environment variables that can be found in `src/config.ts` to use a local [backend](https://github.com/BuidlGuidl/eth-tech-tree-backend) or point to a different repository for setting up challenges. 29 | 30 | ## Back-merging main branch / Publishing to NPM (TODO: Update to main branch workflow) 31 | 32 | 1. Make sure you have the latest changes from `main` branch 33 | 2. Checkout to `cli` branch and create a new branch from it eg: `cli-backmerge` 34 | 3. Checkout to `cli-backmerge` branch and `git merge main` 35 | 4. If there are any conflicts, resolve them and commit the changes 36 | 5. Add changeset by doing `yarn changeset add` follow prompt and commit changes, learn more about changeset [here](https://github.com/scaffold-eth/scaffold-eth-2/blob/cli/CONTRIBUTING.md#changeset) 37 | 6. Push the branch and create a PR against `cli` branch 38 | 39 | > NOTE: The `cli-backmerge` branch should be merged with "Create a merge commit" option instead of "Squash and merge" option into `cli` branch to preserve the commit history and not needing to create an extra commit directly into `cli` to merge `main` to resolve conflicts. 40 | 41 | ### Publishing to NPM 42 | 43 | Once the previous PR containing `changeset` is merged to `cli` branch, github bot will automatically create a new PR against `cli` branch containing version increment in `package.json` based on `changeset` and will also update `CHANGELOG.md` with respective `changesets` present. 44 | 45 | After this GH bot PR is merged to `cli`. It will auto publish a new release to NPM. 46 | -------------------------------------------------------------------------------- /contributors/RFC-extensions.md: -------------------------------------------------------------------------------- 1 | # Main concepts 2 | 3 | I propose we treat anything other than "base" as an extension. That way we don't need to make distinctions between solidity frameworks or any other kind of extension. 4 | 5 | This change should make it easier to grow the options we provide our users without having to classify extensions by category. 6 | 7 | This change requires a new file, `src/config.ts`, where a few things about the extensions are defined, e.g. the sequence of questions about extensions. 8 | 9 | ## Nested extensions 10 | 11 | We can add extensions of extensions, just by adding an `/extensions` folder inside an existing extension. For example, adding [next-auth][1] as an extension for next would look something like the following: 12 | 13 | ```text 14 | create-dapp-example/ 15 | ├─ src/ 16 | │ ├─ ... 17 | │ 18 | ├─ templates/ 19 | │ ├─ base/ 20 | │ ├─ extensions/ 21 | │ │ ├─nextjs 22 | │ │ ├─ ... 23 | │ │ ├─ extensions/ 24 | │ │ │ ├─ next-auth/ 25 | │ │ │ ├─ ... 26 | ``` 27 | 28 | ## Extending extensions 29 | 30 | > sorry for the confusing naming 31 | 32 | Extensions can also inherit (or extend) another extension. The goal of this feature is that two extensions can share files or nested extensions without duplication. An example of this could be hardhat and foundry, which are two different extensions, but both of them could have a shared UI to debug smart contracts. 33 | 34 | The file structure could be like this: 35 | 36 | ```text 37 | create-dapp-example/ 38 | ├─ src/ 39 | │ ├─ ... 40 | │ 41 | ├─ templates/ 42 | │ ├─ base/ 43 | │ ├─ extensions/ 44 | │ │ ├─ foundry/ 45 | │ │ │ ├─ config.json <- important to declare the `extends` field here 46 | │ │ │ ├─ ... 47 | │ │ ├─ hardhat/ 48 | │ │ │ ├─ config.json <- important to declare the `extends` field here 49 | │ │ │ ├─ ... 50 | │ │ ├─ common/ 51 | │ │ │ ├─ extensions/ 52 | │ │ │ │ ├─ possible-shared-nested-extension/ 53 | │ │ │ │ ├─ ... 54 | │ │ │ ├─ shared-file.md 55 | ``` 56 | 57 | For `foundry` and `hardhat` extensions to inherit from `common`, they need to add the `extends` field to the config.json file. 58 | 59 | ```json 60 | { 61 | "extends": "common" 62 | } 63 | ``` 64 | 65 | # Config files 66 | 67 | There's one main `src/config.ts` file to configure the questions shown to the user. 68 | 69 | For each extension there is an optional `templates/extensions/{extensionName}/config.json` file providing information about the specific extension. 70 | 71 | | ⚠️ Note how the extension config file is a JSON file 72 | 73 | ## Config files API 74 | 75 | ### `src/config.ts` 76 | 77 | Have a look at `src/types.ts#Config` or the file itself. 78 | 79 | Just a quick note to mention that adding `null` as an item in the `extensions` property will show the "None" option to the user. That option doesn't add any extension for the given question. 80 | 81 | ### `{extension}/config.json` 82 | 83 | Since these files can't be .ts, the API is not typed. However, there are certain properties that are used in the code. 84 | 85 | Those properties are: 86 | 87 | - `name`: the string to be used when showing the package name to the user via the cli, as well as for error reporting. 88 | 89 | - `extends`: the name of a different extension used as "parent extension". Read more at the [Extending extensions](#extending-extensions) section 90 | 91 | Note that all values are optional, as well as the file itself. 92 | 93 | | ⚠️ TODO list when new properties are added to config.json: 94 | | - Update this document 95 | | - Update the ExtensionDescriptor type at /src/types.ts 96 | | - Update the src/utils/extensions-tree.ts file so the new field from the config is actually added into the extension descriptor 97 | 98 | # Template files 99 | 100 | A Template file is a file to which extensions can add content. Removing content is out of scope for this experiment. 101 | 102 | ## Template files API 103 | 104 | ### Template file name 105 | 106 | All Template file should be named as \`{original-name.with-extension}.template.mjs\`. This way we can skip those files while copying base and extensions, and process them with the values from the base and the combined extensions. 107 | 108 | Note how the extension is `.mjs`. We cover why in it's own section, [.mjs extension](#mjs-extension). 109 | 110 | ### Template file contents 111 | 112 | All Template files should `export default` a function receiving named arguments and returning a string where those input arguments can be used to do string interpolation. 113 | 114 | Given multiple extensions can write to the same templates, each of the name argument should expect to receive an array of strings. Note the array might potentially be empty, which would mean no extension is adding values to that template. 115 | 116 | Therefore the exported function signature should always be \`(Record) => string\` 117 | 118 | The values from each file writing to the template are placed in the array in the same order the user selected the extensions. This effectively means nested extensions write last. 119 | 120 | Also, receiving an array instead of strings give the template itself more control over the final output. Like how the different values should be joined. 121 | 122 | Important to note that named arguments could use any arbitrary name. Because of that, we have to provide default values to all those arguments, otherwise missing values would be parsed as the string "undefined". Because we don't know what are the expected names for each template expects. 123 | 124 | ## Things to note about Template files 125 | ### `.mjs` extension 126 | It's important to note the file extension should be `.mjs`. The CLI uses es6 modules, but other packages might use commonjs imports, like Hardhat. 127 | 128 | When a package enforces commonjs imports, our templates created within those packages wouldn't work unless we explicitly tell node that it should use es6 imports. Using `.mjs` extensions is the best way we've found to do that. 129 | 130 | ### Default values 131 | 132 | It's a bit annoying having to define an empty array as a default value for all the arguments. To solve this, I've created a utility function that receives the template and expected arguments with their default values, and takes care of it. You can find it at `templates/utils.js`, the function named `withDefaults`. 133 | 134 | As a bonus, using this function will throw an error when an [Args file](#args-file-content) is trying to send an argument with a name not expected by the template. 135 | 136 | The way it should be used is as follows: 137 | ```js 138 | // file.ext.template.mjs 139 | import { withDefaults } from '../path-to/utils.js' 140 | 141 | const contents = ({ foo, bar }) => 142 | `blah blah 143 | foo value is ${foo} 144 | bar value is ${bar} 145 | blah blah 146 | ` 147 | 148 | export default withDefaults(contents, { 149 | foo: 'default foo value', 150 | bar: 'default bar value' 151 | }) 152 | ``` 153 | 154 | There's an optional 3rd argument that's for debugging purposes, which is `false` by default. If sent `true`, it will print some information about the arguments received. 155 | 156 | ⚠️ Important to note that all arguments should be defined in the object sent as the second argument. If an argument is used within the template, but it's not defined in the object, and it's not sent with any args file, it will become the string "undefined". For example: 157 | 158 | ```js 159 | // file.ext.template.mjs 160 | import { withDefaults } from '../path-to/utils.js' 161 | 162 | const contents = ({ foo, bar }) => `${foo} and ${bar}` 163 | 164 | export default withDefaults(contents, { 165 | foo: 'default', 166 | // bar: 'not defined!' 167 | }) 168 | 169 | // result: "default and undefined" 170 | ``` 171 | 172 | ### Unwanted new lines 173 | 174 | Note when you use backticks, "`", to create interpolated strings, new lines are taken as part of the string. Therefore the following string would start and end with extra empty lines: 175 | 176 | ```ts 177 | const stringWithNewLines = ` 178 | woops, there are new lines 179 | `; 180 | ``` 181 | 182 | You can do the following: 183 | 184 | ```ts 185 | const stringWithoutNewLines = `This string starts without a new line 186 | and ends without new lines`; 187 | ``` 188 | 189 | If you do this, however, prettier will try to indent the backtick. To avoid that you can see I've added a bunch of `// prettier-ignore`s before the template strings. 190 | 191 | # Args files 192 | 193 | Args files are the files used to add content to Template files. 194 | 195 | ## Args files API 196 | 197 | ### Args file name 198 | 199 | All Args files should be named as \`{original-name.with-extension}.args.mjs\`. This way we can check, for a given Template file, if any Args files exist. 200 | 201 | Note how the extension is `.mjs`, due to the same reasons we needed to use it for template files. Read more about [.mjs extension](#mjs-extension). 202 | 203 | Important to note here that the relative path of the Template and Args files **must** be the same. Otherwise the Args file content won't be used. By relative path I mean the path relative to the `base/` path or the `extensions/{extension-name}/` paths. An example: 204 | 205 | ``` 206 | create-dapp-example/ 207 | ├─ ... 208 | │ 209 | ├─ templates/ 210 | │ ├─ base/ 211 | │ │ ├─ some-folder/ 212 | │ │ │ ├─ template-at-folder.md.template.mjs 213 | │ │ ├─ template-at-root.md.template.mjs 214 | │ │ 215 | │ ├─ extensions/ 216 | │ │ ├─ foo/ 217 | │ │ │ ├─ some-folder/ 218 | │ │ │ │ ├─ template-at-root.md.args.mjs <-- won't work! 219 | │ │ │ │ ├─ template-at-folder.md.args.mjs 220 | │ │ │ ├─ template-at-root.md.args.mjs 221 | │ │ │ ├─ template-at-folder.md.args.mjs <-- won't work! 222 | ``` 223 | 224 | ### Args file content 225 | 226 | Args files should export an object with key-value pairs where keys are the named argument of the template, and the values are the content you want to send for the given named argument. 227 | 228 | This can be accomplished by using named exports instead of explicitly defining the object. 229 | 230 | ```js 231 | // this 232 | export one = 1 233 | export two = 2 234 | 235 | // would be equivalent to 236 | export default { one: 1, two: 2} 237 | ``` 238 | 239 | To avoid issues when named arguments have typos, the `withDefaults` utility will also throw an error when an argument is passed with a name that wasn't expected by the template. 240 | 241 | # Args files injection in Template files 242 | 243 | For each Template file, we search on the extensions the user selected for the existence of Args files in the exact same relative path. If there are multiple Args files, we combine them into an array 244 | 245 | I've thought about how the strings should be joined, but an option is to use [tagged templates](4). We can go as crazy as we want with tagged templates. 246 | 247 | # Extension folder anatomy 248 | 249 | When creating a new extension, simply create a new folder under `templates/extensions` with the name you want the extension to have. 250 | 251 | Inside the folder you will have a mix of normal, templated, and special files and folders. 252 | 253 | ## Normal files and folders 254 | 255 | These are the untouched files and folders you want the extension to add to the final project. 256 | In the case of files, they will be copied to the resulting project and no other extension will be able to touch them. 257 | For folders, they will create the folder with its contents, and other extension will be able to add new files to it. 258 | Templated files can be nested within normal folders to keep the same path in the resulting project. 259 | 260 | ## Templated files 261 | 262 | Templated files are both [Template files](#template-files), and [Args files](#args-files). We use them to write to other files already added by the base project or another extension, or to let other extensions modify a file created by this extension. Just to recap, those files are the ones ending in `*.template.js` (Template file) or `*.args.js` (Args file). 263 | 264 | ## Special files and folders 265 | 266 | The special files and folders are: 267 | 268 | - [`package.json` file](#merging-packagejson-files) 269 | - [`config.json` file](#extensionconfigjson) 270 | - [`extensions/` folder](#nested-extensions) 271 | 272 | # Things worth mentioning 273 | 274 | ## Merging package.json files 275 | 276 | The package we use to merge package.json files [merge-packages](3) will use the last version of a dependency given a conflict. For example: 277 | 278 | ``` 279 | version on file one: 1.0.0 280 | version on file two: 0.1.0 281 | resulting version: 0.1.0 282 | 283 | version on file one: 0.1.0 284 | version on file two: 1.0.0 285 | resulting version: 1.0.0 286 | ``` 287 | 288 | The first and last files are the first and second arguments when we call the function, so we can choose what version we want to win when there's a conflict. 289 | 290 | ## Filesystem async methods 291 | 292 | This is a possible improvement in the speed of the cli. I've used the sync API to avoid adding extra complexity for the proof of concept, but it might be an improvement helping parallelize tasks. For example processing templates in parallel. 293 | 294 | [1]: https://github.com/nextauthjs/next-auth 295 | [2]: https://www.prisma.io/ 296 | [3]: https://github.com/zppack/merge-packages 297 | [4]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#tagged_templates 298 | -------------------------------------------------------------------------------- /example.env: -------------------------------------------------------------------------------- 1 | # For development purposes you can sidestep the default values by creating a .env file in the root of the project 2 | API_URL= 3 | BASE_REPO= 4 | BASE_BRANCH= 5 | BASE_COMMIT= 6 | CHALLENGE_REPO= -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eth-tech-tree", 3 | "version": "0.2.2", 4 | "description": "", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/BuidlGuidl/eth-tech-tree.git" 8 | }, 9 | "main": "dist/cli.js", 10 | "type": "module", 11 | "bin": "bin/eth-tech-tree.js", 12 | "scripts": { 13 | "build": "rollup -c rollup.config.js", 14 | "dev": "rollup -c rollup.config.js --watch", 15 | "cli": "node bin/eth-tech-tree.js", 16 | "test": "echo \"Error: no test specified\" && exit 1", 17 | "changeset:release": "yarn build && changeset publish" 18 | }, 19 | "keywords": [ 20 | "cli", 21 | "ethereum", 22 | "scaffold-eth 2", 23 | "wagmi", 24 | "hardhat", 25 | "foundry", 26 | "viem", 27 | "rainbowkit", 28 | "challenge", 29 | "coding challenge", 30 | "solidity" 31 | ], 32 | "license": "MIT", 33 | "devDependencies": { 34 | "@rollup/plugin-json": "^6.1.0", 35 | "@rollup/plugin-typescript": "11.1.0", 36 | "@types/ncp": "2.0.5", 37 | "@types/node": "18.16.0", 38 | "rollup": "3.21.0", 39 | "rollup-plugin-auto-external": "2.0.0", 40 | "tslib": "2.5.0", 41 | "typescript": "5.0.4" 42 | }, 43 | "dependencies": { 44 | "@changesets/cli": "^2.26.2", 45 | "@inquirer/prompts": "^7.1.0", 46 | "ansi-escapes": "^7.0.0", 47 | "arg": "5.0.2", 48 | "chalk": "^5.3.0", 49 | "create-eth": "0.0.65", 50 | "dotenv": "^16.4.5", 51 | "execa": "7.1.1", 52 | "semver": "^7.6.3" 53 | }, 54 | "packageManager": "yarn@3.5.0" 55 | } 56 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from "@rollup/plugin-typescript"; 2 | import autoExternal from "rollup-plugin-auto-external"; 3 | import json from '@rollup/plugin-json'; 4 | 5 | export default { 6 | input: "src/cli.ts", 7 | output: { 8 | dir: "dist", 9 | format: "es", 10 | sourcemap: true, 11 | }, 12 | plugins: [autoExternal(), typescript({ exclude: ["challenges/**"] }), json()], 13 | external: ["@inquirer/core"], 14 | }; 15 | -------------------------------------------------------------------------------- /src/actions/index.ts: -------------------------------------------------------------------------------- 1 | import { setupChallenge } from "./setup-challenge"; 2 | import { submitChallenge } from "./submit-challenge"; 3 | import { testChallenge } from "./test-challenge"; 4 | import { removeStorage } from "./remove-storage"; 5 | 6 | export { setupChallenge, submitChallenge, testChallenge, removeStorage }; -------------------------------------------------------------------------------- /src/actions/remove-storage.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | 4 | export function removeStorage() { 5 | console.log("Resetting storage..."); 6 | const configPath = path.join(process.cwd(), "storage"); 7 | if (!fs.existsSync(configPath)) { 8 | console.log("Storage does not exist. Nothing to reset."); 9 | return; 10 | } 11 | fs.rmSync(configPath, { recursive: true }); 12 | console.log("Storage reset successfully."); 13 | } -------------------------------------------------------------------------------- /src/actions/setup-challenge.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | import fs from "fs"; 3 | import { execa } from "execa"; 4 | import semver, { Range } from 'semver'; 5 | 6 | type RequiredDependency = "node" | "git" | "yarn" | "foundryup"; 7 | 8 | export const setupChallenge = async (name: string, installLocation: string) => { 9 | checkUserDependencies(); 10 | 11 | const challengeRepo = process.env.CHALLENGE_REPO || "BuidlGuidl/eth-tech-tree-challenges"; 12 | 13 | // Create install location if it doesn't exist 14 | fs.mkdirSync(installLocation, { recursive: true }); 15 | 16 | const extensionName = `${challengeRepo}:${name}-extension`; 17 | const challengeDir = `${installLocation}/${name}`; 18 | try { 19 | await execa("create-eth", ["-e", extensionName, challengeDir], { stdio: "inherit" }); 20 | console.clear(); 21 | console.log(chalk.green("Challenge setup completed successfully.\n")); 22 | console.log(chalk.cyan(`Now open this repository in your favorite code editor and look at the readme for instructions:\n${challengeDir}`)); 23 | } catch (e) { 24 | console.error(`Failed to create challenge: ${name}, \n${e}`); 25 | } 26 | } 27 | 28 | const checkDependencyInstalled = async (name: RequiredDependency) => { 29 | try { 30 | await execa(name, ["--help"]); 31 | } catch(_) { 32 | throw new Error(`${name} is required. Please install to continue.`); 33 | } 34 | } 35 | 36 | const checkDependencyVersion = async (name: RequiredDependency, requiredVersion: string | Range) => { 37 | try { 38 | const userVersion = (await execa(name, ["--version"])).stdout; 39 | if (!semver.satisfies(userVersion, requiredVersion)) { 40 | throw new Error(`${name} version requirement of ${requiredVersion} not met. Please update to continue.`); 41 | } 42 | } catch(_) { 43 | throw new Error(`${name} ${requiredVersion} is required. Please install to continue.`); 44 | } 45 | } 46 | 47 | export const checkUserDependencies = async () => { 48 | await Promise.all([ 49 | checkDependencyVersion("node", ">=18.17.0"), 50 | checkDependencyInstalled("git"), 51 | checkDependencyInstalled("yarn"), 52 | checkDependencyInstalled("foundryup"), 53 | ]) 54 | } 55 | 56 | 57 | -------------------------------------------------------------------------------- /src/actions/submit-challenge.ts: -------------------------------------------------------------------------------- 1 | import { loadUserState } from "../utils/state-manager"; 2 | import { submitChallengeToServer } from "../modules/api"; 3 | import chalk from "chalk"; 4 | import { input } from "@inquirer/prompts"; 5 | import { isValidAddress } from "../utils/helpers"; 6 | 7 | export async function submitChallenge(name: string, contractAddress?: string) { 8 | const { address: userAddress } = loadUserState(); 9 | if (!contractAddress) { 10 | // Prompt the user for the contract address 11 | const question = { 12 | message: "What is the contract address of your completed challenge?:", 13 | validate: (value: string) => isValidAddress(value) ? true : "Please enter a valid contract address", 14 | }; 15 | const answer = await input(question); 16 | contractAddress = answer; 17 | } 18 | 19 | console.log("Submitting challenge..."); 20 | console.log(""); 21 | 22 | // Send the contract address to the server 23 | const response = await submitChallengeToServer(userAddress as string, "sepolia", name, contractAddress as string); 24 | if (response.result) { 25 | const { passed, failingTests, error } = response.result; 26 | if (passed) { 27 | console.log("Challenge passed tests! Congratulations!"); 28 | } else { 29 | if (error) { 30 | console.log("The testing server encountered an error when running this test:"); 31 | console.log(chalk.red(error)); 32 | } 33 | console.log("Failing tests:", Object.keys(failingTests).length); 34 | for (const testName in failingTests) { 35 | console.log(chalk.blueBright(testName), chalk.red(failingTests[testName].reason)); 36 | } 37 | console.log(""); 38 | console.log("Challenge failed tests. See output above for details."); 39 | } 40 | } else { 41 | console.log(response.error); 42 | } 43 | } -------------------------------------------------------------------------------- /src/actions/test-challenge.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import { execa } from "execa"; 3 | 4 | export async function testChallenge(name: string) { 5 | console.log("Running tests on challenge..."); 6 | const targetDir = `challenges/${name}`; 7 | // Check if challenge exists 8 | if (!fs.existsSync(targetDir)) { 9 | console.log("Challenge does not exist. Please setup the challenge first."); 10 | return; 11 | } 12 | // Install dependencies and run tests 13 | const { failed: installFailed } = await execa("yarn", ["install"], { cwd: targetDir }); 14 | if (installFailed) { 15 | console.log("Failed to install dependencies."); 16 | return; 17 | } 18 | 19 | try { 20 | await execa("yarn", ["run", "foundry:test"], { cwd: targetDir, all: true }).pipeAll!(process.stdout); 21 | } catch (error) { 22 | console.log(""); 23 | console.log("Challenge does not pass tests. Examine the output above and try again when you have fixed the issues."); 24 | return; 25 | } 26 | console.log(""); 27 | console.log("Challenge passed the tests! Great work! Now deploy your contract."); 28 | } -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | import { promptForMissingUserState } from "./tasks/prompt-for-missing-user-state"; 2 | import { renderIntroMessage } from "./tasks/render-intro-message"; 3 | import type { Args, IUser } from "./types"; 4 | import { loadUserState, saveChallenges } from "./utils/state-manager"; 5 | import { fetchChallenges } from "./modules/api"; 6 | import { parseCommandArgumentsAndOptions, promptForMissingCommandArgs } from "./tasks/parse-command-arguments-and-options"; 7 | import { handleCommand } from "./tasks/handle-command"; 8 | import { TechTree } from "."; 9 | 10 | 11 | 12 | export async function cli(args: Args) { 13 | try { 14 | const commands = await parseCommandArgumentsAndOptions(args); 15 | const userState = loadUserState(); 16 | if (commands.command || commands.help) { 17 | const parsedCommands = await promptForMissingCommandArgs(commands, userState); 18 | await handleCommand(parsedCommands); 19 | } else { 20 | await renderIntroMessage(); 21 | await init(userState); 22 | // Navigate tree 23 | const techTree = new TechTree(); 24 | 25 | await techTree.start(); 26 | } 27 | } catch (error) { 28 | if (error instanceof Error && error.name === 'ExitPromptError') { 29 | // Because canceling the promise (e.g. ctrl+c) can cause the inquirer prompt to throw we need to silence this error 30 | } else { 31 | throw error; 32 | } 33 | } 34 | } 35 | 36 | async function init(userState: IUser) { 37 | // Use local user state or prompt to retrieve user from server 38 | await promptForMissingUserState(userState); 39 | 40 | // Get Challenges 41 | const challenges = await fetchChallenges(); 42 | await saveChallenges(challenges); 43 | } 44 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | dotenv.config(); 3 | 4 | export const API_URL = process.env.API_URL || "https://api.ethtechtree.com"; -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, rmSync } from "fs"; 2 | import { confirm, input } from "@inquirer/prompts"; 3 | import { IUserChallenge, IChallenge, TreeNode, IUser, Actions } from "./types"; 4 | import chalk from "chalk"; 5 | import { loadChallenges, loadUserState, saveUserState } from "./utils/state-manager"; 6 | import { getUser } from "./modules/api"; 7 | import { setupChallenge, submitChallenge } from "./actions"; 8 | import select from './utils/global-context-select-list'; 9 | import { ProgressView } from "./utils/progress-view"; 10 | import { calculatePoints, stripAnsi } from "./utils/helpers"; 11 | import { LeaderboardView } from "./utils/leaderboard-view"; 12 | import { fetchLeaderboard } from "./modules/api"; 13 | 14 | type GlobalChoice = { 15 | value: string; 16 | key: string; 17 | } 18 | 19 | export class TechTree { 20 | private globalTree: TreeNode; 21 | private userState: IUser; 22 | private challenges: IChallenge[]; 23 | private history: { node: TreeNode, selection: string }[] = []; 24 | private globalChoices: GlobalChoice[]; 25 | private nodeLabel: string = "Main Menu"; 26 | 27 | constructor() { 28 | this.userState = loadUserState(); 29 | this.challenges = loadChallenges(); 30 | this.globalTree = this.buildTree(); 31 | this.globalChoices = [ 32 | { value: 'quit', key: 'q' }, 33 | { value: 'help', key: 'h' }, 34 | { value: 'progress', key: 'p' }, 35 | { value: 'leaderboard', key: 'l' }, 36 | { value: 'back', key: 'escape' }, 37 | { value: 'back', key: 'backspace' }, 38 | ]; 39 | 40 | this.listenForQuit(); 41 | } 42 | 43 | listenForQuit(): void { 44 | process.stdin.setRawMode(true); 45 | process.stdin.on('keypress', (_, key) => { 46 | if ((key.ctrl && key.name === 'c')) { 47 | this.quit(); 48 | } 49 | }); 50 | } 51 | 52 | async start(): Promise { 53 | await this.navigate(); 54 | } 55 | 56 | async navigate(node?: TreeNode, selection?: string, heightOffset = 3): Promise { 57 | if (!node) { 58 | this.globalTree = this.buildTree(); 59 | node = Object.assign({}, this.globalTree); 60 | } 61 | 62 | // Handle navigation nodes 63 | const { choices, actions } = this.getChoicesAndActions(node); 64 | 65 | const directionsPrompt = { 66 | message: this.getMessage(node), 67 | globalChoices: this.globalChoices, 68 | choices, 69 | loop: false, 70 | default: selection, 71 | pageSize: this.getMaxViewHeight() - heightOffset, 72 | theme: { 73 | helpMode: "always" as "never" | "always" | "auto" | undefined, 74 | prefix: "" 75 | } 76 | }; 77 | 78 | try { 79 | this.nodeLabel = node.shortname || node.label; 80 | this.clearView(); 81 | this.printMenu(); 82 | const { answer } = await select(directionsPrompt); 83 | if (!this.globalChoices.find(choice => choice.value === answer)) { 84 | const selectedAction = actions[answer]; 85 | // Only save new history if the action is not a global choice 86 | this.history.push({ node, selection: answer }); 87 | await selectedAction(); 88 | } else { 89 | const selectedAction = this.getGlobalChoiceAction(answer); 90 | await selectedAction(); 91 | } 92 | } catch (error) { 93 | if (error instanceof Error && error.name === 'ExitPromptError') { 94 | // Because canceling the promise (e.g. ctrl+c) can cause the inquirer prompt to throw we need to silence this error 95 | } else { 96 | throw error; 97 | } 98 | } 99 | } 100 | 101 | getGlobalChoiceAction(selectedActionLabel: string): Function { 102 | if (selectedActionLabel === 'quit') { 103 | return () => this.quit(); 104 | } else if (selectedActionLabel === 'back') { 105 | return () => this.goBack(); 106 | } else if (selectedActionLabel === 'help') { 107 | return () => this.printHelp(); 108 | } else if (selectedActionLabel === 'progress') { 109 | return () => this.printProgress(); 110 | } else if (selectedActionLabel === 'leaderboard') { 111 | return () => this.printLeaderboard(); 112 | } 113 | throw new Error(`Invalid global choice: ${selectedActionLabel}`); 114 | } 115 | 116 | async goBack(): Promise { 117 | if (this.history.length > 0) { 118 | const { node, selection } = this.history.pop() as { node: TreeNode, selection: string }; 119 | await this.navigate(node, selection); 120 | } else { 121 | await this.navigate(); 122 | } 123 | } 124 | 125 | getMessage(node: TreeNode): string { 126 | // Default messages based on node type 127 | if (node.type === "challenge") { 128 | return this.getChallengeMessage(node); 129 | } else if (node.message) { 130 | return node.message; 131 | } else if (node.children.find(child => child.type === "challenge")) { 132 | return "Select a challenge"; 133 | } else { 134 | return "Select a category"; 135 | } 136 | } 137 | 138 | getChallengeMessage(node: TreeNode): string { 139 | const { installLocation } = this.userState; 140 | return `${node.message} 141 | ${node.completed ? ` 142 | 🏆 Challenge Completed` : node.installed ? ` 143 | Open up the challenge in your favorite code editor and follow the instructions in the README: 144 | 145 | 📂 Challenge Location: ${installLocation}/${node.name}` : ""} 146 | `; 147 | } 148 | 149 | buildTree(): TreeNode { 150 | const userChallenges = this.userState?.challenges || []; 151 | const tree: TreeNode[] = []; 152 | const tags = this.challenges.reduce((acc: string[], challenge: IChallenge) => { 153 | return Array.from(new Set(acc.concat(challenge.tags))); 154 | }, []); 155 | 156 | for (let tag of tags) { 157 | const filteredChallenges = this.challenges.filter((challenge: IChallenge) => challenge.tags.includes(tag) && challenge.enabled); 158 | let completedCount = 0; 159 | const transformedChallenges = filteredChallenges.map((challenge: IChallenge) => { 160 | const { label, name, level, type, childrenNames, enabled: unlocked, description } = challenge; 161 | const parentName = this.challenges.find((c: IChallenge) => c.childrenNames?.includes(name))?.name; 162 | const completed = userChallenges.find((c: IUserChallenge) => c.challengeName === name)?.status === "success"; 163 | if (completed) { 164 | completedCount++; 165 | } 166 | 167 | 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 }; 168 | }); 169 | const nestedChallenges = this.recursiveNesting(transformedChallenges); 170 | 171 | tree.push({ 172 | type: "header", 173 | label: `${tag} ${chalk.green(`(${completedCount}/${filteredChallenges.length})`)}`, 174 | name: `${tag.toLowerCase()}`, 175 | children: nestedChallenges, 176 | recursive: true 177 | }); 178 | } 179 | // Remove any categories without challenges 180 | const enabledCategories = tree.filter((category: TreeNode) => category.children.length > 0); 181 | const mainMenu: TreeNode = { 182 | label: "Main Menu", 183 | name: "main-menu", 184 | type: "header", 185 | children: enabledCategories, 186 | }; 187 | 188 | return mainMenu; 189 | } 190 | 191 | getChoicesAndActions(node: TreeNode): { choices: { name: string, value: string }[], actions: Actions } { 192 | const choices: { name: string, value: string }[] = []; 193 | let actions: Actions = {}; 194 | 195 | if (!node.recursive) { 196 | if (node.type !== "challenge") { 197 | choices.push(...node.children.map(child => ({ name: this.getNodeLabel(child), value: child.label }))); 198 | for (const child of node.children) { 199 | 200 | actions[child.label] = () => this.navigate(child); 201 | } 202 | if (node.children.length === 0) { 203 | choices.push({ name: "Back", value: "back" }); 204 | actions["Back"] = () => this.goBack(); 205 | } 206 | } else { 207 | actions = node.actions as Actions; 208 | choices.push(...Object.keys(node.actions as Actions).map(action => ({ name: action, value: action }))); 209 | } 210 | return { choices, actions }; 211 | } 212 | 213 | const getChoicesAndActionsRecursive = (node: TreeNode, isLast: boolean = false, depth: string = "") => { 214 | if (node.type !== "header") { 215 | if (!isLast) { 216 | depth += "├─"; 217 | } else { 218 | depth += "└─"; 219 | } 220 | } 221 | choices.push({ name: this.getNodeLabel(node, depth), value: node.label }); 222 | actions[node.label] = () => this.navigate(node); 223 | // Replace characters in the continuing pattern 224 | if (depth.length) { 225 | depth = depth.replace(/├─/g, "│ "); 226 | depth = depth.replace(/└─/g, " "); 227 | } 228 | // Add spaces so that the labels are spaced out 229 | const depthDivisor = node.type === "header" ? 5 : 2; 230 | depth += Array(Math.floor(node.label.length / depthDivisor)).fill(" ").join(""); 231 | node.children.forEach((child, i, siblings) => getChoicesAndActionsRecursive(child, i === siblings.length - 1, depth)); 232 | }; 233 | 234 | node.children.forEach((child, i, siblings) => getChoicesAndActionsRecursive(child, i === siblings.length - 1)); 235 | 236 | return { choices, actions }; 237 | } 238 | 239 | getNodeLabel(node: TreeNode, depth: string = ""): string { 240 | const { label, type, completed, unlocked } = node; 241 | const isHeader = type === "header"; 242 | const isChallenge = type === "challenge"; 243 | const isQuiz = type === "quiz"; 244 | const isCapstoneProject = type === "capstone-project"; 245 | 246 | 247 | if (isHeader) { 248 | return `${depth}${chalk.blueBright(label)}`; 249 | } else if (!unlocked) { 250 | return `${depth}${chalk.dim(chalk.dim(label))}`; 251 | } else if (isChallenge) { 252 | return `${depth}${label} ${completed ? "🏆" : ""}`; 253 | } else if (isQuiz) { 254 | return `${depth}${label} 📜`; 255 | } else if (isCapstoneProject) { 256 | return `${depth}${label} 💻`; 257 | } else { 258 | return `${depth}${label}`; 259 | } 260 | } 261 | 262 | findNode(globalTree: TreeNode, name: string): TreeNode | undefined { 263 | // Descend the tree until the node is found 264 | if (globalTree.name === name) { 265 | return globalTree; 266 | } 267 | for (const child of globalTree.children) { 268 | const node = this.findNode(child, name); 269 | if (node) { 270 | return node; 271 | } 272 | } 273 | } 274 | 275 | recursiveNesting(challenges: any[], parentName: string | undefined = undefined): TreeNode[] { 276 | const tree: TreeNode[] = []; 277 | for (let challenge of challenges) { 278 | if (challenge.parentName === parentName) { 279 | // Recursively call recursiveNesting for each child 280 | challenge.children = this.recursiveNesting(challenges, challenge.name); 281 | tree.push(challenge); 282 | } 283 | } 284 | return tree; 285 | } 286 | 287 | challengeIsInstalled(challenge: TreeNode): boolean { 288 | const { installLocation } = this.userState; 289 | const targetDir = `${installLocation}/${challenge.name}`; 290 | return existsSync(targetDir); 291 | } 292 | 293 | rebuildHistory():void { 294 | // Rebuild Global tree 295 | this.globalTree = this.buildTree(); 296 | // For each node in the current history, find the new node in the newly created tree 297 | const newHistory = this.history.map(({ node, selection }) => { 298 | const newNode = this.findNode(this.globalTree, node.name); 299 | return { node: newNode as TreeNode, selection }; 300 | }); 301 | this.history = newHistory; 302 | } 303 | 304 | getChallengeActions(challenge: TreeNode): Actions { 305 | const actions: Actions = {}; 306 | const { address, installLocation } = this.userState; 307 | const { type, name } = challenge; 308 | if (!this.challengeIsInstalled(challenge)) { 309 | actions["Setup Challenge Repository"] = async () => { 310 | this.clearView(); 311 | await setupChallenge(name, installLocation); 312 | // Rebuild the tree and history 313 | this.rebuildHistory(); 314 | // Wait for enter key 315 | await this.pressEnterToContinue(); 316 | // Return to challenge menu 317 | await this.goBack(); 318 | }; 319 | } else { 320 | actions["Reset Challenge"] = async () => { 321 | this.clearView(); 322 | const confirmReset = await this.yesOrNo("Are you sure you want to reset this challenge? This will remove the challenge from your local machine and re-install it.", false); 323 | if (!confirmReset) { 324 | await this.goBack(); 325 | } else { 326 | const targetDir = `${installLocation}/${name}`; 327 | console.log(`Removing ${targetDir}...`); 328 | rmSync(targetDir, { recursive: true, force: true }); 329 | console.log(`Installing fresh copy of challenge...`); 330 | await setupChallenge(name, installLocation); 331 | // Rebuild the tree and history 332 | this.rebuildHistory(); 333 | await this.pressEnterToContinue(); 334 | // Return to challenge menu 335 | await this.goBack(); 336 | } 337 | }; 338 | actions["Submit Completed Challenge"] = async () => { 339 | this.clearView(); 340 | // Submit the challenge 341 | await submitChallenge(name); 342 | // Fetch users challenge state from the server 343 | const newUserState = await getUser(address); 344 | this.userState.challenges = newUserState.challenges; 345 | // Save the new user state locally 346 | await saveUserState(this.userState); 347 | // Rebuild the tree and history 348 | this.rebuildHistory(); 349 | // Wait for enter key 350 | await this.pressEnterToContinue(); 351 | // Return to challenge menu 352 | await this.goBack(); 353 | }; 354 | } 355 | return actions; 356 | }; 357 | 358 | async yesOrNo(message: string, defaultAnswer: boolean = true) { 359 | const answer = await confirm({ 360 | message, 361 | default: defaultAnswer, 362 | theme: { 363 | prefix: "", 364 | } 365 | }); 366 | return answer; 367 | } 368 | 369 | async pressEnterToContinue(customMessage?: string) { 370 | const answer = await input({ 371 | message: typeof customMessage === "string" ? customMessage : 'Press Enter to continue...', 372 | theme: { 373 | prefix: "", 374 | } 375 | }); 376 | return answer; 377 | } 378 | 379 | private clearView(): void { 380 | process.stdout.moveCursor(0, this.getMaxViewHeight()); 381 | console.clear(); 382 | } 383 | 384 | private printMenu(): void { 385 | const border = chalk.blueBright("─"); 386 | const borderLeft = chalk.blueBright("●─"); 387 | const borderRight = chalk.blueBright("─●"); 388 | const currentViewName = this.nodeLabel || "Main Menu"; 389 | const user = this.userState.ens || this.userState.address; 390 | const completedChallenges = this.userState.challenges 391 | .filter(c => c.status === "success") 392 | .map(c => ({ 393 | challenge: this.challenges.find(ch => ch.name === c.challengeName), 394 | completion: c 395 | })) 396 | .filter(c => c.challenge); 397 | const points = calculatePoints(completedChallenges); 398 | 399 | 400 | const width = process.stdout.columns; 401 | const userInfo = `${chalk.green(user)} ${chalk.yellow(`(${points} points)`)}`; 402 | const topMenuText = chalk.bold(`${borderLeft}${currentViewName}${new Array(width - (stripAnsi(currentViewName).length + stripAnsi(userInfo).length + 4)).fill(border).join('')}${userInfo}${borderRight}`); 403 | const bottomMenuText = chalk.bold(`${borderLeft}${chalk.bgGreen(``)} to quit | ${chalk.bgGreen(``)} to go back | ${chalk.bgGreen(`

`)} view progress | ${chalk.bgGreen(``)} leaderboard${new Array(width - 72).fill(border).join('')}${borderRight}`); 404 | 405 | // Save cursor position 406 | process.stdout.write('\x1B7'); 407 | 408 | // Hide cursor while we work 409 | process.stdout.write('\x1B[?25l'); 410 | 411 | // Print at top 412 | process.stdout.cursorTo(0, 0); 413 | process.stdout.clearLine(0); 414 | process.stdout.write(topMenuText); 415 | 416 | // Print at bottom 417 | process.stdout.cursorTo(0, this.getMaxViewHeight()); 418 | process.stdout.clearLine(0); 419 | process.stdout.write(bottomMenuText); 420 | 421 | // Move cursor to line 1 (just below the top menu) 422 | process.stdout.cursorTo(0, 1); 423 | 424 | // Show cursor again 425 | process.stdout.write('\x1B[?25h'); 426 | } 427 | 428 | getMaxViewHeight(): number { 429 | const maxRows = 20; 430 | if (process.stdout.rows < maxRows) { 431 | return process.stdout.rows; 432 | } 433 | return maxRows; 434 | } 435 | 436 | quit(): void { 437 | this.clearView(); 438 | process.exit(0); 439 | } 440 | 441 | printHelp(): void { 442 | this.clearView(); 443 | console.log("Help"); 444 | } 445 | 446 | async printProgress(): Promise { 447 | const progressView = new ProgressView(this.userState, this.challenges); 448 | const progressTree = progressView.buildProgressTree(); 449 | await this.navigate(progressTree, undefined, 6); 450 | } 451 | 452 | async printLeaderboard(): Promise { 453 | const leaderboardData = await fetchLeaderboard(); 454 | const leaderboardView = new LeaderboardView(leaderboardData, this.userState.address); 455 | const leaderboardTree = leaderboardView.buildLeaderboardTree(); 456 | await this.navigate(leaderboardTree, undefined, 6); 457 | } 458 | } -------------------------------------------------------------------------------- /src/modules/api.ts: -------------------------------------------------------------------------------- 1 | import { API_URL } from "../config"; 2 | 3 | /** 4 | * Fetch Challenges 5 | */ 6 | export const fetchChallenges = async () => { 7 | try { 8 | const response = await fetch(`${API_URL}/challenges`); 9 | const data = await response.json(); 10 | return data.challenges; 11 | } catch (error) { 12 | console.error('Error:', error); 13 | return []; 14 | } 15 | }; 16 | 17 | /** 18 | * Get User 19 | */ 20 | export const getUser = async (identifier: string) => { 21 | try { 22 | const response = await fetch(`${API_URL}/user/${identifier}`, { 23 | method: 'GET', 24 | headers: { 25 | 'Content-Type': 'application/json', 26 | }, 27 | }); 28 | const data = await response.json(); 29 | return data.user; 30 | } catch (error) { 31 | console.error('Error:', error); 32 | return {}; 33 | } 34 | }; 35 | 36 | /** 37 | * Create User 38 | */ 39 | export const upsertUser = async (userData: { address?: string, ens?: string, deviceInstallLocation: { [device: string]: string } }) => { 40 | try { 41 | const response = await fetch(`${API_URL}/user`, { 42 | method: 'POST', 43 | headers: { 44 | 'Content-Type': 'application/json', 45 | }, 46 | body: JSON.stringify(userData), 47 | }); 48 | const data = await response.json(); 49 | return data.user; 50 | } catch (error) { 51 | console.error('Error:', error); 52 | return {}; 53 | } 54 | }; 55 | 56 | /** 57 | * Submit Challenge 58 | */ 59 | export const submitChallengeToServer = async (userAddress: string, network: string, challengeName: string, contractAddress: string) => { 60 | try { 61 | const response = await fetch(`${API_URL}/submit`, { 62 | method: 'POST', 63 | headers: { 64 | 'Content-Type': 'application/json', 65 | }, 66 | body: JSON.stringify({ challengeName, contractAddress, network, userAddress }), 67 | }); 68 | const data = await response.json(); 69 | return data; 70 | } catch (error) { 71 | console.error('Error:', error); 72 | return {}; 73 | } 74 | }; 75 | 76 | /** 77 | * Fetch Leaderboard 78 | */ 79 | export const fetchLeaderboard = async () => { 80 | try { 81 | const response = await fetch(`${API_URL}/leaderboard`); 82 | const data = await response.json(); 83 | return data.leaderboard; 84 | } catch (error) { 85 | console.error('Error:', error); 86 | return []; 87 | } 88 | }; -------------------------------------------------------------------------------- /src/tasks/create-first-git-commit.ts: -------------------------------------------------------------------------------- 1 | import { execa } from "execa"; 2 | import path from "path"; 3 | 4 | const foundryLibraries = ["foundry-rs/forge-std", "OpenZeppelin/openzeppelin-contracts", "gnsps/solidity-bytes-utils"]; 5 | 6 | export async function createFirstGitCommit(targetDir: string) { 7 | let errorLog = ""; 8 | try { 9 | const foundryWorkSpacePath = path.resolve(targetDir, "packages", "foundry"); 10 | 11 | errorLog = "Removing lib directory"; 12 | // remove lib directory since it doesn't like directories to exist when installing 13 | await execa("rm",["-rf", "lib"], { cwd: foundryWorkSpacePath }); 14 | 15 | errorLog = "Installing forge dependencies"; 16 | // forge install foundry libraries 17 | await execa("forge", ["install", ...foundryLibraries, "--no-git"], { cwd: foundryWorkSpacePath }); 18 | 19 | errorLog = "git add -A"; 20 | // Add and commit all changes 21 | await execa("git", ["add", "-A"], { cwd: targetDir }); 22 | 23 | errorLog = "git commit -m 'Initial commit with 🏗️ Scaffold-ETH 2'"; 24 | await execa("git", ["commit", "-m", "Initial commit with 🏗️ Scaffold-ETH 2", "--no-verify"], { cwd: targetDir }); 25 | 26 | errorLog = "git remote remove origin"; 27 | // Remove remote 28 | await execa("git", ["remote", "remove", "origin"], { cwd: targetDir }); 29 | } catch (e: any) { 30 | // cast error as ExecaError to get stderr 31 | throw new Error(`Failed to initialize git repository after step: ${errorLog}`, { 32 | cause: e?.stderr ?? e, 33 | }); 34 | } 35 | } -------------------------------------------------------------------------------- /src/tasks/handle-command.ts: -------------------------------------------------------------------------------- 1 | import { CommandOptions } from "./parse-command-arguments-and-options"; 2 | import { removeStorage, setupChallenge, submitChallenge } from "../actions"; 3 | import { version } from '../../package.json' 4 | 5 | export async function handleCommand(commands: CommandOptions) { 6 | const { command, installLocation, challenge, contractAddress, dev, help } = commands; 7 | if (help) { 8 | // TODO: Show help menu based on command 9 | console.log("Help menu not implemented yet 🙃"); 10 | return; 11 | } 12 | 13 | if (command === 'version') { 14 | console.log(version) 15 | return 16 | } 17 | 18 | if (command === "setup") { 19 | await setupChallenge(challenge as string, installLocation as string); 20 | } 21 | 22 | if (command === "submit") { 23 | await submitChallenge(challenge as string, contractAddress as string); 24 | } 25 | 26 | if (command === "reset") { 27 | // Delete the storage files 28 | removeStorage(); 29 | } 30 | 31 | } -------------------------------------------------------------------------------- /src/tasks/parse-command-arguments-and-options.ts: -------------------------------------------------------------------------------- 1 | import arg from "arg"; 2 | import { IUser } from "../types"; 3 | import fs from "fs"; 4 | import { search, input } from "@inquirer/prompts"; 5 | import { isValidAddress, searchChallenges } from "../utils/helpers"; 6 | import { promptForMissingUserState } from "./prompt-for-missing-user-state"; 7 | 8 | type Commands = { 9 | setup: SetupCommand; 10 | submit: SubmitCommand; 11 | } 12 | 13 | type BaseOptions = { 14 | dev: boolean; 15 | help: boolean; 16 | }; 17 | 18 | type SetupCommand = { 19 | challenge: string | null, 20 | installLocation: string | null; 21 | } 22 | 23 | type SubmitCommand = { 24 | challenge: string | null; 25 | contractAddress: string | null; 26 | } 27 | 28 | export type CommandOptions = BaseOptions & { command: string | null } & SetupCommand & SubmitCommand; 29 | 30 | export type Choice = { 31 | value: Value; 32 | name?: string; 33 | description?: string; 34 | short?: string; 35 | disabled?: boolean | string; 36 | }; 37 | 38 | type SearchOptions = { 39 | type: "search"; 40 | name: string; 41 | message: string; 42 | source: (term: string | undefined) => Promise[]>; 43 | } 44 | 45 | type InputOptions = { 46 | type: "input"; 47 | name: string; 48 | message: string; 49 | validate: (value: string) => string | true; 50 | } 51 | 52 | const commandArguments = { 53 | setup: { 54 | 1: "challenge", 55 | 2: "installLocation" 56 | }, 57 | submit: { 58 | 1: "challenge", 59 | 2: "contractAddress" 60 | } 61 | }; 62 | 63 | export async function parseCommandArgumentsAndOptions( 64 | rawArgs: string[], 65 | ): Promise { 66 | const args = rawArgs.slice(2).map(a => a.toLowerCase()); 67 | const parsedArgs = arg( 68 | { 69 | "--dev": Boolean, 70 | "--help": Boolean, 71 | "-h": "--help", 72 | "--version": Boolean, 73 | "-v": "--version" 74 | }, 75 | { 76 | argv: args, 77 | }, 78 | ); 79 | 80 | const dev = parsedArgs["--dev"] ?? false; // info: use false avoid asking user 81 | 82 | const help = parsedArgs["--help"] ?? parsedArgs._[0] === 'help' ?? false; 83 | 84 | const version = parsedArgs["--version"] ?? parsedArgs._[0] === 'version' ?? false; 85 | 86 | const command = version ? 'version' : parsedArgs._[0] ?? null; 87 | 88 | const argumentObject: Partial = { 89 | dev, 90 | help, 91 | command, 92 | }; 93 | 94 | if (Object.keys(commandArguments).includes(command)) { 95 | const commandArgs = commandArguments[command as keyof Commands]; 96 | for (const key in commandArgs) { 97 | const argName: string = commandArgs[Number(key) as keyof typeof commandArgs]; 98 | argumentObject[argName as keyof Partial] = parsedArgs._[Number(key)] ?? null; 99 | } 100 | } 101 | return argumentObject as CommandOptions; 102 | } 103 | 104 | export async function promptForMissingCommandArgs(commands: CommandOptions, userState: IUser): Promise { 105 | const questions = []; 106 | 107 | const { command, challenge, contractAddress } = commands; 108 | let { installLocation } = commands; 109 | if (!installLocation) { 110 | installLocation = userState.installLocation; 111 | } 112 | 113 | if (command === "setup") { 114 | if (!challenge) { 115 | questions.push({ 116 | type: "search", 117 | name: "challenge", 118 | message: "Which challenge would you like to setup?", 119 | source: searchChallenges 120 | }); 121 | } 122 | if (!installLocation) { 123 | questions.push({ 124 | type: "input", 125 | name: "installLocation", 126 | message: "Where would you like to download the challenges?", 127 | default: process.cwd() + '/challenges', 128 | validate: (value: string) => fs.lstatSync(value).isDirectory(), 129 | }); 130 | } 131 | } 132 | 133 | if (command === "submit") { 134 | // Need user state so direct to promptForMissingUserState 135 | await promptForMissingUserState(userState, true); 136 | 137 | if (!challenge) { 138 | questions.push({ 139 | type: "search", 140 | name: "challenge", 141 | message: "Which challenge would you like to submit?", 142 | source: searchChallenges 143 | }); 144 | } 145 | if (!contractAddress) { 146 | questions.push({ 147 | type: "input", 148 | name: "contractAddress", 149 | message: "What is the contract address of your completed challenge?", 150 | validate: (value: string) => isValidAddress(value) ? true : "Please enter a valid contract address", 151 | }); 152 | } 153 | } 154 | const answers: Record = {}; 155 | for (const question of questions) { 156 | if (question.type === "search") { 157 | const answer = await search(question as unknown as SearchOptions); 158 | answers[question.name] = answer; 159 | } else if (question.type === "input") { 160 | const answer = await input(question as InputOptions); 161 | answers[question.name] = answer; 162 | } 163 | } 164 | 165 | return { 166 | ...commands, 167 | ...{ installLocation }, 168 | ...answers, 169 | }; 170 | } -------------------------------------------------------------------------------- /src/tasks/prompt-for-missing-user-state.ts: -------------------------------------------------------------------------------- 1 | import { getUser, upsertUser } from "../modules/api"; 2 | import { IUser } from "../types"; 3 | import { input } from "@inquirer/prompts"; 4 | import { saveUserState } from "../utils/state-manager"; 5 | import { isValidAddressOrENS, getDevice, checkValidPathOrCreate, isValidAddress } from "../utils/helpers"; 6 | 7 | // default values for unspecified args 8 | const defaultOptions: Partial = { 9 | installLocation: process.cwd() + '/challenges', 10 | }; 11 | 12 | export async function promptForMissingUserState( 13 | userState: IUser, 14 | skipInstallLocation: boolean = false 15 | ): Promise { 16 | const userDevice = getDevice(); 17 | let identifier = userState.address; 18 | 19 | if (!userState.address) { 20 | const answer = await input({ 21 | message: "Your wallet address (or ENS):", 22 | validate: isValidAddressOrENS, 23 | }); 24 | 25 | identifier = answer; 26 | } 27 | 28 | // Fetch the user data from the server - also handles ens resolution 29 | let user = await getUser(identifier as string); 30 | const newUser = !user?.address; 31 | const existingInstallLocation = user?.installLocations?.find((loc: {location: string, device: string}) => loc.device === userDevice); 32 | 33 | // New user 34 | if (newUser) { 35 | if (isValidAddress(identifier as string)) { 36 | user.address = identifier as string; 37 | } else { 38 | user.ens = identifier as string; 39 | } 40 | } 41 | 42 | // Prompt for install location if it doesn't exist on device 43 | if (!existingInstallLocation && !skipInstallLocation) { 44 | const answer = await input({ 45 | message: "Where would you like to download the challenges?", 46 | default: defaultOptions.installLocation, 47 | validate: checkValidPathOrCreate, 48 | }); 49 | 50 | // Create (or update) the user with their preferred install location for this device 51 | user.location = answer; 52 | user.device = userDevice; 53 | user = await upsertUser(user); 54 | } 55 | 56 | const { address, ens, installLocations, challenges, creationTimestamp } = user; 57 | const thisDeviceLocation = installLocations?.find((loc: {location: string, device: string}) => loc.device === userDevice); 58 | const newState = { address, ens, installLocation: thisDeviceLocation?.location, challenges, creationTimestamp }; 59 | if (JSON.stringify(userState) !== JSON.stringify(newState)) { 60 | // Save the new state locally 61 | await saveUserState(newState); 62 | } 63 | 64 | return newState; 65 | } 66 | -------------------------------------------------------------------------------- /src/tasks/render-intro-message.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | import { wait } from "../utils/helpers"; 3 | 4 | const MAX_VIEW_HEIGHT = 20; // Match the max height from index.ts 5 | 6 | export async function renderIntroMessage() { 7 | await checkTerminalSize(); 8 | console.clear(); 9 | const trimmedText = getTrimmedTitleText(); 10 | console.log(trimmedText); 11 | await wait(1500); 12 | console.clear(); 13 | } 14 | 15 | function getTrimmedTitleText(): string { 16 | const lines = TITLE_TEXT.split('\n').filter(line => line.length > 0); 17 | const { columns } = process.stdout; 18 | 19 | // Calculate the width of the longest line (without ANSI escape codes) 20 | const maxLineWidth = Math.max(...lines.map(line => 21 | line.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '').length 22 | )); 23 | 24 | // Calculate horizontal padding 25 | const horizontalPadding = Math.max(0, Math.floor((columns - maxLineWidth) / 2)); 26 | 27 | // Calculate vertical padding within MAX_VIEW_HEIGHT 28 | const verticalPadding = Math.max(0, Math.floor((MAX_VIEW_HEIGHT - lines.length) / 2)); 29 | 30 | // Add horizontal padding to each line 31 | const centeredLines = lines.map(line => ' '.repeat(horizontalPadding) + line); 32 | 33 | // Add vertical padding 34 | const verticallyPaddedLines = [ 35 | ...Array(verticalPadding).fill(''), 36 | ...centeredLines, 37 | ...Array(verticalPadding).fill('') 38 | ]; 39 | 40 | return verticallyPaddedLines.join('\n'); 41 | } 42 | 43 | async function checkTerminalSize(): Promise { 44 | const minRows = 16; 45 | const minCols = 80; 46 | const { rows, columns } = process.stdout; 47 | 48 | if (rows < minRows || columns < minCols) { 49 | console.clear(); 50 | console.error(`Terminal window too small. Minimum size required: ${minCols}x${minRows}`); 51 | console.error(`Current size: ${columns}x${rows}`); 52 | console.log("Please resize your terminal window and try again."); 53 | await wait(5000); 54 | process.exit(1); 55 | } 56 | } 57 | 58 | export const TITLE_TEXT = `${chalk.green('╔═══════════════════════════════════════════════════════════════╗')} 59 | ${chalk.green('║')}${chalk.blueBright(' ▗▄▄▄▖▗▄▄▄▖▗▖ ▗▖ ▗▄▄▄▖▗▄▄▄▖ ▗▄▄▖▗▖ ▗▖ ▗▄▄▄▖▗▄▄▖ ▗▄▄▄▖▗▄▄▄▖ ')}${chalk.green('║')} 60 | ${chalk.green('║')}${chalk.blueBright(' ▐▌ █ ▐▌ ▐▌ █ ▐▌ ▐▌ ▐▌ ▐▌ █ ▐▌ ▐▌▐▌ ▐▌ ')}${chalk.green('║')} 61 | ${chalk.green('║')}${chalk.blueBright(' ▐▛▀▀▘ █ ▐▛▀▜▌ █ ▐▛▀▀▘▐▌ ▐▛▀▜▌ █ ▐▛▀▚▖▐▛▀▀▘▐▛▀▀▘ ')}${chalk.green('║')} 62 | ${chalk.green('║')}${chalk.blueBright(' ▐▙▄▄▖ █ ▐▌ ▐▌ █ ▐▙▄▄▖▝▚▄▄▖▐▌ ▐▌ █ ▐▌ ▐▌▐▙▄▄▖▐▙▄▄▖ ')}${chalk.green('║')} 63 | ${chalk.green('╚═══════════════════════════════════════════════════════════════╝')}`; -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type Args = string[]; 2 | 3 | export type RawOptions = { 4 | project: string | null; 5 | install: boolean | null; 6 | dev: boolean; 7 | }; 8 | 9 | export interface IUserChallenge { 10 | challengeName: string 11 | status: string; 12 | lastFeedback: string; 13 | timestamp: Date; 14 | contractAddress: string; 15 | network: string; 16 | gasReport?: IGasReport[]; 17 | } 18 | 19 | export interface IGasReport { 20 | functionName: string; 21 | gasUsed: number; 22 | } 23 | 24 | export interface IChallenge { 25 | type: string; 26 | level: number; 27 | name: string; 28 | label: string; 29 | repo: string; 30 | tags: string[]; 31 | contractName: string; 32 | testFileName: string; 33 | childrenNames: string[]; 34 | enabled: boolean; 35 | description: string; 36 | } 37 | 38 | export interface IUser { 39 | address: string; 40 | ens: string; 41 | installLocation: string; 42 | creationTimestamp: number; 43 | challenges: IUserChallenge[]; 44 | } 45 | 46 | export type Actions = { 47 | [label: string]: () => Promise; 48 | } 49 | 50 | export type TreeNode = { 51 | label: string; 52 | shortname?: string; 53 | name: string; 54 | children: TreeNode[]; 55 | type: "header" | "challenge" | "quiz" | "capstone-project"; 56 | completed?: boolean; 57 | installed?: boolean; 58 | level?: number; 59 | unlocked?: boolean; 60 | actions?: Actions; 61 | repo?: string; 62 | message?: string; 63 | recursive?: boolean; 64 | } -------------------------------------------------------------------------------- /src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/utils/global-context-select-list.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createPrompt, 3 | useState, 4 | useKeypress, 5 | usePrefix, 6 | usePagination, 7 | useMemo, 8 | isEnterKey, 9 | isUpKey, 10 | isDownKey, 11 | Separator, 12 | ValidationError, 13 | makeTheme, 14 | type Theme, 15 | } from '@inquirer/core'; 16 | import type { PartialDeep } from '@inquirer/type'; 17 | import chalk from 'chalk'; 18 | 19 | type SelectTheme = { 20 | icon: { cursor: string }; 21 | style: { disabled: (text: string) => string }; 22 | }; 23 | 24 | const selectTheme: SelectTheme = { 25 | icon: { cursor: '❯' }, 26 | style: { disabled: (text: string) => chalk.dim(`- ${text}`) }, 27 | }; 28 | 29 | type GlobalChoice = { 30 | value: Value; 31 | key: string; 32 | } 33 | 34 | type Choice = { 35 | value: Value; 36 | name?: string; 37 | description?: string; 38 | disabled?: boolean | string; 39 | type?: never; 40 | }; 41 | 42 | type GlobalChoiceSelectConfig = { 43 | message: string; 44 | globalChoices: ReadonlyArray>; 45 | choices: ReadonlyArray | Separator>; 46 | pageSize?: number; 47 | loop?: boolean; 48 | default?: unknown; 49 | theme?: PartialDeep>; 50 | }; 51 | 52 | type GlobalChoiceSelectResult = { 53 | answer: Value; 54 | } 55 | 56 | type Item = Separator | Choice; 57 | 58 | function isSelectable(item: Item): item is Choice { 59 | return !Separator.isSeparator(item) && !item.disabled; 60 | } 61 | 62 | export default createPrompt( 63 | ( 64 | config: GlobalChoiceSelectConfig, 65 | done: (result: GlobalChoiceSelectResult) => void 66 | ): string => { 67 | const { choices: items, loop = true, pageSize = 7 } = config; 68 | const theme = makeTheme(selectTheme, config.theme); 69 | const prefix = usePrefix({ theme }); 70 | const [status, setStatus] = useState('pending'); 71 | 72 | const bounds = useMemo(() => { 73 | const first = items.findIndex(isSelectable); 74 | const last = items.findLastIndex(isSelectable); 75 | 76 | if (first < 0) { 77 | throw new ValidationError( 78 | '[select prompt] No selectable choices. All choices are disabled.', 79 | ); 80 | } 81 | 82 | return { first, last }; 83 | }, [items]); 84 | 85 | const defaultItemIndex = useMemo(() => { 86 | if (!('default' in config)) return -1; 87 | return items.findIndex( 88 | (item) => isSelectable(item) && item.value === config.default, 89 | ); 90 | }, [config.default, items]); 91 | 92 | const [active, setActive] = useState( 93 | defaultItemIndex === -1 ? bounds.first : defaultItemIndex, 94 | ); 95 | 96 | // Safe to assume the cursor position always point to a Choice. 97 | const selectedChoice = items[active] as Choice; 98 | 99 | useKeypress((key, rl) => { 100 | // Check for global choices first 101 | const globalChoice = config.globalChoices.find(choice => { 102 | if (!!choice.key.length) { 103 | return choice.key.includes(key.name); 104 | } else if (choice.key.includes(",")) { 105 | return choice.key.split(",").includes(key.name); 106 | } else if (choice.key.includes("ctrl+")) { 107 | return key.ctrl && key.name === choice.key.split("+")[1]; 108 | } 109 | return choice.key === key.name; 110 | }); 111 | 112 | if (globalChoice !== undefined) { 113 | setStatus('done'); 114 | done({ 115 | answer: globalChoice.value, 116 | }); 117 | return; 118 | } 119 | 120 | // Then check for visible choices 121 | if (isEnterKey(key)) { 122 | setStatus('done'); 123 | done({ 124 | answer: selectedChoice.value 125 | }); 126 | } else if (isUpKey(key) || isDownKey(key)) { 127 | rl.clearLine(0); 128 | if ( 129 | loop || 130 | (isUpKey(key) && active !== bounds.first) || 131 | (isDownKey(key) && active !== bounds.last) 132 | ) { 133 | const offset = isUpKey(key) ? -1 : 1; 134 | let next = active; 135 | do { 136 | next = (next + offset + items.length) % items.length; 137 | } while (!isSelectable(items[next]!)); 138 | setActive(next); 139 | } 140 | } 141 | }); 142 | 143 | const message = theme.style.message(config.message, status); 144 | 145 | const page = usePagination>({ 146 | items, 147 | active, 148 | renderItem({ item, isActive }: { item: Item; isActive: boolean }) { 149 | if (Separator.isSeparator(item)) { 150 | return ` ${item.separator}`; 151 | } 152 | 153 | const line = item.name || item.value; 154 | if (item.disabled) { 155 | const disabledLabel = 156 | typeof item.disabled === 'string' ? item.disabled : '(disabled)'; 157 | return theme.style.disabled(`${line} ${disabledLabel}`); 158 | } 159 | 160 | const color = isActive ? theme.style.highlight : (x: string) => x; 161 | const cursor = isActive ? theme.icon.cursor : ` `; 162 | return color(`${cursor} ${line}`); 163 | }, 164 | pageSize, 165 | loop, 166 | theme, 167 | }); 168 | 169 | if (status === 'done') { 170 | if (selectedChoice) { 171 | const answer = 172 | selectedChoice.name || 173 | String(selectedChoice.value); 174 | 175 | return `${prefix} ${message} ${theme.style.answer(answer)}`; 176 | } else { 177 | // Hidden action was triggered 178 | return `${prefix} ${message}`; 179 | } 180 | } 181 | 182 | const choiceDescription = selectedChoice?.description 183 | ? `\n${selectedChoice.description}` 184 | : ``; 185 | 186 | return `${[prefix, message].filter(Boolean).join(' ')}\n${page}${choiceDescription}${'\x1B[?25l'}`; 187 | }, 188 | ); -------------------------------------------------------------------------------- /src/utils/helpers.ts: -------------------------------------------------------------------------------- 1 | import os from "os"; 2 | import fs from "fs"; 3 | import { IChallenge } from "../types"; 4 | import { loadChallenges } from "./state-manager"; 5 | import { fetchChallenges } from "../modules/api"; 6 | import { Choice } from "../tasks/parse-command-arguments-and-options"; 7 | 8 | export function wait(ms: number) { 9 | return new Promise((resolve) => setTimeout(resolve, ms)); 10 | } 11 | 12 | export const checkValidPathOrCreate = async (path: string) => { 13 | try { 14 | const exists = fs.lstatSync(path).isDirectory(); 15 | if (!exists) { 16 | console.log('That path is not a directory'); 17 | return false; 18 | } 19 | return true; 20 | } catch (error) { 21 | // Try to create the directory 22 | try { 23 | fs.mkdirSync(path); 24 | return true; 25 | } catch (error) { 26 | console.error('Error creating directory:', error); 27 | return false; 28 | } 29 | } 30 | }; 31 | 32 | export const isValidAddress = (value: string): boolean => { 33 | return /^0x[a-fA-F0-9]{40}$/.test(value) 34 | }; 35 | 36 | export const isValidAddressOrENS = (value: string): boolean => { 37 | return /^(0x[a-fA-F0-9]{40}|.+\.eth)$/.test(value); 38 | }; 39 | 40 | export const getDevice = (): string => { 41 | const hostname = os.hostname(); 42 | const platform = os.platform(); 43 | const arch = os.arch(); 44 | return `${hostname}(${platform}:${arch})`; 45 | } 46 | 47 | export const stripAnsi = (text: string): string => { 48 | return text.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, ''); 49 | } 50 | 51 | export const calculatePoints = (completedChallenges: Array<{ challenge: IChallenge | undefined, completion: any }>): number => { 52 | const pointsPerLevel = [100, 150, 225, 300, 400, 500]; 53 | return completedChallenges 54 | .filter(c => c.challenge) 55 | .reduce((total, { challenge }) => { 56 | const points = pointsPerLevel[challenge!.level - 1] || 100; 57 | return total + points; 58 | }, 0); 59 | } 60 | 61 | export const searchChallenges = async (term: string = "") => { 62 | const challenges = (await fetchChallenges()).filter((challenge: IChallenge) => challenge.enabled); 63 | const choices = challenges.map((challenge: IChallenge) => ({ 64 | value: challenge.name, 65 | name: challenge.label, 66 | description: "" 67 | })); 68 | return choices.filter((choice: Choice) => choice.name?.toLowerCase().includes(term.toLowerCase())); 69 | } -------------------------------------------------------------------------------- /src/utils/leaderboard-view.ts: -------------------------------------------------------------------------------- 1 | import { TreeNode } from "../types"; 2 | import chalk from "chalk"; 3 | import { stripAnsi } from "./helpers"; 4 | 5 | interface LeaderboardEntry { 6 | address: string; 7 | ens: string | null; 8 | challengesCompleted: number; 9 | points: number; 10 | totalGasUsed: number; 11 | rank: number; 12 | } 13 | 14 | export class LeaderboardView { 15 | constructor(private leaderboard: LeaderboardEntry[], private userAddress: string) { 16 | this.userAddress = userAddress; 17 | } 18 | 19 | buildLeaderboardTree(): TreeNode { 20 | // Create individual entry nodes for each leaderboard position 21 | const entryNodes: TreeNode[] = this.leaderboard.map(entry => ({ 22 | type: "header", 23 | label: this.getEntryLabel(entry), 24 | shortname: entry.ens || entry.address, 25 | name: `rank-${entry.rank}`, 26 | children: [], 27 | message: this.buildEntryMessage(entry) 28 | })); 29 | 30 | // Create main leaderboard node 31 | return { 32 | type: "header", 33 | label: "Leaderboard", 34 | name: "leaderboard", 35 | children: entryNodes, 36 | message: this.buildLeaderboardMessage() 37 | }; 38 | } 39 | 40 | private getEntryLabel(entry: LeaderboardEntry): string { 41 | const identifier = entry.ens || entry.address; 42 | return chalk.white(`${this.getRankFormatting(entry.rank)} | ${this.formatSpacing(chalk.yellow(entry.points.toLocaleString()), 8)} | ${chalk.green(identifier)}`); 43 | } 44 | 45 | private getRankFormatting(rank: number): string { 46 | let rankString = rank.toString(); 47 | if (rank === 1) rankString = "🥇 " + rankString; 48 | if (rank === 2) rankString = "🥈 " + rankString; 49 | if (rank === 3) rankString = "🥉 " + rankString; 50 | return chalk.bold(this.formatSpacing(rankString, 5, false)); 51 | } 52 | 53 | private buildEntryMessage(entry: LeaderboardEntry): string { 54 | return `${chalk.bold(`Rank ${entry.rank}`)} 55 | Points: ${chalk.yellow(entry.points.toLocaleString())} 56 | Challenges Completed: ${chalk.blueBright(entry.challengesCompleted.toString())} 57 | Total Gas Used: ${chalk.green(entry.totalGasUsed.toLocaleString())} 58 | `; 59 | } 60 | 61 | private formatSpacing(text: string, width: number, center: boolean = true): string { 62 | const strippedText = stripAnsi(text); 63 | const textLength = strippedText.length; 64 | const totalPadding = width - textLength; 65 | 66 | if (totalPadding <= 0) return text; 67 | 68 | if (!center) { 69 | return " ".repeat(totalPadding) + text; 70 | } 71 | 72 | const leftPadding = Math.ceil(totalPadding / 2); 73 | const rightPadding = Math.floor(totalPadding / 2); 74 | 75 | return " ".repeat(leftPadding) + text + " ".repeat(rightPadding); 76 | } 77 | 78 | private getUserInfo(): LeaderboardEntry | undefined { 79 | return this.leaderboard.find(entry => entry.address === this.userAddress); 80 | } 81 | 82 | private buildLeaderboardMessage(): string { 83 | const userInfo = this.getUserInfo(); 84 | let statement = "Complete challenges to score points and climb the leaderboard!"; 85 | if (userInfo) { 86 | switch (userInfo.rank) { 87 | case 1: 88 | statement = "You are the TOP DOG!"; 89 | break; 90 | case 2: 91 | statement = "You are second place!\nKeep challenging yourself and see if you can catch up to the leader!\nTry to improve the gas efficiency of your past solutions."; 92 | break; 93 | case 3: 94 | statement = "You are third place!\nKeep challenging yourself and see if you can catch up to the leader!\nTry to improve the gas efficiency of your past solutions."; 95 | break; 96 | case 4: 97 | case 5: 98 | case 6: 99 | case 7: 100 | case 8: 101 | case 9: 102 | case 10: 103 | statement = `You are in the top ten!\nKeep challenging yourself and see if you can climb the leaderboard!\nTry to improve the gas efficiency of your past solutions.`; 104 | break; 105 | default: 106 | statement = `You are rank ${userInfo.rank}.\nKeep challenging yourself and see if you can climb the leaderboard!`; 107 | break; 108 | } 109 | } 110 | 111 | return chalk.bold(`${statement}\n\nTop Players\n Rank | Points | Player`); 112 | } 113 | } -------------------------------------------------------------------------------- /src/utils/progress-view.ts: -------------------------------------------------------------------------------- 1 | import { IUser, IChallenge, TreeNode } from "../types"; 2 | import chalk from "chalk"; 3 | import { calculatePoints } from "./helpers"; 4 | 5 | export class ProgressView { 6 | constructor( 7 | private userState: IUser, 8 | private challenges: IChallenge[], 9 | ) {} 10 | 11 | buildProgressTree(): TreeNode { 12 | const completedChallenges = this.userState.challenges 13 | .filter(c => c.status === "success") 14 | .map(c => ({ 15 | challenge: this.challenges.find(ch => ch.name === c.challengeName), 16 | completion: c 17 | })) 18 | .filter(c => c.challenge); 19 | 20 | const points = calculatePoints(completedChallenges); 21 | const completionRate = (completedChallenges.length / this.challenges.filter(c => c.enabled).length * 100).toFixed(1); 22 | 23 | // Create completed challenges node with all completed challenges as children 24 | const challengeNodes: TreeNode[] = completedChallenges.map(({ challenge, completion }) => { 25 | const children: TreeNode[] = []; 26 | 27 | // Add gas report node as a child if available 28 | if (completion.gasReport && completion.gasReport.length > 0) { 29 | children.push(this.buildGasReportNode(completion.gasReport)); 30 | } 31 | 32 | return { 33 | type: "header", 34 | label: challenge!.label, 35 | name: challenge!.name, 36 | children, 37 | message: this.buildChallengeMessage(challenge!, completion) 38 | }; 39 | }); 40 | 41 | // Create stats node 42 | const statsNode: TreeNode = { 43 | type: "header", 44 | label: "Progress", 45 | name: "stats", 46 | children: [...challengeNodes], 47 | message: this.buildStatsMessage(points, completionRate) 48 | }; 49 | 50 | return statsNode; 51 | } 52 | 53 | private buildStatsMessage(points: number, completionRate: string): string { 54 | const totalChallenges = this.challenges.filter(c => c.enabled).length; 55 | const completedChallenges = this.userState.challenges.filter(c => c.status === "success").length; 56 | return `Address: ${chalk.green(this.userState.ens || this.userState.address)} 57 | ${chalk.yellow(`Points Earned: ${points.toLocaleString()}`)} 58 | 59 | Challenges Completed: ${chalk.blueBright(`${completedChallenges}/${totalChallenges} (${completionRate}%)`)} 60 | ${completedChallenges ? "Details:" : ""}`; 61 | } 62 | 63 | private buildChallengeMessage(challenge: IChallenge, completion: any): string { 64 | let message = `Description: ${challenge.description}\n\n`; 65 | message += `Completion Date: ${chalk.blueBright(new Date(completion.timestamp).toLocaleString())}\n`; 66 | 67 | if (completion.contractAddress) { 68 | message += `Contract Address: ${chalk.blueBright(completion.contractAddress)}\n`; 69 | message += `Network: ${chalk.blueBright(completion.network)}\n`; 70 | } 71 | 72 | return message; 73 | } 74 | 75 | private buildGasReportNode(gasReport: Array<{ functionName: string; gasUsed: number }>): TreeNode { 76 | const gasInfo = gasReport.sort((a, b) => b.gasUsed - a.gasUsed); 77 | const totalGas = gasInfo.reduce((sum, g) => sum + g.gasUsed, 0); 78 | 79 | // Create individual gas entry nodes 80 | const nodes: TreeNode[] = gasInfo.map(({ functionName, gasUsed }) => ({ 81 | type: "header", 82 | label: `${functionName}: ${chalk.yellow(`${gasUsed.toLocaleString()} gas`)}`, 83 | name: `gas-entry-${functionName}`, 84 | children: [], 85 | message: `Function: ${functionName}\nGas Used: ${gasUsed.toLocaleString()} (${((gasUsed / totalGas) * 100).toFixed(1)}% of total)` 86 | })); 87 | 88 | return { 89 | type: "header", 90 | label: "Gas Report", 91 | name: "gas-report", 92 | children: nodes, 93 | message: `Total Gas Used: ${chalk.bold(totalGas.toLocaleString())}\nDetailed breakdown:` 94 | }; 95 | } 96 | } -------------------------------------------------------------------------------- /src/utils/state-manager.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { promisify } from 'util'; 3 | import path from 'path'; 4 | import { IChallenge, IUser } from '../types'; 5 | 6 | const writeFile = promisify(fs.writeFile); 7 | 8 | export async function saveUserState(state: IUser) { 9 | const configPath = path.join(process.cwd(), "storage"); 10 | if (!fs.existsSync(configPath)) { 11 | fs.mkdirSync(configPath); 12 | } 13 | const filePath = path.join(configPath, "user.json"); 14 | await writeFile(filePath, JSON.stringify(state, null, 2)); 15 | } 16 | 17 | export function loadUserState(): IUser { 18 | try { 19 | const configPath = path.join(process.cwd(), "storage", `user.json`); 20 | const data = fs.readFileSync(configPath, 'utf8'); 21 | return JSON.parse(data) as IUser; 22 | } catch (error: any) { 23 | if (error.code === 'ENOENT') { 24 | return {} as IUser; // Return empty object if file doesn't exist 25 | } 26 | throw error; 27 | } 28 | } 29 | 30 | export async function saveChallenges(challenges: IChallenge[]) { 31 | const configPath = path.join(process.cwd(), "storage"); 32 | 33 | if (!fs.existsSync(configPath)) { 34 | fs.mkdirSync(configPath); 35 | } 36 | const filePath = path.join(configPath, "challenges.json"); 37 | await writeFile(filePath, JSON.stringify(challenges, null, 2)); 38 | } 39 | 40 | export function loadChallenges(): IChallenge[] { 41 | try { 42 | const configPath = path.join(process.cwd(), "storage", `challenges.json`); 43 | const data = fs.readFileSync(configPath, 'utf8'); 44 | return JSON.parse(data); 45 | } catch (error: any) { 46 | if (error.code === 'ENOENT') { 47 | return []; // Return empty array if file doesn't exist 48 | } 49 | throw error; 50 | } 51 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "strict": true, 6 | "esModuleInterop": true, 7 | "sourceMap": true, 8 | "skipLibCheck": true, 9 | "moduleResolution": "node", 10 | "resolveJsonModule": true 11 | }, 12 | "exclude": ["node_modules"] 13 | } 14 | --------------------------------------------------------------------------------