├── .changeset ├── README.md └── config.json ├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── actions │ └── install-dependencies │ │ └── action.yml │ ├── publish-package.yml │ └── pull-requests.yml ├── .gitignore ├── .prettierrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── install.sh ├── jest-unit.config.ts ├── package.json ├── pnpm-lock.yaml ├── rollup.config.js ├── src ├── __tests__ │ ├── cli-flag-parsers.ts │ ├── example.ts │ └── rpc-and-monikers.ts ├── commands │ ├── balance.ts │ ├── build.ts │ ├── clone.ts │ ├── coverage.ts │ ├── deploy.ts │ ├── docs.ts │ ├── doctor.ts │ ├── index.ts │ ├── info.ts │ ├── inspect.ts │ ├── install.ts │ ├── self-update.ts │ ├── token.ts │ ├── token │ │ ├── create.ts │ │ ├── mint.ts │ │ └── transfer.ts │ └── validator.ts ├── const │ ├── commands.ts │ ├── setup.ts │ └── solana.ts ├── index.ts ├── lib │ ├── anchor.ts │ ├── app-info.ts │ ├── cargo.ts │ ├── cli │ │ ├── config.ts │ │ ├── help.ts │ │ ├── index.ts │ │ └── parsers │ │ │ ├── index.ts │ │ │ └── url.ts │ ├── gill │ │ ├── errors.ts │ │ └── keys.ts │ ├── git.ts │ ├── inspect │ │ ├── account.ts │ │ ├── block.ts │ │ ├── index.ts │ │ └── transaction.ts │ ├── install.ts │ ├── logger.ts │ ├── logs.ts │ ├── node.ts │ ├── npm.ts │ ├── programs.ts │ ├── prompts │ │ ├── build.ts │ │ ├── clone.ts │ │ ├── git.ts │ │ └── install.ts │ ├── setup.ts │ ├── shell │ │ ├── build.ts │ │ ├── clone.ts │ │ ├── deploy.ts │ │ ├── index.ts │ │ └── test-validator.ts │ ├── solana.ts │ ├── update.ts │ ├── utils.ts │ └── web3.ts └── types │ ├── anchor.ts │ ├── cargo.ts │ ├── config.ts │ ├── index.ts │ ├── inspect.ts │ └── solana.ts ├── tests └── Solana.toml └── tsconfig.json /.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/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.4/schema.json", 3 | "access": "public", 4 | "baseBranch": "master", 5 | "changelog": [ 6 | "@changesets/changelog-github", 7 | { 8 | "repo": "solana-foundation/mucho" 9 | } 10 | ], 11 | "commit": false, 12 | "fixed": [], 13 | "linked": [], 14 | "ignore": [], 15 | "updateInternalDependencies": "patch" 16 | } 17 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | #### Problem 2 | 3 | 4 | 5 | #### Summary of Changes 6 | 7 | 8 | 9 | Fixes # -------------------------------------------------------------------------------- /.github/workflows/actions/install-dependencies/action.yml: -------------------------------------------------------------------------------- 1 | name: Install Dependencies 2 | description: Sets up Node and its package manager, then installs all dependencies 3 | 4 | inputs: 5 | version: 6 | default: 'lts/*' 7 | type: string 8 | 9 | runs: 10 | using: composite 11 | steps: 12 | - name: Install package manager 13 | uses: pnpm/action-setup@v3 14 | with: 15 | version: 9.1.0 16 | 17 | - name: Setup Node 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: ${{ inputs.version }} 21 | cache: 'pnpm' 22 | 23 | - name: Install dependencies 24 | shell: bash 25 | run: pnpm install 26 | -------------------------------------------------------------------------------- /.github/workflows/publish-package.yml: -------------------------------------------------------------------------------- 1 | name: Version & Publish Package 2 | 3 | on: 4 | workflow_dispatch: 5 | branches: 6 | - master 7 | push: 8 | branches: 9 | - master 10 | 11 | env: 12 | # See https://consoledonottrack.com/ 13 | DO_NOT_TRACK: "1" 14 | 15 | jobs: 16 | build-and-publish-to-npm: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | 22 | - name: Install Dependencies 23 | uses: ./.github/workflows/actions/install-dependencies 24 | 25 | - name: Configure NPM token 26 | run: | 27 | pnpm config set '//registry.npmjs.org/:_authToken' "${NPM_TOKEN}" 28 | env: 29 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 30 | 31 | - name: Create Changesets Pull Request or Trigger an NPM Publish 32 | id: changesets 33 | uses: changesets/action@v1 34 | env: 35 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 36 | with: 37 | # This expects you to have a script called release which does a build for your packages and calls changeset publish 38 | publish: pnpm changeset:publish 39 | 40 | # - name: Push Git Tag 41 | # if: steps.changesets.outputs.hasChangesets == 'false' 42 | # run: | 43 | # VERSION_TAG=v$(cd packages/library/ && pnpm pkg get version | sed -n '2p' | grep -o '"\([^"]\)\+"$' | tr -d \") 44 | # if ! git ls-remote --tags | grep -q "$VERSION_TAG"; then git tag $VERSION_TAG && git push --tags; fi 45 | # env: 46 | # GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 47 | -------------------------------------------------------------------------------- /.github/workflows/pull-requests.yml: -------------------------------------------------------------------------------- 1 | name: Pull requests 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | all-pr-checks: 8 | runs-on: ubuntu-latest 9 | needs: build-and-test 10 | steps: 11 | - run: echo "Done" 12 | 13 | build-and-test: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node: 19 | - "current" 20 | - "lts/*" 21 | 22 | name: Build & Test on Node ${{ matrix.node }} 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v4 26 | 27 | - name: Install Dependencies 28 | uses: ./.github/workflows/actions/install-dependencies 29 | with: 30 | version: ${{ matrix.node }} 31 | 32 | - name: Build 33 | run: pnpm build 34 | - name: Unit Tests 35 | run: pnpm test 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.cache 6 | /.pnp 7 | .pnp.js 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | /dist 19 | /bin 20 | 21 | # misc 22 | .DS_Store 23 | *.pem 24 | 25 | # debug 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | 30 | # local env files 31 | .env 32 | !.env.example 33 | 34 | # vercel 35 | .vercel 36 | .turbo 37 | 38 | # contentlayer 39 | .contentlayer 40 | 41 | # typescript 42 | *.tsbuildinfo 43 | next-env.d.ts 44 | 45 | # sitemaps / robots 46 | public/sitemap* 47 | public/robot* 48 | 49 | test-ledger 50 | temp 51 | 52 | .cache -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false, 4 | "singleQuote": false, 5 | "bracketSpacing": true, 6 | "semi": true, 7 | "trailingComma": "all", 8 | "proseWrap": "always", 9 | "arrowParens": "always", 10 | "printWidth": 80 11 | } 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Solana Developers 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 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | ######################################## 5 | # Logging Functions 6 | ######################################## 7 | log_info() { 8 | printf "[INFO] %s\n" "$1" 9 | } 10 | 11 | log_error() { 12 | printf "[ERROR] %s\n" "$1" >&2 13 | } 14 | 15 | ######################################## 16 | # Install nvm and Node.js 17 | ######################################## 18 | install_nvm_and_node() { 19 | if [ -s "$HOME/.nvm/nvm.sh" ]; then 20 | log_info "NVM is already installed." 21 | else 22 | log_info "Installing NVM..." 23 | curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/master/install.sh | bash 24 | fi 25 | 26 | export NVM_DIR="$HOME/.nvm" 27 | # Immediately source nvm and bash_completion for the current session 28 | if [ -s "$NVM_DIR/nvm.sh" ]; then 29 | . "$NVM_DIR/nvm.sh" 30 | else 31 | log_error "nvm not found. Ensure it is installed correctly." 32 | fi 33 | 34 | if [ -s "$NVM_DIR/bash_completion" ]; then 35 | . "$NVM_DIR/bash_completion" 36 | fi 37 | 38 | if command -v node >/dev/null 2>&1; then 39 | local current_node 40 | current_node=$(node --version) 41 | local latest_node 42 | latest_node=$(nvm version-remote node) 43 | if [ "$current_node" = "$latest_node" ]; then 44 | log_info "Latest Node.js ($current_node) is already installed." 45 | else 46 | log_info "Updating Node.js: Installed ($current_node), Latest ($latest_node)." 47 | nvm install node 48 | nvm alias default node 49 | nvm use default 50 | fi 51 | else 52 | log_info "Installing Node.js via NVM..." 53 | nvm install node 54 | nvm alias default node 55 | nvm use default 56 | fi 57 | 58 | echo "" 59 | } 60 | 61 | 62 | ######################################## 63 | # Append nvm Initialization to the Correct Shell RC File 64 | ######################################## 65 | ensure_nvm_in_shell() { 66 | local shell_rc="" 67 | if [[ "$SHELL" == *"zsh"* ]]; then 68 | shell_rc="$HOME/.zshrc" 69 | elif [[ "$SHELL" == *"bash"* ]]; then 70 | shell_rc="$HOME/.bashrc" 71 | else 72 | shell_rc="$HOME/.profile" 73 | fi 74 | 75 | if [ -f "$shell_rc" ]; then 76 | if ! grep -q 'export NVM_DIR="$HOME/.nvm"' "$shell_rc"; then 77 | log_info "Appending nvm initialization to $shell_rc" 78 | { 79 | echo '' 80 | echo 'export NVM_DIR="$HOME/.nvm"' 81 | echo '[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm' 82 | } >> "$shell_rc" 83 | fi 84 | else 85 | log_info "$shell_rc does not exist, creating it with nvm initialization." 86 | echo 'export NVM_DIR="$HOME/.nvm"' > "$shell_rc" 87 | echo '[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm' >> "$shell_rc" 88 | fi 89 | } 90 | 91 | ######################################## 92 | # install mucho and the solana tooling 93 | ######################################## 94 | install_mucho(){ 95 | npx mucho@latest install 96 | } 97 | 98 | main() { 99 | install_nvm_and_node 100 | 101 | ensure_nvm_in_shell 102 | 103 | install_mucho 104 | 105 | echo "Installation complete. Please restart your terminal to apply all changes." 106 | } 107 | 108 | main "$@" -------------------------------------------------------------------------------- /jest-unit.config.ts: -------------------------------------------------------------------------------- 1 | import { Config } from "@jest/types"; 2 | 3 | const config: Partial = { 4 | resetMocks: true, 5 | restoreMocks: true, 6 | roots: ["/"], 7 | moduleNameMapper: { 8 | "^@/(.*)$": "/src/$1", 9 | }, 10 | displayName: { 11 | color: "grey", 12 | name: "Unit Test", 13 | }, 14 | transform: { 15 | "^.+\\.(ts|js)x?$": [ 16 | "@swc/jest", 17 | { 18 | jsc: { 19 | target: "es2020", 20 | }, 21 | }, 22 | ], 23 | }, 24 | }; 25 | 26 | export default config; 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mucho", 3 | "version": "0.10.0", 4 | "description": "", 5 | "license": "MIT", 6 | "type": "module", 7 | "publishConfig": { 8 | "access": "public" 9 | }, 10 | "main": "./bin/index.mjs", 11 | "scripts": { 12 | "dev": "pnpm build && pnpm mucho", 13 | "build": "rollup -c", 14 | "watch": "rollup -c --watch", 15 | "test": "pnpm test:unit", 16 | "test:unit": "jest -c ./jest-unit.config.ts --rootDir . --silent", 17 | "test:unit:watch": "jest -c ./jest-unit.config.ts --rootDir . --watch", 18 | "mucho": "node ./bin/index.mjs", 19 | "changeset:version": "changeset version && git add -A && git commit -m \"chore: version\"", 20 | "changeset:publish": "pnpm build && changeset publish", 21 | "style:fix": "prettier --write 'src/{*,**/*}.{ts,tsx,js,jsx,css,json,md}'" 22 | }, 23 | "bin": { 24 | "mucho": "bin/index.mjs" 25 | }, 26 | "files": [ 27 | "/bin" 28 | ], 29 | "dependencies": { 30 | "@commander-js/extra-typings": "^12.1.0", 31 | "@iarna/toml": "^2.2.5", 32 | "@inquirer/prompts": "^7.0.0", 33 | "@solana/errors": "^2.1.0", 34 | "cli-table3": "^0.6.5", 35 | "commander": "^12.1.0", 36 | "dotenv": "^16.4.5", 37 | "gill": "^0.6.0", 38 | "inquirer": "^12.0.0", 39 | "ora": "^8.1.0", 40 | "picocolors": "^1.1.1", 41 | "punycode": "^2.3.1", 42 | "shell-exec": "^1.1.2", 43 | "yaml": "^2.6.0" 44 | }, 45 | "devDependencies": { 46 | "@changesets/changelog-github": "^0.5.0", 47 | "@changesets/cli": "^2.27.9", 48 | "@jest/types": "^29.6.3", 49 | "@rollup/plugin-commonjs": "^28.0.1", 50 | "@rollup/plugin-json": "^6.1.0", 51 | "@rollup/plugin-node-resolve": "^15.3.0", 52 | "@rollup/plugin-terser": "^0.4.4", 53 | "@rollup/plugin-typescript": "^12.1.1", 54 | "@swc/core": "^1.9.3", 55 | "@swc/jest": "^0.2.37", 56 | "@types/bn.js": "^5.1.6", 57 | "@types/inquirer": "^9.0.7", 58 | "@types/jest": "^29.5.14", 59 | "@types/node": "^22.7.6", 60 | "@types/prompts": "^2.4.9", 61 | "jest": "^29.7.0", 62 | "rollup": "^4.24.0", 63 | "ts-jest": "^29.2.5", 64 | "ts-node": "^10.9.2", 65 | "tslib": "^2.8.0", 66 | "typescript": "^5.6.3" 67 | }, 68 | "packageManager": "pnpm@9.1.0", 69 | "keywords": [], 70 | "homepage": "https://github.com/solana-foundation/mucho#readme", 71 | "bugs": { 72 | "url": "https://github.com/solana-foundation/mucho/issues" 73 | }, 74 | "repository": { 75 | "name": "solana-foundation/mucho", 76 | "type": "git", 77 | "url": "git+https://github.com/solana-foundation/mucho.git" 78 | }, 79 | "author": "nickfrosty", 80 | "contributors": [ 81 | { 82 | "name": "Nick Frostbutter", 83 | "url": "https://github.com/nickfrosty" 84 | } 85 | ] 86 | } 87 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from "@rollup/plugin-typescript"; 2 | import terser from "@rollup/plugin-terser"; 3 | import json from "@rollup/plugin-json"; 4 | import nodeResolve from "@rollup/plugin-node-resolve"; 5 | import commonjs from "@rollup/plugin-commonjs"; 6 | 7 | export default { 8 | input: "src/index.ts", 9 | output: { 10 | dir: "bin", 11 | format: "esm", 12 | entryFileNames: "[name].mjs", 13 | }, 14 | plugins: [ 15 | commonjs(), 16 | nodeResolve({ 17 | exportConditions: ["node"], 18 | // prevent using the deprecated punycode module 19 | preferBuiltins: (module) => module != "punycode", 20 | }), 21 | json(), 22 | typescript(), 23 | terser({ 24 | format: { 25 | comments: "some", 26 | beautify: true, 27 | }, 28 | compress: { 29 | drop_console: ["warn"], 30 | }, 31 | mangle: false, 32 | module: true, 33 | }), 34 | ], 35 | }; 36 | -------------------------------------------------------------------------------- /src/__tests__/cli-flag-parsers.ts: -------------------------------------------------------------------------------- 1 | import { getPublicSolanaRpcUrl } from "gill"; 2 | import { getClusterMonikerFromUrl, parseRpcUrlOrMoniker } from "@/lib/solana"; 3 | import { parseOptionsFlagForRpcUrl } from "@/lib/cli/parsers"; 4 | 5 | jest.mock("gill", () => ({ 6 | getPublicSolanaRpcUrl: jest.fn(), 7 | })); 8 | 9 | jest.mock("@/lib/solana", () => ({ 10 | getClusterMonikerFromUrl: jest.fn(), 11 | parseRpcUrlOrMoniker: jest.fn(), 12 | })); 13 | 14 | describe("parseOptionsFlagForRpcUrl", () => { 15 | beforeEach(() => { 16 | jest.clearAllMocks(); 17 | 18 | (getPublicSolanaRpcUrl as jest.Mock).mockImplementation((cluster) => { 19 | const urls = { 20 | mainnet: "https://api.mainnet-beta.solana.com", 21 | devnet: "https://api.devnet.solana.com", 22 | testnet: "https://api.testnet.solana.com", 23 | localnet: "http://localhost:8899", 24 | }; 25 | return urls[cluster] || urls.devnet; 26 | }); 27 | 28 | (getClusterMonikerFromUrl as jest.Mock).mockImplementation((url) => { 29 | const urlStr = url.toString(); 30 | if (urlStr.includes("devnet")) return "devnet"; 31 | if (urlStr.includes("testnet")) return "testnet"; 32 | if (urlStr.includes("mainnet-beta")) return "mainnet-beta"; 33 | if (urlStr.includes("localhost") || urlStr.includes("127.0.0.1")) 34 | return "localhost"; 35 | throw new Error("Unable to determine moniker from RPC url"); 36 | }); 37 | 38 | (parseRpcUrlOrMoniker as jest.Mock).mockImplementation((input) => { 39 | if (input.startsWith("d")) return "devnet"; 40 | if (input.startsWith("t")) return "testnet"; 41 | if (input.startsWith("m")) return "mainnet"; 42 | if (input.startsWith("l")) return "localhost"; 43 | return "devnet"; // Default fallback 44 | }); 45 | }); 46 | 47 | // Test URL input scenarios 48 | test("should correctly parse URL string input", () => { 49 | const result = parseOptionsFlagForRpcUrl("https://api.devnet.solana.com"); 50 | 51 | expect(getClusterMonikerFromUrl).toHaveBeenCalledWith( 52 | "https://api.devnet.solana.com", 53 | ); 54 | expect(result.cluster).toBe("devnet"); 55 | expect(result.url.toString()).toBe("https://api.devnet.solana.com/"); 56 | }); 57 | 58 | test("should correctly parse URL with sub paths input", () => { 59 | const result = parseOptionsFlagForRpcUrl( 60 | "https://api.devnet.solana.com/path/another-one", 61 | ); 62 | 63 | expect(getClusterMonikerFromUrl).toHaveBeenCalledWith( 64 | "https://api.devnet.solana.com/path/another-one", 65 | ); 66 | expect(result.cluster).toBe("devnet"); 67 | expect(result.url.toString()).toBe( 68 | "https://api.devnet.solana.com/path/another-one", 69 | ); 70 | }); 71 | 72 | test("should correctly parse URL with search params input", () => { 73 | const result = parseOptionsFlagForRpcUrl( 74 | "https://api.devnet.solana.com/?token=whatever", 75 | ); 76 | 77 | expect(getClusterMonikerFromUrl).toHaveBeenCalledWith( 78 | "https://api.devnet.solana.com/?token=whatever", 79 | ); 80 | expect(result.cluster).toBe("devnet"); 81 | expect(result.url.toString()).toBe( 82 | "https://api.devnet.solana.com/?token=whatever", 83 | ); 84 | }); 85 | 86 | test("should correctly parse URL object input", () => { 87 | const urlObj = new URL("https://api.testnet.solana.com"); 88 | const result = parseOptionsFlagForRpcUrl(urlObj); 89 | 90 | expect(getClusterMonikerFromUrl).toHaveBeenCalledWith( 91 | "https://api.testnet.solana.com/", 92 | ); 93 | expect(result.cluster).toBe("testnet"); 94 | expect(result.url.toString()).toBe("https://api.testnet.solana.com/"); 95 | }); 96 | 97 | // Test moniker input scenarios 98 | test("should correctly parse moniker string input", () => { 99 | const result = parseOptionsFlagForRpcUrl("devnet"); 100 | 101 | expect(parseRpcUrlOrMoniker).toHaveBeenCalledWith("devnet", false); 102 | expect(getPublicSolanaRpcUrl).toHaveBeenCalledWith("devnet"); 103 | expect(result.cluster).toBe("devnet"); 104 | expect(result.url.toString()).toBe("https://api.devnet.solana.com/"); 105 | }); 106 | 107 | test("should handle localhost moniker correctly", () => { 108 | (parseRpcUrlOrMoniker as jest.Mock).mockReturnValueOnce("localhost"); 109 | 110 | const result = parseOptionsFlagForRpcUrl("localhost"); 111 | 112 | expect(parseRpcUrlOrMoniker).toHaveBeenCalledWith("localhost", false); 113 | expect(getPublicSolanaRpcUrl).toHaveBeenCalledWith("localnet"); 114 | expect(result.cluster).toBe("localhost"); 115 | }); 116 | 117 | // Test fallback scenarios 118 | test("should use fallback URL when input is undefined", () => { 119 | const result = parseOptionsFlagForRpcUrl( 120 | undefined, 121 | "https://api.mainnet-beta.solana.com", 122 | ); 123 | 124 | expect(result.cluster).toBe("mainnet-beta"); 125 | expect(result.url.toString()).toBe("https://api.mainnet-beta.solana.com/"); 126 | }); 127 | 128 | test("should use fallback moniker when input is undefined", () => { 129 | const result = parseOptionsFlagForRpcUrl(undefined, "testnet"); 130 | 131 | expect(parseRpcUrlOrMoniker).toHaveBeenCalledWith("testnet"); 132 | expect(result.cluster).toBe("testnet"); 133 | }); 134 | 135 | test("should fallback to devnet cluster when URL cluster cannot be determined", () => { 136 | (getClusterMonikerFromUrl as jest.Mock).mockImplementationOnce(() => { 137 | throw new Error("Unable to determine moniker from RPC url"); 138 | }); 139 | 140 | const result = parseOptionsFlagForRpcUrl("https://custom-rpc.example.com"); 141 | 142 | expect(result.cluster).toBe("devnet"); 143 | expect(result.url.toString()).toBe("https://custom-rpc.example.com/"); 144 | }); 145 | 146 | // Test error scenarios 147 | test("should throw error when fallback URL is invalid", () => { 148 | (parseRpcUrlOrMoniker as jest.Mock).mockImplementationOnce(() => { 149 | throw new Error("Parse error"); 150 | }); 151 | 152 | expect(() => { 153 | parseOptionsFlagForRpcUrl(undefined, "invalid-input"); 154 | }).toThrow("Unable to parse fallbackUrlOrMoniker: invalid-input"); 155 | }); 156 | 157 | test("should throw error when no input or fallbackUrl is provided", () => { 158 | expect(() => { 159 | parseOptionsFlagForRpcUrl(undefined, undefined); 160 | }).toThrow("Invalid input provided for parsing the RPC url"); 161 | }); 162 | }); 163 | -------------------------------------------------------------------------------- /src/__tests__/example.ts: -------------------------------------------------------------------------------- 1 | // import assert from "node:assert"; 2 | 3 | describe("scaffold", () => { 4 | test("example test", () => { 5 | // do a thing 6 | // assert.equal(think1, "thing2"); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /src/__tests__/rpc-and-monikers.ts: -------------------------------------------------------------------------------- 1 | import { parseRpcUrlOrMoniker, getClusterMonikerFromUrl } from "@/lib/solana"; 2 | 3 | describe("parseRpcUrlOrMoniker", () => { 4 | // Test URL parsing 5 | test("should properly parse valid URLs", () => { 6 | expect(parseRpcUrlOrMoniker("https://api.mainnet-beta.solana.com")).toBe( 7 | "https://api.mainnet-beta.solana.com/", 8 | ); 9 | expect( 10 | parseRpcUrlOrMoniker("https://api.mainnet-beta.solana.com", true), 11 | ).toBe("https://api.mainnet-beta.solana.com/"); 12 | 13 | expect(parseRpcUrlOrMoniker("http://localhost:8899")).toBe( 14 | "http://localhost:8899/", 15 | ); 16 | }); 17 | 18 | test("should not parse URLs when allowUrl is false", () => { 19 | expect(() => 20 | parseRpcUrlOrMoniker("https://api.mainnet-beta.solana.com", false), 21 | ).toThrow(); 22 | }); 23 | 24 | // Test moniker parsing 25 | test("should parse localhost/localnet monikers", () => { 26 | expect(parseRpcUrlOrMoniker("local")).toBe("localhost"); 27 | expect(parseRpcUrlOrMoniker("localhost")).toBe("localhost"); 28 | expect(parseRpcUrlOrMoniker("l")).toBe("localhost"); 29 | // note: solana cli only supports `localhost` as a value, not `localnet` 30 | expect(parseRpcUrlOrMoniker("localnet")).toBe("localhost"); 31 | }); 32 | 33 | test("should parse testnet monikers", () => { 34 | expect(parseRpcUrlOrMoniker("testnet")).toBe("testnet"); 35 | expect(parseRpcUrlOrMoniker("t")).toBe("testnet"); 36 | }); 37 | 38 | test("should parse devnet monikers", () => { 39 | expect(parseRpcUrlOrMoniker("devnet")).toBe("devnet"); 40 | expect(parseRpcUrlOrMoniker("d")).toBe("devnet"); 41 | }); 42 | 43 | test("should parse mainnet monikers", () => { 44 | expect(parseRpcUrlOrMoniker("mainnet-beta")).toBe("mainnet-beta"); 45 | expect(parseRpcUrlOrMoniker("m")).toBe("mainnet-beta"); 46 | 47 | // note: solana cli only supports `mainnet-beta` as a value, not `mainnet` 48 | expect(parseRpcUrlOrMoniker("mainnet")).toBe("mainnet-beta"); 49 | }); 50 | 51 | test("should not parse unknown monikers", () => { 52 | expect(() => parseRpcUrlOrMoniker("unknown", false)).toThrow(); 53 | expect(() => parseRpcUrlOrMoniker("unknown")).toThrow(); 54 | expect(() => parseRpcUrlOrMoniker("unknown", true)).toThrow(); 55 | }); 56 | 57 | // Test case insensitivity 58 | test("should be case insensitive for monikers", () => { 59 | expect(parseRpcUrlOrMoniker("MAINNET")).toBe("mainnet-beta"); 60 | expect(parseRpcUrlOrMoniker("Devnet")).toBe("devnet"); 61 | expect(parseRpcUrlOrMoniker("TestNet")).toBe("testnet"); 62 | expect(parseRpcUrlOrMoniker("LocalHost")).toBe("localhost"); 63 | }); 64 | }); 65 | 66 | describe("getClusterMonikerFromUrl", () => { 67 | test("should identify devnet from url", () => { 68 | expect(getClusterMonikerFromUrl("https://api.devnet.solana.com")).toBe( 69 | "devnet", 70 | ); 71 | expect(getClusterMonikerFromUrl("http://api.devnet.solana.com/")).toBe( 72 | "devnet", 73 | ); 74 | expect( 75 | getClusterMonikerFromUrl("http://api.devnet.solana.com/some/path"), 76 | ).toBe("devnet"); 77 | expect( 78 | getClusterMonikerFromUrl(new URL("https://api.devnet.solana.com")), 79 | ).toBe("devnet"); 80 | }); 81 | 82 | test("should identify testnet from url", () => { 83 | expect(getClusterMonikerFromUrl("https://api.testnet.solana.com")).toBe( 84 | "testnet", 85 | ); 86 | expect(getClusterMonikerFromUrl("http://api.testnet.solana.com")).toBe( 87 | "testnet", 88 | ); 89 | expect( 90 | getClusterMonikerFromUrl(new URL("https://api.testnet.solana.com")), 91 | ).toBe("testnet"); 92 | }); 93 | 94 | test("should identify mainnet-beta from url", () => { 95 | expect( 96 | getClusterMonikerFromUrl("https://api.mainnet-beta.solana.com"), 97 | ).toBe("mainnet-beta"); 98 | expect(getClusterMonikerFromUrl("http://api.mainnet-beta.solana.com")).toBe( 99 | "mainnet-beta", 100 | ); 101 | expect( 102 | getClusterMonikerFromUrl(new URL("https://api.mainnet-beta.solana.com")), 103 | ).toBe("mainnet-beta"); 104 | }); 105 | 106 | test("should identify localhost from various local urls", () => { 107 | expect(getClusterMonikerFromUrl("http://localhost")).toBe("localhost"); 108 | expect(getClusterMonikerFromUrl("http://localhost:8899")).toBe("localhost"); 109 | expect(getClusterMonikerFromUrl("http://127.0.0.1")).toBe("localhost"); 110 | expect(getClusterMonikerFromUrl("http://127.0.0.1:8899")).toBe("localhost"); 111 | expect(getClusterMonikerFromUrl("http://0.0.0.0")).toBe("localhost"); 112 | expect(getClusterMonikerFromUrl("http://0.0.0.0:8899")).toBe("localhost"); 113 | expect(getClusterMonikerFromUrl(new URL("http://localhost:8899"))).toBe( 114 | "localhost", 115 | ); 116 | }); 117 | 118 | // Test error cases 119 | test("should throw error for invalid url strings", () => { 120 | expect(() => getClusterMonikerFromUrl("not-a-url")).toThrow( 121 | "Unable to parse RPC url", 122 | ); 123 | expect(() => getClusterMonikerFromUrl("")).toThrow( 124 | "Unable to parse RPC url", 125 | ); 126 | }); 127 | 128 | test("should throw error for unrecognized hostnames", () => { 129 | expect(() => getClusterMonikerFromUrl("https://example.com")).toThrow( 130 | "Unable to determine moniker from RPC url", 131 | ); 132 | expect(() => 133 | getClusterMonikerFromUrl("https://unknown-rpc.solana.com"), 134 | ).toThrow("Unable to determine moniker from RPC url"); 135 | expect(() => getClusterMonikerFromUrl("https://api.solana.com")).toThrow( 136 | "Unable to determine moniker from RPC url", 137 | ); 138 | }); 139 | 140 | // Test edge cases 141 | test("should handle URLs with different protocols", () => { 142 | expect(getClusterMonikerFromUrl("wss://api.devnet.solana.com")).toBe( 143 | "devnet", 144 | ); 145 | expect(getClusterMonikerFromUrl("ws://api.testnet.solana.com")).toBe( 146 | "testnet", 147 | ); 148 | }); 149 | 150 | test("should handle URLs with query parameters", () => { 151 | expect( 152 | getClusterMonikerFromUrl( 153 | "https://api.mainnet-beta.solana.com?param=value", 154 | ), 155 | ).toBe("mainnet-beta"); 156 | }); 157 | }); 158 | -------------------------------------------------------------------------------- /src/commands/balance.ts: -------------------------------------------------------------------------------- 1 | import { Argument, Command } from "@commander-js/extra-typings"; 2 | import { cliOutputConfig } from "@/lib/cli"; 3 | import ora from "ora"; 4 | 5 | import { getRunningTestValidatorCommand } from "@/lib/shell/test-validator"; 6 | import { 7 | Address, 8 | createSolanaRpc, 9 | getPublicSolanaRpcUrl, 10 | isAddress, 11 | lamportsToSol, 12 | } from "gill"; 13 | import { getAddressFromStringOrFilePath } from "@/lib/solana"; 14 | import { DEFAULT_CLI_KEYPAIR_PATH } from "gill/node"; 15 | 16 | /** 17 | * Command: `balance` 18 | * 19 | * Get the desired account's balances 20 | */ 21 | export function balanceCommand() { 22 | return new Command("balance") 23 | .configureOutput(cliOutputConfig) 24 | .addArgument( 25 | new Argument( 26 | "[address]", 27 | `account balance to check, either a base58 address or path to a keypair json file ` + 28 | `\n(default: ${DEFAULT_CLI_KEYPAIR_PATH}`, 29 | ), 30 | ) 31 | .description("get the balances for an account") 32 | .action(async (addressOrKeypairPath) => { 33 | if (!addressOrKeypairPath) 34 | addressOrKeypairPath = DEFAULT_CLI_KEYPAIR_PATH; 35 | 36 | const spinner = ora("Getting balances").start(); 37 | 38 | const info: string[] = []; 39 | let address: Address; 40 | 41 | try { 42 | address = await getAddressFromStringOrFilePath(addressOrKeypairPath); 43 | 44 | if (!isAddress(address)) throw new Error("Invalid address"); 45 | } catch (err) { 46 | throw new Error("Unable to parse the provided address"); 47 | } 48 | 49 | const testValidatorChecker = getRunningTestValidatorCommand(); 50 | 51 | // build the test validator port, with support for an manually defined one 52 | let localnetClusterUrl = "http://127.0.0.1:8899"; 53 | if ( 54 | !!testValidatorChecker && 55 | testValidatorChecker.includes("--rpc-port") 56 | ) { 57 | localnetClusterUrl = `http://127.0.0.1:${ 58 | testValidatorChecker.match(/--rpc-port\s+(\d+)/)[1] || 8899 59 | }`; 60 | } 61 | 62 | const { devnet, mainnet, testnet, localnet } = { 63 | mainnet: createSolanaRpc(getPublicSolanaRpcUrl("mainnet-beta")), 64 | devnet: createSolanaRpc(getPublicSolanaRpcUrl("devnet")), 65 | testnet: createSolanaRpc(getPublicSolanaRpcUrl("testnet")), 66 | localnet: createSolanaRpc(localnetClusterUrl), 67 | }; 68 | 69 | info.push("Address: " + address); 70 | 71 | const balances = { 72 | mainnet: 73 | (await mainnet 74 | .getBalance(address) 75 | .send() 76 | .then((res) => res.value)) || false, 77 | devnet: 78 | (await devnet 79 | .getBalance(address) 80 | .send() 81 | .then((res) => res.value)) || false, 82 | testnet: 83 | (await testnet 84 | .getBalance(address) 85 | .send() 86 | .then((res) => res.value)) || false, 87 | localnet: !!testValidatorChecker 88 | ? (await localnet 89 | .getBalance(address) 90 | .send() 91 | .then((res) => res.value)) || false 92 | : false, 93 | }; 94 | 95 | info.push("Balances for address:"); 96 | for (const cluster in balances) { 97 | info.push( 98 | " - " + 99 | cluster + 100 | ": " + 101 | `${lamportsToSol(balances[cluster] ?? 0n)} SOL`, 102 | ); 103 | } 104 | 105 | info.push("Is test-validator running? " + !!testValidatorChecker); 106 | if (!!testValidatorChecker) { 107 | info.push("Localnet url: " + localnetClusterUrl); 108 | } 109 | 110 | spinner.stop(); 111 | 112 | console.log(info.join("\n")); 113 | }); 114 | } 115 | -------------------------------------------------------------------------------- /src/commands/build.ts: -------------------------------------------------------------------------------- 1 | import { Command, Option } from "@commander-js/extra-typings"; 2 | import { cliOutputConfig } from "@/lib/cli"; 3 | import { titleMessage, warningOutro, warnMessage } from "@/lib/logs"; 4 | import { checkCommand, shellExecInSession } from "@/lib/shell"; 5 | import { COMMON_OPTIONS } from "@/const/commands"; 6 | import { autoLocateProgramsInWorkspace, loadCargoToml } from "@/lib/cargo"; 7 | import { buildProgramCommand } from "@/lib/shell/build"; 8 | import { doesFileExist } from "@/lib/utils"; 9 | import { logger } from "@/lib/logger"; 10 | 11 | /** 12 | * Command: `build` 13 | * 14 | * Build the programs located in the user's repo 15 | */ 16 | export function buildCommand() { 17 | return new Command("build") 18 | .configureOutput(cliOutputConfig) 19 | .description("build your Solana programs") 20 | .usage("[options] [-- ...]") 21 | .addOption( 22 | new Option( 23 | "-- ", 24 | `arguments to pass to the underlying 'cargo build-sbf' command`, 25 | ), 26 | ) 27 | .addOption( 28 | new Option( 29 | "-p --program-name ", 30 | "name of the program to build", 31 | ), 32 | ) 33 | .addOption(COMMON_OPTIONS.manifestPath) 34 | .addOption(COMMON_OPTIONS.config) 35 | .addOption(COMMON_OPTIONS.outputOnly) 36 | .action(async (options, { args: passThroughArgs }) => { 37 | if (!options.outputOnly) { 38 | titleMessage("Build your Solana programs"); 39 | } 40 | 41 | await checkCommand("cargo build-sbf --help", { 42 | exit: true, 43 | message: 44 | "Unable to detect the 'cargo build-sbf' command. Do you have it installed?", 45 | }); 46 | 47 | let { programs, cargoToml } = autoLocateProgramsInWorkspace( 48 | options.manifestPath, 49 | ); 50 | 51 | // only build a single program 52 | if (options.programName) { 53 | if ( 54 | programs.has(options.programName) && 55 | doesFileExist(programs.get(options.programName)) 56 | ) { 57 | cargoToml = loadCargoToml(programs.get(options.programName)); 58 | } else { 59 | warnMessage( 60 | `Unable to locate program '${options.programName}' in this workspace`, 61 | ); 62 | console.log(`The following programs were located:`); 63 | 64 | programs.forEach((_programPath, programName) => 65 | console.log(" -", programName), 66 | ); 67 | 68 | // todo: should we prompt the user to select a valid program? 69 | logger.exit(); 70 | } 71 | } 72 | 73 | if (!cargoToml) { 74 | return warningOutro( 75 | `Unable to locate Cargo.toml file. Operation canceled.`, 76 | ); 77 | } 78 | 79 | let buildCommand: null | string = null; 80 | 81 | if (cargoToml.workspace) { 82 | console.log("Building all programs in the workspace"); 83 | buildCommand = buildProgramCommand({ 84 | // no manifest file will attempt to build the whole workspace 85 | manifestPath: cargoToml.configPath, 86 | workspace: true, 87 | }); 88 | } else if ( 89 | cargoToml.package && 90 | cargoToml.lib["crate-type"].includes("lib") 91 | ) { 92 | console.log( 93 | `Building program '${ 94 | cargoToml.lib.name || cargoToml.package.name || "[unknown]" 95 | }':`, 96 | ); 97 | 98 | buildCommand = buildProgramCommand({ 99 | // a single program manifest will build only that one program 100 | manifestPath: cargoToml.configPath, 101 | }); 102 | } else { 103 | return warningOutro(`Unable to locate any program's Cargo.toml file`); 104 | } 105 | 106 | if (!buildCommand) { 107 | return warningOutro(`Unable to create build command`); 108 | } 109 | 110 | shellExecInSession({ 111 | command: buildCommand, 112 | args: passThroughArgs, 113 | outputOnly: options.outputOnly, 114 | }); 115 | }); 116 | } 117 | -------------------------------------------------------------------------------- /src/commands/clone.ts: -------------------------------------------------------------------------------- 1 | import { Command, Option } from "@commander-js/extra-typings"; 2 | import { cliOutputConfig, loadConfigToml } from "@/lib/cli"; 3 | import { titleMessage, warnMessage } from "@/lib/logs"; 4 | import { checkCommand } from "@/lib/shell"; 5 | import { 6 | createFolders, 7 | loadFileNamesToMap, 8 | moveFiles, 9 | updateGitignore, 10 | } from "@/lib/utils"; 11 | import { 12 | cloneAccountsFromConfig, 13 | cloneProgramsFromConfig, 14 | cloneTokensFromConfig, 15 | mergeOwnersMapWithConfig, 16 | validateExpectedCloneCounts, 17 | } from "@/lib/shell/clone"; 18 | import { COMMON_OPTIONS } from "@/const/commands"; 19 | import { 20 | DEFAULT_ACCOUNTS_DIR_TEMP, 21 | DEFAULT_CACHE_DIR, 22 | DEFAULT_TEST_LEDGER_DIR, 23 | } from "@/const/solana"; 24 | import { rmSync } from "fs"; 25 | import { deconflictAnchorTomlWithConfig, loadAnchorToml } from "@/lib/anchor"; 26 | import { isGitRepo } from "@/lib/git"; 27 | import { promptToInitGitRepo } from "@/lib/prompts/git"; 28 | 29 | /** 30 | * Command: `clone` 31 | * 32 | * Clone all the fixtures listed in the Solana.toml file 33 | */ 34 | export function cloneCommand() { 35 | return ( 36 | new Command("clone") 37 | .configureOutput(cliOutputConfig) 38 | .description( 39 | "clone all the accounts and programs listed in the Solana.toml file", 40 | ) 41 | .addOption( 42 | new Option("--force", "force clone all fixtures, even if they exist"), 43 | ) 44 | .addOption( 45 | new Option( 46 | "--no-prompt", 47 | "skip the prompts to override any existing fixtures", 48 | ), 49 | ) 50 | .addOption(COMMON_OPTIONS.accountDir) 51 | .addOption(COMMON_OPTIONS.config) 52 | // we intentionally make cloning from mainnet by default (vice the solana cli's config.yml) 53 | .addOption( 54 | new Option( 55 | COMMON_OPTIONS.url.flags, 56 | COMMON_OPTIONS.url.description, 57 | ).default("mainnet"), 58 | ) 59 | .action(async (options) => { 60 | titleMessage("Clone fixtures (accounts and programs)"); 61 | 62 | // console.log("options:"); 63 | // console.log(options); 64 | 65 | await checkCommand("solana account --help", { 66 | exit: true, 67 | message: 68 | "Unable to detect the 'solana account' command. Do you have it installed?", 69 | }); 70 | 71 | let targetGitDir = process.cwd(); 72 | if (!isGitRepo(targetGitDir)) { 73 | warnMessage( 74 | `Cloning fixtures without tracking changes via git is not recommended`, 75 | ); 76 | 77 | await promptToInitGitRepo(targetGitDir); 78 | } 79 | 80 | let config = loadConfigToml( 81 | options.config, 82 | options, 83 | true /* config required */, 84 | ); 85 | 86 | // attempt to load and combine the anchor toml clone settings 87 | const anchorToml = loadAnchorToml(config.configPath); 88 | if (anchorToml) { 89 | config = deconflictAnchorTomlWithConfig(anchorToml, config); 90 | } 91 | 92 | updateGitignore([DEFAULT_CACHE_DIR, DEFAULT_TEST_LEDGER_DIR]); 93 | rmSync(DEFAULT_ACCOUNTS_DIR_TEMP, { 94 | recursive: true, 95 | force: true, 96 | }); 97 | 98 | const currentAccounts = loadFileNamesToMap(config.settings.accountDir); 99 | 100 | /** 101 | * we clone the accounts in the order of: accounts, tokens, then programs 102 | * in order to perform any special processing on them 103 | */ 104 | 105 | const accounts = await cloneAccountsFromConfig( 106 | config, 107 | options, 108 | currentAccounts, 109 | ); 110 | await cloneTokensFromConfig(config, options, currentAccounts); 111 | 112 | let detectedPrograms: ReturnType = {}; 113 | if (accounts) { 114 | detectedPrograms = mergeOwnersMapWithConfig(accounts.owners); 115 | await cloneProgramsFromConfig( 116 | { settings: config.settings, clone: { program: detectedPrograms } }, 117 | { ...options, autoClone: true }, 118 | currentAccounts, 119 | ); 120 | } 121 | 122 | // always clone the config-declared programs last (in order to override the detected ones) 123 | await cloneProgramsFromConfig(config, options, currentAccounts); 124 | 125 | // now that all the files have been deconflicted, we can move them to their final home 126 | createFolders(DEFAULT_ACCOUNTS_DIR_TEMP); 127 | moveFiles(DEFAULT_ACCOUNTS_DIR_TEMP, config.settings.accountDir, true); 128 | 129 | rmSync(DEFAULT_ACCOUNTS_DIR_TEMP, { 130 | recursive: true, 131 | force: true, 132 | }); 133 | 134 | const cloneCounts = validateExpectedCloneCounts( 135 | config.settings.accountDir, 136 | config.clone, 137 | ); 138 | if (cloneCounts.actual === cloneCounts.expected) { 139 | console.log( 140 | `Completed cloning ${cloneCounts.actual} ${ 141 | cloneCounts.actual == 1 ? "account" : "accounts" 142 | }`, 143 | ); 144 | } else { 145 | warnMessage( 146 | `Completed cloning fixtures. Expected ${cloneCounts.expected} fixtures, but only ${cloneCounts.actual} found`, 147 | ); 148 | } 149 | }) 150 | ); 151 | } 152 | -------------------------------------------------------------------------------- /src/commands/coverage.ts: -------------------------------------------------------------------------------- 1 | import { Command, Option } from "@commander-js/extra-typings"; 2 | import { COMMON_OPTIONS } from "@/const/commands"; 3 | import { cliOutputConfig } from "@/lib/cli"; 4 | import { titleMessage, warnMessage } from "@/lib/logs"; 5 | import { checkCommand, shellExecInSession } from "@/lib/shell"; 6 | import { promptToInstall } from "@/lib/prompts/install"; 7 | import { installZest } from "@/lib/install"; 8 | 9 | const command = `zest coverage`; 10 | 11 | /** 12 | * Command: `coverage` 13 | * 14 | * Run the zest code coverage tool 15 | */ 16 | export function coverageCommand() { 17 | return new Command("coverage") 18 | .configureOutput(cliOutputConfig) 19 | .description("run code coverage on a Solana program") 20 | .usage("[options] [-- ...]") 21 | .addOption( 22 | new Option( 23 | "-- ", 24 | `arguments to pass to the underlying ${command} command`, 25 | ), 26 | ) 27 | .addOption(COMMON_OPTIONS.outputOnly) 28 | .action(async (options, { args: passThroughArgs }) => { 29 | if (!options.outputOnly) { 30 | titleMessage("Zest code coverage"); 31 | } 32 | 33 | await checkCommand("zest --help", { 34 | exit: true, 35 | onError: async () => { 36 | warnMessage("Unable to detect the 'zest' command."); 37 | const shouldInstall = await promptToInstall("zest"); 38 | if (shouldInstall) await installZest(); 39 | }, 40 | doubleCheck: true, 41 | }); 42 | 43 | shellExecInSession({ 44 | command, 45 | args: passThroughArgs, 46 | outputOnly: options.outputOnly, 47 | }); 48 | }); 49 | } 50 | -------------------------------------------------------------------------------- /src/commands/deploy.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { Command, Option } from "@commander-js/extra-typings"; 3 | import { cliConfig, COMMON_OPTIONS } from "@/const/commands"; 4 | import { cliOutputConfig, loadConfigToml } from "@/lib/cli"; 5 | import { titleMessage, warningOutro, warnMessage } from "@/lib/logs"; 6 | import { checkCommand, shellExecInSession } from "@/lib/shell"; 7 | import { 8 | buildDeployProgramCommand, 9 | getDeployedProgramInfo, 10 | } from "@/lib/shell/deploy"; 11 | import { autoLocateProgramsInWorkspace } from "@/lib/cargo"; 12 | import { directoryExists, doesFileExist } from "@/lib/utils"; 13 | import { getSafeClusterMoniker, parseRpcUrlOrMoniker } from "@/lib/solana"; 14 | import { promptToSelectCluster } from "@/lib/prompts/build"; 15 | import { loadKeypairSignerFromFile } from "gill/node"; 16 | import { getRunningTestValidatorCommand } from "@/lib/shell/test-validator"; 17 | import { logger } from "@/lib/logger"; 18 | 19 | /** 20 | * Command: `deploy` 21 | * 22 | * Manage Solana program deployments and upgrades 23 | */ 24 | export function deployCommand() { 25 | return new Command("deploy") 26 | .configureOutput(cliOutputConfig) 27 | .description("deploy a Solana program") 28 | .usage("[options] [-- ...]") 29 | .addOption( 30 | new Option( 31 | "-- ", 32 | `arguments to pass to the underlying 'solana program' command`, 33 | ), 34 | ) 35 | .addOption( 36 | new Option( 37 | "-p --program-name ", 38 | "name of the program to deploy", 39 | ), 40 | ) 41 | .addOption(COMMON_OPTIONS.url) 42 | .addOption(COMMON_OPTIONS.manifestPath) 43 | .addOption(COMMON_OPTIONS.keypair) 44 | .addOption(COMMON_OPTIONS.config) 45 | .addOption(COMMON_OPTIONS.outputOnly) 46 | .addOption(COMMON_OPTIONS.priorityFee) 47 | .action(async (options, { args: passThroughArgs }) => { 48 | if (!options.outputOnly) { 49 | titleMessage("Deploy a Solana program"); 50 | } 51 | 52 | await checkCommand("solana program --help", { 53 | exit: true, 54 | message: "Unable to detect the 'solana program' command", 55 | }); 56 | 57 | const { programs, cargoToml } = autoLocateProgramsInWorkspace( 58 | options.manifestPath, 59 | ); 60 | 61 | // auto select the program name for single program repos 62 | if (!options.programName && programs.size == 1) { 63 | options.programName = programs.entries().next().value[0]; 64 | } 65 | 66 | if (!cargoToml) return warnMessage(`Unable to locate Cargo.toml`); 67 | 68 | // ensure the selected program directory exists in the workspace 69 | if (!programs.has(options.programName) || !options.programName) { 70 | if (!options.programName) { 71 | // todo: we could give the user a prompt 72 | warnMessage(`You must select a program to deploy. See --help.`); 73 | } else if (!programs.has(options.programName)) { 74 | warnMessage( 75 | `Unable to locate program '${options.programName}' in this workspace`, 76 | ); 77 | } 78 | 79 | console.log(`The following programs were located:`); 80 | programs.forEach((_programPath, programName) => 81 | console.log(" -", programName), 82 | ); 83 | 84 | // todo: should we prompt the user to select a valid program? 85 | logger.exit(); 86 | } 87 | 88 | if (!options.url) { 89 | const cluster = await promptToSelectCluster( 90 | "Select the cluster to deploy your program on?", 91 | getSafeClusterMoniker(cliConfig?.json_rpc_url) || undefined, 92 | ); 93 | console.log(); // print a line separator 94 | options.url = parseRpcUrlOrMoniker(cluster); 95 | } 96 | 97 | if (!options.url) { 98 | return warnMessage(`You must select cluster to deploy to. See --help`); 99 | } 100 | 101 | let selectedCluster = getSafeClusterMoniker(options.url); 102 | if (!selectedCluster) { 103 | // prompting a second time will allow users to use a custom rpc url 104 | const cluster = await promptToSelectCluster( 105 | "Unable to auto detect the cluster to deploy too. Select a cluster?", 106 | ); 107 | selectedCluster = getSafeClusterMoniker(cluster); 108 | if (!selectedCluster) { 109 | return warnMessage( 110 | `Unable to detect cluster to deploy to. Operation canceled.`, 111 | ); 112 | } 113 | } 114 | 115 | if (selectedCluster == "localnet" && !getRunningTestValidatorCommand()) { 116 | return warningOutro( 117 | `Attempted to deploy to localnet with no local validator running. Operation canceled.`, 118 | ); 119 | } 120 | 121 | let config = loadConfigToml( 122 | options.config, 123 | options, 124 | false /* config not required */, 125 | ); 126 | 127 | const buildDir = path.join( 128 | path.dirname(cargoToml.configPath), 129 | "target", 130 | "deploy", 131 | ); 132 | 133 | if (!directoryExists(buildDir)) { 134 | warnMessage(`Unable to locate your build dir: ${buildDir}`); 135 | return warnMessage(`Have you built your programs?`); 136 | } 137 | 138 | const binaryPath = path.join(buildDir, `${options.programName}.so`); 139 | if (!doesFileExist(binaryPath)) { 140 | // todo: we should detect if the program is declared and recommend building it 141 | // todo: or we could generate a fresh one? 142 | warnMessage(`Unable to locate program binary:\n${binaryPath}`); 143 | return warnMessage(`Have you built your programs?`); 144 | } 145 | 146 | let programId: string | null = null; 147 | let programIdPath: string | null = path.join( 148 | buildDir, 149 | `${options.programName}-keypair.json`, 150 | ); 151 | const programKeypair = await loadKeypairSignerFromFile(programIdPath); 152 | 153 | // process the user's config file if they have one 154 | if (config?.programs) { 155 | // make sure the user has the cluster program declared 156 | if (!getSafeClusterMoniker(selectedCluster, config.programs)) { 157 | warnMessage( 158 | `Unable to locate '${selectedCluster}' programs in your Solana.toml`, 159 | ); 160 | 161 | console.log("The following programs are declared:"); 162 | Object.keys(config.programs).forEach((cl) => { 163 | console.log(` - ${cl}:`); 164 | Object.keys(config.programs[cl]).forEach((name) => { 165 | console.log(` - ${name}`); 166 | }); 167 | }); 168 | 169 | logger.exit(); 170 | } 171 | 172 | if ( 173 | !config?.programs?.[selectedCluster] || 174 | !Object.hasOwn(config.programs[selectedCluster], options.programName) 175 | ) { 176 | return warningOutro( 177 | `Program '${options.programName}' not found in 'programs.${selectedCluster}'`, 178 | ); 179 | } 180 | 181 | programId = config.programs[selectedCluster][options.programName]; 182 | } else { 183 | // if the user does not have a config file, we will try to auto detect the program id to use 184 | // todo: this 185 | 186 | if (programKeypair) { 187 | programId = programKeypair.address; 188 | warnMessage(`Auto detected default program keypair file:`); 189 | console.log(` - keypair path: ${programIdPath}`); 190 | console.log(` - program id: ${programId}`); 191 | } else { 192 | return warningOutro( 193 | `Unable to locate any program id or program keypair.`, 194 | ); 195 | } 196 | } 197 | 198 | if (!programId) { 199 | return warnMessage( 200 | `Unable to locate program id for '${options.programName}'. Do you have it declared?`, 201 | ); 202 | } 203 | 204 | let programInfo = await getDeployedProgramInfo(programId, options.url); 205 | 206 | /** 207 | * when programInfo exists, we assume the program is already deployed 208 | * (either from the user's current machine or not) 209 | */ 210 | if (!programInfo) { 211 | // not-yet-deployed programs require a keypair to deploy for the first time 212 | // warnMessage( 213 | // `Program ${options.programName} (${programId}) is NOT already deployed on ${selectedCluster}`, 214 | // ); 215 | if (!programKeypair) { 216 | return warnMessage( 217 | `Unable to locate program keypair: ${programIdPath}`, 218 | ); 219 | } 220 | 221 | const programIdFromKeypair = programKeypair.address; 222 | /** 223 | * since the initial deployment requires a keypair: 224 | * if the user has a mismatch between their declared program id 225 | * and the program keypair, we do not explicitly know which address they want 226 | */ 227 | if (programIdFromKeypair !== programId) { 228 | warnMessage( 229 | `The loaded program keypair does NOT match the configured program id`, 230 | ); 231 | console.log(` - program keypair: ${programIdFromKeypair}`); 232 | console.log(` - declared program id: ${programId}`); 233 | warnMessage( 234 | `Unable to perform initial program deployment. Operation cancelled.`, 235 | ); 236 | logger.exit(); 237 | // todo: should we prompt the user if they want to proceed 238 | } 239 | programId = programIdFromKeypair; 240 | programInfo = await getDeployedProgramInfo(programId, options.url); 241 | } 242 | 243 | const authorityKeypair = await loadKeypairSignerFromFile( 244 | config.settings.keypair, 245 | ); 246 | 247 | /** 248 | * todo: assorted pre-deploy checks to add 249 | * + is program already deployed 250 | * + is program frozen 251 | * - do you have the upgrade authority 252 | * - is the upgrade authority a multi sig? 253 | * - do you have enough sol to deploy ? 254 | */ 255 | if (programInfo) { 256 | if (!programInfo.authority) { 257 | return warningOutro( 258 | `Program ${programInfo.programId} is no longer upgradeable`, 259 | ); 260 | } 261 | 262 | if (programInfo.authority !== authorityKeypair.address) { 263 | return warningOutro( 264 | `Your keypair (${authorityKeypair.address}) is not the upgrade authority for program ${programId}`, 265 | ); 266 | } 267 | 268 | programId = programInfo.programId; 269 | } else { 270 | // todo: do we need to perform any checks if the program is not already deployed? 271 | } 272 | 273 | const command = buildDeployProgramCommand({ 274 | programPath: binaryPath, 275 | programId: programIdPath || programId, 276 | url: options.url, 277 | keypair: options.keypair, 278 | priorityFee: options.priorityFee, 279 | }); 280 | 281 | // todo: if options.url is localhost, verify the test validator is running 282 | 283 | // todo: if localhost deploy, support feature cloning to match a cluster 284 | 285 | /** 286 | * todo: if deploying to mainnet, we should add some "confirm" prompts 287 | * - this is the program id 288 | * - this is the upgrade authority 289 | * - estimated cost (you have X sol) 290 | * do you want to continue? 291 | */ 292 | 293 | shellExecInSession({ 294 | command, 295 | args: passThroughArgs, 296 | outputOnly: options.outputOnly, 297 | }); 298 | }); 299 | } 300 | -------------------------------------------------------------------------------- /src/commands/docs.ts: -------------------------------------------------------------------------------- 1 | import { Argument, Command } from "@commander-js/extra-typings"; 2 | import { cliOutputConfig } from "@/lib/cli"; 3 | import shellExec from "shell-exec"; 4 | import pc from "picocolors"; 5 | import { errorMessage } from "@/lib/logs"; 6 | 7 | type DocInfo = { 8 | url: string; 9 | desc: string; 10 | }; 11 | 12 | const DOCS_INFO: Record = { 13 | // Core Development Tools 14 | mucho: { 15 | url: "https://github.com/solana-foundation/mucho?tab=readme-ov-file#mucho", 16 | desc: "A superset of popular Solana developer tools to simplify Solana program development and testing", 17 | }, 18 | solana: { 19 | url: "https://solana.com/docs", 20 | desc: "Official Solana blockchain technical documentation", 21 | }, 22 | "solana-cli": { 23 | url: "https://solana.com/docs/intro/installation#install-the-solana-cli", 24 | desc: "The Agave CLI tool suite required to build and deploy Solana programs (formerly known as Solana CLI tool suite)", 25 | }, 26 | // Programming Languages & Runtimes 27 | rust: { 28 | url: "https://www.rust-lang.org/learn", 29 | desc: "Programming language for writing Solana smart contracts", 30 | }, 31 | node: { 32 | url: "https://nodejs.org/en/learn", 33 | desc: "Runtime environment for Solana client applications", 34 | }, 35 | typescript: { 36 | url: "https://www.typescriptlang.org/docs", 37 | desc: "TypeScript language for writing Solana client applications", 38 | }, 39 | // Smart Contract Development 40 | anchor: { 41 | url: "https://www.anchor-lang.com", 42 | desc: "Solana's most popular smart contract framework", 43 | }, 44 | avm: { 45 | url: "https://www.anchor-lang.com/docs/installation#installing-using-anchor-version-manager-avm-recommended", 46 | desc: "Anchor Version Manager (AVM) to manage multiple versions of Anchor", 47 | }, 48 | spl: { 49 | url: "https://spl.solana.com", 50 | desc: "The Solana Program Library (SPL) is a collection of standard smart contracts for Solana", 51 | }, 52 | metaplex: { 53 | url: "https://developers.metaplex.com", 54 | desc: "Metaplex NFT standard documentation", 55 | }, 56 | // Testing & Verification Tools 57 | "solana-verify": { 58 | url: "https://github.com/Ellipsis-Labs/solana-verifiable-build", 59 | desc: "CLI tool for verifying Solana program builds match on-chain deployments", 60 | }, 61 | trident: { 62 | url: "https://ackee.xyz/trident/docs/latest", 63 | desc: "Rust-based fuzzing framework for testing Solana programs", 64 | }, 65 | zest: { 66 | url: "https://github.com/LimeChain/zest", 67 | desc: "Code coverage CLI tool for Solana programs", 68 | }, 69 | // Node Related Tools 70 | nvm: { 71 | url: "https://github.com/nvm-sh/nvm", 72 | desc: "Node Version Manager for managing multiple versions of Node.js", 73 | }, 74 | npm: { 75 | url: "https://docs.npmjs.com/cli", 76 | desc: "Default package manager for Node.js - manages dependencies and project scripts", 77 | }, 78 | pnpm: { 79 | url: "https://pnpm.io/installation", 80 | desc: "Fast, disk space efficient package manager for Node.js - alternative to npm", 81 | }, 82 | yarn: { 83 | url: "https://yarnpkg.com/getting-started", 84 | desc: "Alternative Node.js package manager - dependency for Anchor", 85 | }, 86 | }; 87 | 88 | const OPEN_COMMANDS = { 89 | win32: "start", 90 | darwin: "open", 91 | linux: "xdg-open", 92 | } as const; 93 | 94 | /** 95 | * Command: `docs` 96 | * 97 | * Open documentation websites for Solana development tools 98 | */ 99 | export function docsCommand() { 100 | const openCommand = 101 | OPEN_COMMANDS[process.platform as keyof typeof OPEN_COMMANDS] ?? "xdg-open"; 102 | const TOOLS = Object.keys(DOCS_INFO); 103 | 104 | return new Command("docs") 105 | .configureOutput(cliOutputConfig) 106 | .description("Open documentation websites for Solana development tools") 107 | .usage("[options] [tool]") 108 | .addArgument( 109 | new Argument("[tool]", "tool to open docs for") 110 | .choices(TOOLS) 111 | .default("mucho"), 112 | ) 113 | .addHelpText( 114 | "after", 115 | ` 116 | Available documentation: 117 | ${Object.entries(DOCS_INFO) 118 | .map( 119 | ([name, { desc, url }]) => 120 | ` ${name.padEnd(14)}${desc}\n ${" ".repeat(14)}${url}`, 121 | ) 122 | .join("\n\n")} 123 | 124 | Examples: 125 | $ mucho docs # open Mucho documentation 126 | $ mucho docs solana # open Solana documentation`, 127 | ) 128 | .action(async (tool) => { 129 | if (tool in DOCS_INFO == false) { 130 | return errorMessage("Invalid tool name: " + tool); 131 | } 132 | 133 | console.log(pc.dim(`Opening: ${DOCS_INFO[tool].url}`)); 134 | 135 | await shellExec(`${openCommand} ${DOCS_INFO[tool].url}`); 136 | }); 137 | } 138 | -------------------------------------------------------------------------------- /src/commands/doctor.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "@commander-js/extra-typings"; 2 | import { cliOutputConfig } from "@/lib/cli"; 3 | import { successOutro, warnMessage } from "@/lib/logs"; 4 | 5 | import { detectOperatingSystem } from "@/lib/shell"; 6 | import { checkInstalledTools } from "@/lib/setup"; 7 | 8 | /** 9 | * Command: `doctor` 10 | * 11 | * Inspect and remedy your local machine for Solana development 12 | */ 13 | export function doctorCommand() { 14 | return new Command("doctor") 15 | .configureOutput(cliOutputConfig) 16 | .description("inspect and remedy your local development environment") 17 | .addCommand(doctorListCommand()); 18 | } 19 | 20 | /** 21 | * Command: `doctor list` 22 | * 23 | * List the current installed versions of Solana development tooling 24 | */ 25 | function doctorListCommand() { 26 | return new Command("list") 27 | .configureOutput(cliOutputConfig) 28 | .description("list the current versions of Solana development tooling") 29 | .action(async () => { 30 | // titleMessage("Solana development tooling"); 31 | 32 | const os = detectOperatingSystem(); 33 | 34 | if (os == "windows") { 35 | warnMessage( 36 | "Windows is not yet natively supported for the rust based tooling.\n" + 37 | "We recommend using WSL inside your Windows terminal.", 38 | ); 39 | } 40 | 41 | const tools = await checkInstalledTools({ 42 | outputToolStatus: true, 43 | }); 44 | 45 | if (tools.allInstalled) { 46 | return successOutro("All tools are installed!"); 47 | } 48 | 49 | // todo: allow a command flag to force install 50 | 51 | // todo: ask the user if they want to install missing tools? 52 | 53 | successOutro(); 54 | }); 55 | } 56 | -------------------------------------------------------------------------------- /src/commands/index.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "@commander-js/extra-typings"; 2 | import { cliOutputConfig } from "@/lib/cli"; 3 | import { getAppInfo } from "@/lib/app-info"; 4 | import { getCommandOutputSync } from "@/lib/shell"; 5 | import { getPlatformToolsVersions } from "@/lib/solana"; 6 | import { TOOL_CONFIG } from "@/const/setup"; 7 | import { logger } from "@/lib/logger"; 8 | 9 | export function cliProgramRoot() { 10 | // console.log(picocolors.bgMagenta(` ${app.name} - v${app.version} `)); 11 | 12 | // initialize the cli commands and options parsing 13 | const program = new Command() 14 | .name(`mucho`) 15 | .configureOutput(cliOutputConfig) 16 | .description("mucho tools, one cli") 17 | .option("--version", "output the version number of this tool", async () => { 18 | console.log("mucho", getAppInfo().version); 19 | 20 | if (process.argv.indexOf("--version") === 2) { 21 | console.log( 22 | getCommandOutputSync(TOOL_CONFIG.rust.version) || `rustc not found`, 23 | ); 24 | console.log( 25 | "node", 26 | getCommandOutputSync(TOOL_CONFIG.node.version) || 27 | "not found (odd if you are running this node tool...)", 28 | ); 29 | 30 | console.log( 31 | getCommandOutputSync(TOOL_CONFIG.solana.version) || 32 | `solana cli not found`, 33 | ); 34 | 35 | const solanaTools = getPlatformToolsVersions(); 36 | console.log("solana platform tools:"); 37 | for (const key in solanaTools) { 38 | console.log(" " + key + ": " + solanaTools[key]); 39 | } 40 | 41 | console.log("\nFor more info run: 'npx mucho info'"); 42 | } 43 | logger.exit(0); 44 | }); 45 | 46 | return program; 47 | } 48 | -------------------------------------------------------------------------------- /src/commands/info.ts: -------------------------------------------------------------------------------- 1 | import os from "node:os"; 2 | import { Command } from "@commander-js/extra-typings"; 3 | import { cliOutputConfig } from "@/lib/cli"; 4 | import { titleMessage } from "@/lib/logs"; 5 | import ora from "ora"; 6 | 7 | import { getCommandOutput } from "@/lib/shell"; 8 | import { checkInstalledTools } from "@/lib/setup"; 9 | import { getPlatformToolsVersions } from "@/lib/solana"; 10 | import { DEFAULT_CLI_YAML_PATH } from "@/const/solana"; 11 | import { doesFileExist } from "@/lib/utils"; 12 | import { DEFAULT_CLI_KEYPAIR_PATH } from "gill/node"; 13 | import { getRunningTestValidatorCommand } from "@/lib/shell/test-validator"; 14 | 15 | /** 16 | * Command: `info` 17 | * 18 | * Get loads of helpful troubleshooting info for the user's computer 19 | */ 20 | export function infoCommand() { 21 | return new Command("info") 22 | .configureOutput(cliOutputConfig) 23 | .description("gather helpful troubleshooting info about your setup") 24 | .action(async () => { 25 | titleMessage("Gather troubleshooting info"); 26 | console.log(); 27 | 28 | const spinner = ora("Gathering mucho info").start(); 29 | 30 | const separator = "------------------------"; 31 | 32 | const info: string[] = []; 33 | 34 | info.push("--- start mucho info ---"); 35 | info.push(`System Info:`); 36 | const sysInfo = await getCommandOutput("uname -a"); 37 | if (sysInfo) info.push(sysInfo.toString()); 38 | else info.push(`platform: ${os.platform()}, arch: ${os.arch()}`); 39 | 40 | const tools = await checkInstalledTools(); 41 | 42 | info.push(separator); 43 | info.push("Installed tooling:"); 44 | for (const key in tools.status) { 45 | info.push(" " + key + ": " + tools.status[key]); 46 | } 47 | 48 | info.push(separator); 49 | info.push("Platform tools:"); 50 | const solanaTools = getPlatformToolsVersions(); 51 | for (const key in solanaTools) { 52 | info.push(" " + key + ": " + solanaTools[key]); 53 | } 54 | 55 | info.push(separator); 56 | info.push("Solana CLI info:"); 57 | 58 | const solanaConfig = 59 | (await getCommandOutput("solana config get")) || 60 | "Unable to get 'solana config'"; 61 | 62 | info.push( 63 | solanaConfig 64 | .split("\n") 65 | .map((line) => ` ${line}`) 66 | .join("\n"), 67 | ); 68 | 69 | info.push( 70 | `Does CLI config.yml exist: ${await doesFileExist( 71 | DEFAULT_CLI_YAML_PATH, 72 | )}`, 73 | ); 74 | 75 | const testValidatorChecker = getRunningTestValidatorCommand(); 76 | 77 | // build the test validator port, with support for an manually defined one 78 | let localnetClusterUrl = "http://127.0.0.1:8899"; 79 | if ( 80 | !!testValidatorChecker && 81 | testValidatorChecker.includes("--rpc-port") 82 | ) { 83 | localnetClusterUrl = `http://127.0.0.1:${ 84 | testValidatorChecker.match(/--rpc-port\s+(\d+)/)[1] || 8899 85 | }`; 86 | } 87 | 88 | // only attempt to fetch the address and balances if the user has a local keypair created 89 | if (await doesFileExist(DEFAULT_CLI_KEYPAIR_PATH)) { 90 | const address = await getCommandOutput("solana address"); 91 | info.push("Address: " + address); 92 | 93 | const balances = { 94 | devnet: (await getCommandOutput("solana balance -ud")) || false, 95 | mainnet: (await getCommandOutput("solana balance -um")) || false, 96 | testnet: (await getCommandOutput("solana balance -ut")) || false, 97 | localnet: 98 | (await getCommandOutput( 99 | `solana balance --url ${localnetClusterUrl}`, 100 | )) || false, 101 | }; 102 | 103 | info.push("Balances for address:"); 104 | for (const key in balances) { 105 | info.push(" " + key + ": " + balances[key]); 106 | } 107 | } else { 108 | info.push( 109 | `Default keypair file NOT found at: ${DEFAULT_CLI_KEYPAIR_PATH}`, 110 | ); 111 | } 112 | 113 | info.push("Is test-validator running? " + !!testValidatorChecker); 114 | info.push("Localnet url: " + localnetClusterUrl); 115 | 116 | info.push("---- end mucho info ----"); 117 | 118 | spinner.stop(); 119 | 120 | console.log(info.join("\n")); 121 | // todo: add the ability to save to a file 122 | 123 | console.log(); 124 | console.log( 125 | "Looking for troubleshooting help? Post a question on the Solana StackExchange:", 126 | ); 127 | console.log("https://solana.stackexchange.com"); 128 | }); 129 | } 130 | -------------------------------------------------------------------------------- /src/commands/inspect.ts: -------------------------------------------------------------------------------- 1 | import { Argument, Command } from "@commander-js/extra-typings"; 2 | import { cliOutputConfig, loadSolanaCliConfig } from "@/lib/cli"; 3 | import { 4 | errorMessage, 5 | titleMessage, 6 | warningOutro, 7 | warnMessage, 8 | } from "@/lib/logs"; 9 | import { COMMON_OPTIONS } from "@/const/commands"; 10 | import { inspectAddress, inspectSignature } from "@/lib/inspect"; 11 | import { inspectBlock } from "@/lib/inspect/block"; 12 | import { numberStringToNumber } from "@/lib/utils"; 13 | import { 14 | address, 15 | createSolanaRpc, 16 | isAddress, 17 | isSignature, 18 | isStringifiedNumber, 19 | signature, 20 | getMonikerFromGenesisHash, 21 | } from "gill"; 22 | import { parseOptionsFlagForRpcUrl } from "@/lib/cli/parsers"; 23 | 24 | const helpText: string[] = [ 25 | "Examples:", 26 | " Account:", 27 | " mucho inspect GQuioVe2yA6KZfstgmirAvugfUZBcdxSi7sHK7JGk3gk", 28 | " mucho inspect --url devnet nicktrLHhYzLmoVbuZQzHUTicd2sfP571orwo9jfc8c", 29 | " Transaction:", 30 | " mucho inspect --url mainnet 5tb4d17U4ZsBa2gkNFEmVsrnaQ6wCzQ51i4iPkh2p9S2mnfZezcmWQUogVMK3mBZBtgMxKSqe242vAxuV6FyTYPf", 31 | " Block:", 32 | " mucho inspect 315667873", 33 | ` mucho inspect --url mainnet ${Intl.NumberFormat().format( 34 | 315667873, 35 | )} (with your locale formatted numbers)`, 36 | ]; 37 | 38 | /** 39 | * Command: `inspect` 40 | * 41 | * Solana block explorer on the CLI, akin to https://explorer.solana.com/tx/inspector 42 | */ 43 | export function inspectCommand() { 44 | return new Command("inspect") 45 | .configureOutput(cliOutputConfig) 46 | .description("inspect transactions, accounts, etc (like a block explorer)") 47 | .addHelpText("after", helpText.join("\n")) 48 | .addArgument( 49 | new Argument( 50 | "", 51 | "transaction signature, account address, or block number to inspect", 52 | ).argRequired(), 53 | ) 54 | .addOption(COMMON_OPTIONS.url) 55 | .addOption(COMMON_OPTIONS.config) 56 | .action(async (input, options) => { 57 | titleMessage("Inspector"); 58 | 59 | // construct the rpc url endpoint to use based on the provided url option or the solana cli config.yml file 60 | const cliConfig = loadSolanaCliConfig(); 61 | 62 | let parsedRpcUrl = parseOptionsFlagForRpcUrl( 63 | options.url, 64 | cliConfig.json_rpc_url /* use the Solana cli config's rpc url as the fallback */, 65 | ); 66 | 67 | // when an explorer url is provided, attempt to parse this and strip away the noise 68 | if (input.match(/^https?:\/\//gi)) { 69 | try { 70 | const url = new URL(input); 71 | 72 | if (url.hostname.toLowerCase() != "explorer.solana.com") { 73 | return errorMessage( 74 | "Only the https://explorer.solana.com is supported", 75 | ); 76 | } 77 | 78 | let clusterFromUrl = ( 79 | url.searchParams.get("cluster") || "mainnet" 80 | ).toLowerCase(); 81 | 82 | // accept custom rpc urls via the query param 83 | if (clusterFromUrl == "custom") { 84 | if (url.searchParams.has("customUrl")) { 85 | clusterFromUrl = url.searchParams.get("customUrl"); 86 | } else clusterFromUrl = "localhost"; 87 | } 88 | 89 | // auto detect the cluster selected via the url 90 | parsedRpcUrl = parseOptionsFlagForRpcUrl( 91 | clusterFromUrl, 92 | parsedRpcUrl.url, 93 | ); 94 | 95 | if (url.pathname.match(/^\/address\//gi)) { 96 | input = url.pathname.match(/^\/address\/(.*)\/?/i)[1]; 97 | } else if (url.pathname.match(/^\/(tx|transaction)\//gi)) { 98 | input = url.pathname.match(/^\/(tx|transaction)\/(.*)\/?/i)[2]; 99 | } else if (url.pathname.match(/^\/(block)\//gi)) { 100 | input = url.pathname.match(/^\/block\/(.*)\/?/i)[1]; 101 | } else { 102 | return warningOutro("Unsupported explorer URL"); 103 | } 104 | } catch (err) { 105 | return errorMessage("Unable to parse inspector input as valid URL"); 106 | } 107 | } 108 | 109 | const rpc = createSolanaRpc(parsedRpcUrl.url.toString()); 110 | 111 | try { 112 | let selectedCluster = getMonikerFromGenesisHash( 113 | await rpc.getGenesisHash().send(), 114 | ); 115 | // for unknown clusters, force to localnet since that is the most likely 116 | if (selectedCluster == "unknown") selectedCluster = "localnet"; 117 | 118 | if (isAddress(input)) { 119 | await inspectAddress({ 120 | rpc, 121 | cluster: selectedCluster, 122 | address: address(input), 123 | }); 124 | } else if (isSignature(input)) { 125 | await inspectSignature({ 126 | rpc, 127 | cluster: selectedCluster, 128 | signature: signature(input), 129 | }); 130 | } else if ( 131 | isStringifiedNumber(numberStringToNumber(input).toString()) 132 | ) { 133 | await inspectBlock({ 134 | rpc, 135 | cluster: selectedCluster, 136 | block: input, 137 | }); 138 | } else { 139 | warnMessage( 140 | "Unable to determine your 'INPUT'. Check it's formatting and try again :)", 141 | ); 142 | } 143 | } catch (err) { 144 | // node js fetch throws a TypeError for network errors (for some reason...) 145 | if (err instanceof TypeError) { 146 | warnMessage( 147 | "A network error occurred while connecting to your configured RPC endpoint." + 148 | "\nIs your RPC connection available at: " + 149 | parsedRpcUrl.cluster, 150 | ); 151 | } else { 152 | warnMessage(err); 153 | } 154 | } 155 | }); 156 | } 157 | -------------------------------------------------------------------------------- /src/commands/install.ts: -------------------------------------------------------------------------------- 1 | import { Argument, Command, Option } from "@commander-js/extra-typings"; 2 | import { cliOutputConfig } from "@/lib/cli"; 3 | import { titleMessage, warningOutro } from "@/lib/logs"; 4 | import { detectOperatingSystem, getCommandOutput } from "@/lib/shell"; 5 | import type { ToolNames } from "@/types"; 6 | import { 7 | installAnchorVersionManager, 8 | installRust, 9 | installSolana, 10 | installAnchorUsingAvm, 11 | installYarn, 12 | installTrident, 13 | installZest, 14 | installSolanaVerify, 15 | installMucho, 16 | } from "@/lib/install"; 17 | import { checkShellPathSource } from "@/lib/setup"; 18 | import { PathSourceStatus, TOOL_CONFIG } from "@/const/setup"; 19 | import { getCargoUpdateOutput, getNpmPackageUpdates } from "@/lib/update"; 20 | import { getAvailableAnchorVersions } from "@/lib/anchor"; 21 | import { isVersionNewer } from "@/lib/node"; 22 | 23 | const toolNames: Array = [ 24 | "rust", 25 | "solana", 26 | "avm", 27 | "anchor", 28 | "trident", 29 | "zest", 30 | "yarn", 31 | "verify", 32 | ]; 33 | 34 | /** 35 | * Command: `install` 36 | * 37 | * Setup your local machine for Solana development 38 | */ 39 | export function installCommand() { 40 | return new Command("install") 41 | .configureOutput(cliOutputConfig) 42 | .description("install Solana development tooling") 43 | .addArgument( 44 | new Argument("", "tool to install") 45 | .choices(toolNames) 46 | .argOptional(), 47 | ) 48 | .addArgument( 49 | new Argument( 50 | "", 51 | "desired tool version to install (default: stable)", 52 | ).argOptional(), 53 | ) 54 | .addOption( 55 | new Option( 56 | "--core", 57 | "install only the core tools and their dependencies - rust, solana, anchor", 58 | ).default(true), 59 | ) 60 | .addOption( 61 | new Option( 62 | "--all", 63 | "install all the tools, not just the core tooling", 64 | ).default(false), 65 | ) 66 | .action(async (toolName, version, options) => { 67 | titleMessage("Install Solana development tools"); 68 | if (options.all) options.core = true; 69 | 70 | const os = detectOperatingSystem(); 71 | 72 | if (os == "windows") { 73 | return warningOutro( 74 | "Windows is not yet natively supported for the rust based tooling.\n" + 75 | "We recommend using WSL inside your Windows terminal.", 76 | ); 77 | } 78 | 79 | const postInstallMessages: string[] = []; 80 | 81 | // track which commands may require a path/session refresh 82 | const pathsToRefresh: string[] = []; 83 | 84 | const updatesAvailable = await getNpmPackageUpdates("mucho", true); 85 | 86 | if (options.all) { 87 | console.log(""); // spacer 88 | console.log("Core tools:"); 89 | } 90 | 91 | // always check and install `mucho` 92 | await installMucho({ 93 | os, 94 | version, 95 | updateAvailable: updatesAvailable.filter( 96 | (data) => data.name.toLowerCase() == "mucho", 97 | )[0], 98 | }); 99 | 100 | // we require rust to check for available updates on many of the other tools 101 | // so we install it first before performing that version check 102 | if (!toolName || toolName == "rust") { 103 | await installRust({ 104 | os, 105 | version, 106 | }); 107 | 108 | await checkShellPathSource( 109 | TOOL_CONFIG.rust.version, 110 | TOOL_CONFIG.rust.pathSource, 111 | ).then((status) => 112 | status == PathSourceStatus.MISSING_PATH 113 | ? pathsToRefresh.push(TOOL_CONFIG.rust.pathSource) 114 | : true, 115 | ); 116 | } 117 | 118 | const anchorVersions = await getAvailableAnchorVersions(); 119 | if ( 120 | anchorVersions.current && 121 | anchorVersions.latest && 122 | isVersionNewer(anchorVersions.latest, anchorVersions.current) 123 | ) { 124 | updatesAvailable.push({ 125 | installed: anchorVersions.current, 126 | latest: anchorVersions.latest, 127 | name: "anchor", 128 | needsUpdate: true, 129 | }); 130 | } 131 | 132 | // this requires cargo to already be installed, so we must do it after the rust check 133 | updatesAvailable.push(...(await getCargoUpdateOutput())); 134 | 135 | if (!toolName || toolName == "solana") { 136 | const res = await installSolana({ 137 | os, 138 | version, 139 | }); 140 | 141 | // string response means this was a fresh install 142 | // so we can perform additional setup steps 143 | if (typeof res == "string") { 144 | await getCommandOutput("solana config set --url localhost"); 145 | postInstallMessages.push( 146 | "Create a Solana wallet for your CLI with the following command: solana-keygen new", 147 | ); 148 | } 149 | 150 | await checkShellPathSource( 151 | TOOL_CONFIG.solana.version, 152 | TOOL_CONFIG.solana.pathSource, 153 | ).then((status) => 154 | status == PathSourceStatus.MISSING_PATH 155 | ? pathsToRefresh.push(TOOL_CONFIG.solana.pathSource) 156 | : true, 157 | ); 158 | } 159 | 160 | // anchor is installed via avm 161 | if (!toolName || toolName == "avm" || toolName == "anchor") { 162 | await installAnchorVersionManager({ 163 | os, 164 | version, 165 | updateAvailable: updatesAvailable.filter( 166 | (data) => data.name.toLowerCase() == "avm", 167 | )[0], 168 | }); 169 | } 170 | if (!toolName || toolName == "anchor") { 171 | await installAnchorUsingAvm({ 172 | os, 173 | version, 174 | updateAvailable: updatesAvailable.filter( 175 | (data) => data.name.toLowerCase() == "anchor", 176 | )[0], 177 | }); 178 | } 179 | 180 | if (!toolName || toolName == "verify") { 181 | await installSolanaVerify({ 182 | os, 183 | version, 184 | updateAvailable: updatesAvailable.filter( 185 | (data) => data.name.toLowerCase() == "solana-verify", 186 | )[0], 187 | }); 188 | } 189 | 190 | // yarn is considered a "core" tool because it is currently a dependency of anchor (sadly) 191 | // this is expected to change in anchor 0.31 192 | if (!toolName || toolName == "yarn") { 193 | await installYarn({ 194 | os, 195 | version, 196 | }); 197 | } 198 | 199 | if (options.all) { 200 | console.log(""); // spacer 201 | console.log("Additional tools:"); 202 | } 203 | 204 | if ((options.all && !toolName) || toolName == "trident") { 205 | await installTrident({ 206 | os, 207 | version, 208 | updateAvailable: updatesAvailable.filter( 209 | (data) => data.name.toLowerCase() == "trident-cli", 210 | )[0], 211 | }); 212 | } 213 | 214 | if ((options.all && !toolName) || toolName == "zest") { 215 | await installZest({ 216 | os, 217 | version, 218 | updateAvailable: updatesAvailable.filter( 219 | (data) => data.name.toLowerCase() == "zest", 220 | )[0], 221 | }); 222 | } 223 | 224 | if (pathsToRefresh.length > 0) { 225 | console.log( 226 | "\nClose and reopen your terminal to apply the required", 227 | "PATH changes \nor run the following in your existing shell:", 228 | "\n", 229 | ); 230 | console.log(`export PATH="${pathsToRefresh.join(":")}:$PATH"`, "\n"); 231 | } 232 | 233 | if (postInstallMessages.length > 0) { 234 | console.log(); // spacer line 235 | console.log(postInstallMessages.join("\n")); 236 | } 237 | }); 238 | } 239 | -------------------------------------------------------------------------------- /src/commands/self-update.ts: -------------------------------------------------------------------------------- 1 | import { Argument, Command } from "@commander-js/extra-typings"; 2 | import { cliOutputConfig } from "@/lib/cli"; 3 | import { getAppInfo } from "@/lib/app-info"; 4 | import shellExec from "shell-exec"; 5 | import ora from "ora"; 6 | import { 7 | getCurrentNpmPackageVersion, 8 | getNpmRegistryPackageVersion, 9 | } from "@/lib/npm"; 10 | import picocolors from "picocolors"; 11 | 12 | const appName = getAppInfo().name; 13 | 14 | /** 15 | * Command: `self-update` 16 | */ 17 | export function selfUpdateCommand() { 18 | return new Command("self-update") 19 | .configureOutput(cliOutputConfig) 20 | .addArgument( 21 | new Argument( 22 | "", 23 | "desired version to install (default: latest)", 24 | ).argOptional(), 25 | ) 26 | .description( 27 | `update ${appName} to the latest version (or the one specified)`, 28 | ) 29 | .action(async (version) => { 30 | version = version?.toLowerCase() || "latest"; 31 | 32 | const spinner = ora( 33 | `Preparing to update ${appName} to '${version}'`, 34 | ).start(); 35 | 36 | const registry = await getNpmRegistryPackageVersion(appName); 37 | const current = await getCurrentNpmPackageVersion(appName, true); 38 | 39 | if (version == "latest") { 40 | version = registry.latest; 41 | } 42 | 43 | if (version == current) { 44 | spinner.succeed(`${appName} version '${version}' already installed`); 45 | return; 46 | } 47 | 48 | try { 49 | if (registry.allVersions.find((ver) => ver === version).length == 0) { 50 | throw `${appName} version '${version}' not found`; 51 | } 52 | 53 | spinner.text = `Updating ${appName} to '${version}'`; 54 | 55 | await shellExec(`npm install -gy --force ${appName}@${version}`); 56 | 57 | spinner.text = `Validating installation`; 58 | const newCurrent = await getCurrentNpmPackageVersion(appName, true); 59 | 60 | if (newCurrent != version) { 61 | throw new Error(`Failed to update ${appName} to '${version}'`); 62 | } 63 | 64 | spinner.succeed(`${appName}@${newCurrent} installed`); 65 | } catch (err) { 66 | let message = `Failed to update ${appName} to '${version}'. Current version: ${current}`; 67 | if (typeof err == "string") message = err; 68 | spinner.fail(picocolors.red(message)); 69 | } 70 | }); 71 | } 72 | -------------------------------------------------------------------------------- /src/commands/token.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "@commander-js/extra-typings"; 2 | import { cliOutputConfig, setHelpCommandAsDefault } from "@/lib/cli"; 3 | import { createTokenCommand } from "./token/create"; 4 | import { mintTokenCommand } from "./token/mint"; 5 | import { transferTokenCommand } from "./token/transfer"; 6 | 7 | export function tokenCommand() { 8 | return new Command(setHelpCommandAsDefault("token")) 9 | .configureOutput(cliOutputConfig) 10 | .description("create, mint, and transfer tokens") 11 | .addCommand(createTokenCommand()) 12 | .addCommand(mintTokenCommand()) 13 | .addCommand(transferTokenCommand()); 14 | } 15 | -------------------------------------------------------------------------------- /src/commands/token/mint.ts: -------------------------------------------------------------------------------- 1 | import { Command, Option } from "@commander-js/extra-typings"; 2 | import { cliOutputConfig, loadSolanaCliConfig } from "@/lib/cli"; 3 | import { COMMON_OPTIONS } from "@/const/commands"; 4 | import { errorOutro, titleMessage, warningOutro } from "@/lib/logs"; 5 | import ora from "ora"; 6 | 7 | import { wordWithPlurality } from "@/lib/utils"; 8 | import { 9 | createSolanaClient, 10 | isStringifiedNumber, 11 | getExplorerLink, 12 | isNone, 13 | isSome, 14 | isSolanaError, 15 | } from "gill"; 16 | import { buildMintTokensTransaction, fetchMint } from "gill/programs/token"; 17 | import { loadKeypairSignerFromFile } from "gill/node"; 18 | import { parseOrLoadSignerAddress } from "@/lib/gill/keys"; 19 | import { parseOptionsFlagForRpcUrl } from "@/lib/cli/parsers"; 20 | import { simulateTransactionOnThrow } from "@/lib/gill/errors"; 21 | import { getRunningTestValidatorCommand } from "@/lib/shell/test-validator"; 22 | 23 | export function mintTokenCommand() { 24 | return new Command("mint") 25 | .configureOutput(cliOutputConfig) 26 | .description( 27 | "mint new tokens from an existing token's mint (raising the supply)", 28 | ) 29 | .addHelpText( 30 | "after", 31 | `Examples: 32 | $ npx mucho token mint --url devnet \\ 33 | --mint \\ 34 | --destination \\ 35 | --amount 100 36 | 37 | $ npx mucho token mint --url devnet \\ 38 | --mint \\ 39 | --destination \\ 40 | --mint-authority /path/to/mint-authority.json \\ 41 | --amount 100`, 42 | ) 43 | .addOption(new Option("-a --amount ", `amount of tokens to create`)) 44 | .addOption( 45 | new Option( 46 | "-m --mint ", 47 | `token's mint address or keypair file`, 48 | ), 49 | ) 50 | .addOption( 51 | new Option( 52 | "-d --destination
", 53 | `destination address to mint tokens to`, 54 | ), 55 | ) 56 | .addOption( 57 | new Option( 58 | "--mint-authority ", 59 | `keypair file for the mint authority (default: same as --keypair)`, 60 | ), 61 | ) 62 | .addOption(COMMON_OPTIONS.skipPreflight) 63 | .addOption(COMMON_OPTIONS.commitment) 64 | .addOption(COMMON_OPTIONS.keypair) 65 | .addOption(COMMON_OPTIONS.url) 66 | .action(async (options) => { 67 | titleMessage("Mint new tokens"); 68 | const spinner = ora(); 69 | 70 | const parsedRpcUrl = parseOptionsFlagForRpcUrl( 71 | options.url, 72 | /* use the Solana cli config's rpc url as the fallback */ 73 | loadSolanaCliConfig().json_rpc_url, 74 | ); 75 | 76 | if ( 77 | parsedRpcUrl.cluster == "localhost" && 78 | !getRunningTestValidatorCommand() 79 | ) { 80 | spinner.stop(); 81 | return warningOutro( 82 | `Attempted to use localnet with no local validator running. Operation canceled.`, 83 | ); 84 | } 85 | 86 | if (!options.mint) { 87 | return errorOutro( 88 | "Please provide a mint address or keypair -m ", 89 | "No mint address provided", 90 | ); 91 | } 92 | 93 | if (!options.destination) { 94 | return errorOutro( 95 | "Please provide a destination address -d
", 96 | "No destination address provided", 97 | ); 98 | } 99 | 100 | if (!options.amount) { 101 | return errorOutro( 102 | "Please provide a valid amount with -a ", 103 | "Invalid amount", 104 | ); 105 | } 106 | 107 | if (!isStringifiedNumber(options.amount)) { 108 | return errorOutro( 109 | "Please provide valid amount with -a ", 110 | "Invalid amount", 111 | ); 112 | } 113 | 114 | spinner.start("Preparing to mint tokens"); 115 | 116 | try { 117 | // payer will always be used to pay the fees 118 | const payer = await loadKeypairSignerFromFile(options.keypair); 119 | 120 | const mint = await parseOrLoadSignerAddress(options.mint); 121 | const destination = await parseOrLoadSignerAddress(options.destination); 122 | 123 | // mint authority is required to sign in order to mint the initial tokens 124 | const mintAuthority = options.mintAuthority 125 | ? await loadKeypairSignerFromFile(options.mintAuthority) 126 | : payer; 127 | 128 | const { rpc, sendAndConfirmTransaction, simulateTransaction } = 129 | createSolanaClient({ 130 | urlOrMoniker: parsedRpcUrl.url, 131 | }); 132 | 133 | const tokenPlurality = wordWithPlurality( 134 | options.amount, 135 | "token", 136 | "tokens", 137 | ); 138 | 139 | // fetch the current mint from the chain to validate it 140 | const mintAccount = await fetchMint(rpc, mint); 141 | 142 | // supply is capped when the mint authority is removed 143 | if (isNone(mintAccount.data.mintAuthority)) { 144 | return errorOutro( 145 | "This token's can no longer mint new tokens to holders", 146 | "Frozen Mint", 147 | ); 148 | } 149 | 150 | // only the current mint authority can authorize issuing new supply 151 | if ( 152 | isSome(mintAccount.data.mintAuthority) && 153 | mintAccount.data.mintAuthority.value !== mintAuthority.address 154 | ) { 155 | return errorOutro( 156 | `The provided mint authority cannot mint new tokens: ${mintAuthority.address}\n` + 157 | `Only ${mintAccount.data.mintAuthority.value} can authorize minting new tokens`, 158 | "Incorrect Mint Authority", 159 | ); 160 | } 161 | 162 | spinner.text = "Fetching the latest blockhash"; 163 | const { value: latestBlockhash } = await rpc 164 | .getLatestBlockhash() 165 | .send(); 166 | 167 | spinner.text = `Preparing to mint '${options.amount}' ${tokenPlurality} to ${destination}`; 168 | 169 | const mintTokensTx = await buildMintTokensTransaction({ 170 | feePayer: payer, 171 | latestBlockhash, 172 | mint, 173 | mintAuthority, 174 | tokenProgram: mintAccount.programAddress, 175 | amount: Number(options.amount), 176 | // amount: Number(options.amount) * 10 ** Number(options.decimals), 177 | destination: destination, 178 | }); 179 | 180 | spinner.text = `Minting '${options.amount}' ${tokenPlurality} to ${destination}`; 181 | let signature = await sendAndConfirmTransaction(mintTokensTx, { 182 | commitment: options.commitment || "confirmed", 183 | skipPreflight: options.skipPreflight, 184 | }).catch(async (err) => { 185 | await simulateTransactionOnThrow( 186 | simulateTransaction, 187 | err, 188 | mintTokensTx, 189 | ); 190 | throw err; 191 | }); 192 | 193 | spinner.succeed( 194 | `Minted '${options.amount}' ${tokenPlurality} to ${destination}`, 195 | ); 196 | console.log( 197 | " ", 198 | getExplorerLink({ 199 | cluster: parsedRpcUrl.cluster, 200 | transaction: signature, 201 | }), 202 | ); 203 | } catch (err) { 204 | spinner.stop(); 205 | let title = "Failed to complete operation"; 206 | let message = err; 207 | let extraLog = null; 208 | 209 | if (isSolanaError(err)) { 210 | title = "SolanaError"; 211 | message = err.message; 212 | extraLog = err.context; 213 | } 214 | 215 | errorOutro(message, title, extraLog); 216 | } 217 | }); 218 | } 219 | -------------------------------------------------------------------------------- /src/commands/token/transfer.ts: -------------------------------------------------------------------------------- 1 | import { Command, Option } from "@commander-js/extra-typings"; 2 | import { cliOutputConfig, loadSolanaCliConfig } from "@/lib/cli"; 3 | import { COMMON_OPTIONS } from "@/const/commands"; 4 | import { errorOutro, titleMessage, warningOutro } from "@/lib/logs"; 5 | import ora from "ora"; 6 | 7 | import { wordWithPlurality } from "@/lib/utils"; 8 | import { 9 | createSolanaClient, 10 | isStringifiedNumber, 11 | getExplorerLink, 12 | isSolanaError, 13 | SOLANA_ERROR__ACCOUNTS__ACCOUNT_NOT_FOUND, 14 | } from "gill"; 15 | import { 16 | buildTransferTokensTransaction, 17 | fetchMint, 18 | fetchToken, 19 | getAssociatedTokenAccountAddress, 20 | } from "gill/programs/token"; 21 | import { loadKeypairSignerFromFile } from "gill/node"; 22 | import { parseOrLoadSignerAddress } from "@/lib/gill/keys"; 23 | import { parseOptionsFlagForRpcUrl } from "@/lib/cli/parsers"; 24 | import { simulateTransactionOnThrow } from "@/lib/gill/errors"; 25 | import { getRunningTestValidatorCommand } from "@/lib/shell/test-validator"; 26 | 27 | export function transferTokenCommand() { 28 | return new Command("transfer") 29 | .configureOutput(cliOutputConfig) 30 | .description("transfer tokens from one wallet to another") 31 | .addHelpText( 32 | "after", 33 | `Examples: 34 | $ npx mucho token transfer --url devnet \\ 35 | --mint \\ 36 | --destination \\ 37 | --amount 100 38 | 39 | $ npx mucho token transfer --url devnet \\ 40 | --mint \\ 41 | --destination \\ 42 | --source /path/to/source-wallet.json \\ 43 | --amount 100`, 44 | ) 45 | .addOption( 46 | new Option("-a --amount ", `amount of tokens to transfer`), 47 | ) 48 | .addOption( 49 | new Option( 50 | "-m --mint ", 51 | `token's mint address or keypair file`, 52 | ), 53 | ) 54 | .addOption( 55 | new Option( 56 | "-d --destination
", 57 | `destination address to mint tokens to`, 58 | ), 59 | ) 60 | .addOption( 61 | new Option( 62 | "--source ", 63 | `keypair file for the source wallet (default: same as --keypair)`, 64 | ), 65 | ) 66 | .addOption(COMMON_OPTIONS.skipPreflight) 67 | .addOption(COMMON_OPTIONS.commitment) 68 | .addOption(COMMON_OPTIONS.keypair) 69 | .addOption(COMMON_OPTIONS.url) 70 | .action(async (options) => { 71 | titleMessage("Transfer tokens"); 72 | const spinner = ora(); 73 | 74 | const parsedRpcUrl = parseOptionsFlagForRpcUrl( 75 | options.url, 76 | /* use the Solana cli config's rpc url as the fallback */ 77 | loadSolanaCliConfig().json_rpc_url, 78 | ); 79 | 80 | if ( 81 | parsedRpcUrl.cluster == "localhost" && 82 | !getRunningTestValidatorCommand() 83 | ) { 84 | spinner.stop(); 85 | return warningOutro( 86 | `Attempted to use localnet with no local validator running. Operation canceled.`, 87 | ); 88 | } 89 | 90 | if (!options.mint) { 91 | return errorOutro( 92 | "Please provide a mint address or keypair -m ", 93 | "No mint address provided", 94 | ); 95 | } 96 | 97 | if (!options.destination) { 98 | return errorOutro( 99 | "Please provide a destination address -d
", 100 | "No destination address provided", 101 | ); 102 | } 103 | 104 | if (!options.amount) { 105 | return errorOutro( 106 | "Please provide a valid amount with -a ", 107 | "Invalid amount", 108 | ); 109 | } 110 | 111 | if (!isStringifiedNumber(options.amount)) { 112 | return errorOutro( 113 | "Please provide valid amount with -a ", 114 | "Invalid amount", 115 | ); 116 | } 117 | 118 | spinner.start("Preparing to mint tokens"); 119 | 120 | try { 121 | // payer will always be used to pay the fees 122 | const payer = await loadKeypairSignerFromFile(options.keypair); 123 | 124 | const mint = await parseOrLoadSignerAddress(options.mint); 125 | const destination = await parseOrLoadSignerAddress(options.destination); 126 | 127 | // source wallet is required to sign in order to authorize the transfer 128 | const source = options.source 129 | ? await loadKeypairSignerFromFile(options.source) 130 | : payer; 131 | 132 | console.log(); // line spacer after the common "ExperimentalWarning for Ed25519 Web Crypto API" 133 | const spinner = ora("Preparing to transfer tokens").start(); 134 | 135 | const { rpc, sendAndConfirmTransaction, simulateTransaction } = 136 | createSolanaClient({ 137 | urlOrMoniker: parsedRpcUrl.url, 138 | }); 139 | 140 | const tokenPlurality = wordWithPlurality( 141 | options.amount, 142 | "token", 143 | "tokens", 144 | ); 145 | 146 | // fetch the current mint from the chain to validate it 147 | const mintAccount = await fetchMint(rpc, mint).catch((err) => { 148 | if (isSolanaError(err, SOLANA_ERROR__ACCOUNTS__ACCOUNT_NOT_FOUND)) { 149 | spinner.fail(); 150 | throw errorOutro( 151 | `Mint account not found: ${mint}`, 152 | "Mint Not Found", 153 | ); 154 | } 155 | throw err; 156 | }); 157 | 158 | // fetch the source ata from the chain to validate it 159 | const [sourceAta, destinationAta] = await Promise.all([ 160 | getAssociatedTokenAccountAddress( 161 | mint, 162 | source.address, 163 | mintAccount.programAddress, 164 | ), 165 | getAssociatedTokenAccountAddress( 166 | mint, 167 | destination, 168 | mintAccount.programAddress, 169 | ), 170 | ]); 171 | 172 | const tokenAccount = await fetchToken(rpc, sourceAta).catch((err) => { 173 | if (isSolanaError(err, SOLANA_ERROR__ACCOUNTS__ACCOUNT_NOT_FOUND)) { 174 | spinner.fail(); 175 | throw errorOutro( 176 | `Source token account not found:\n` + 177 | `- mint: ${mint}\n` + 178 | `- source owner: ${source.address}\n` + 179 | `- source ata: ${sourceAta}`, 180 | "Token Account Not Found", 181 | ); 182 | } 183 | throw err; 184 | }); 185 | 186 | if (tokenAccount.data.amount < BigInt(options.amount)) { 187 | spinner.fail(); 188 | return errorOutro( 189 | `The provided source wallet does not have enough tokens to transfer:\n` + 190 | `- current balance: ${tokenAccount.data.amount}\n` + 191 | `- transfer amount: ${options.amount}`, 192 | "Insufficient Balance", 193 | ); 194 | } 195 | 196 | spinner.text = "Fetching the latest blockhash"; 197 | const { value: latestBlockhash } = await rpc 198 | .getLatestBlockhash() 199 | .send(); 200 | 201 | spinner.text = `Preparing to transfer '${options.amount}' ${tokenPlurality} to ${destination}`; 202 | 203 | const transferTokensTx = await buildTransferTokensTransaction({ 204 | feePayer: payer, 205 | latestBlockhash, 206 | mint, 207 | authority: source, 208 | tokenProgram: mintAccount.programAddress, 209 | amount: BigInt(options.amount), 210 | destination, 211 | destinationAta, 212 | sourceAta, 213 | }); 214 | 215 | spinner.text = `Transferring '${options.amount}' ${tokenPlurality} to ${destination}`; 216 | let signature = await sendAndConfirmTransaction(transferTokensTx, { 217 | commitment: options.commitment || "confirmed", 218 | skipPreflight: options.skipPreflight, 219 | }).catch(async (err) => { 220 | await simulateTransactionOnThrow( 221 | simulateTransaction, 222 | err, 223 | transferTokensTx, 224 | ); 225 | throw err; 226 | }); 227 | 228 | spinner.succeed( 229 | `Transferred '${options.amount}' ${tokenPlurality} to ${destination}`, 230 | ); 231 | console.log( 232 | " ", 233 | getExplorerLink({ 234 | cluster: parsedRpcUrl.cluster, 235 | transaction: signature, 236 | }), 237 | ); 238 | 239 | const [updatedSourceTokenAccount, updatedDestinationTokenAccount] = 240 | await Promise.all([ 241 | fetchToken(rpc, sourceAta), 242 | fetchToken(rpc, destinationAta), 243 | ]); 244 | 245 | console.log(); 246 | titleMessage("Updated token balances"); 247 | 248 | console.log( 249 | // `Updated token balances:\n` + 250 | `- ${source.address} - source wallet: ${updatedSourceTokenAccount.data.amount}\n` + 251 | `- ${destination} - destination wallet: ${updatedDestinationTokenAccount.data.amount}`, 252 | ); 253 | } catch (err) { 254 | spinner.stop(); 255 | let title = "Failed to complete operation"; 256 | let message = err; 257 | let extraLog = null; 258 | 259 | if (isSolanaError(err)) { 260 | title = "SolanaError"; 261 | message = err.message; 262 | extraLog = err.context; 263 | } 264 | 265 | errorOutro(message, title, extraLog); 266 | } 267 | }); 268 | } 269 | -------------------------------------------------------------------------------- /src/commands/validator.ts: -------------------------------------------------------------------------------- 1 | import { Command, Option } from "@commander-js/extra-typings"; 2 | import { cliOutputConfig, loadConfigToml } from "@/lib/cli"; 3 | import { titleMessage, warnMessage } from "@/lib/logs"; 4 | import { 5 | checkCommand, 6 | getCommandOutputSync, 7 | shellExecInSession, 8 | } from "@/lib/shell"; 9 | import { 10 | deepMerge, 11 | doesFileExist, 12 | loadFileNamesToMap, 13 | updateGitignore, 14 | } from "@/lib/utils"; 15 | import { buildTestValidatorCommand } from "@/lib/shell/test-validator"; 16 | import { COMMON_OPTIONS } from "@/const/commands"; 17 | import { DEFAULT_CACHE_DIR, DEFAULT_TEST_LEDGER_DIR } from "@/const/solana"; 18 | import { deconflictAnchorTomlWithConfig, loadAnchorToml } from "@/lib/anchor"; 19 | import { validateExpectedCloneCounts } from "@/lib/shell/clone"; 20 | import { promptToAutoClone } from "@/lib/prompts/clone"; 21 | import { listLocalPrograms } from "@/lib/programs"; 22 | import { cloneCommand } from "@/commands/clone"; 23 | import { getAppInfo } from "@/lib/app-info"; 24 | import { loadKeypairSignerFromFile } from "gill/node"; 25 | import { getExplorerLink } from "gill"; 26 | import { logger } from "@/lib/logger"; 27 | 28 | /** 29 | * Command: `validator` 30 | * 31 | * Run the 'solana-test-validator' on your local machine 32 | */ 33 | export function validatorCommand() { 34 | // special handle getting the test validator version 35 | if ( 36 | process.argv.length === 4 && 37 | process.argv[2].toLowerCase() == "validator" && 38 | process.argv[3].toLowerCase() == "--version" 39 | ) { 40 | console.log("mucho", getAppInfo().version); 41 | console.log( 42 | getCommandOutputSync("solana-test-validator --version") || 43 | `solana-test-validator not found`, 44 | ); 45 | logger.exit(); 46 | } 47 | 48 | return new Command("validator") 49 | .allowUnknownOption(true) 50 | .addHelpText( 51 | "afterAll", 52 | "Additional Options:\n" + " see 'solana-test-validator --help'", 53 | ) 54 | .configureOutput(cliOutputConfig) 55 | .description("run the Solana test validator on your local machine") 56 | .addOption( 57 | new Option( 58 | "--reset", 59 | "reset the test validator to genesis, reloading all preloaded fixtures", 60 | ), 61 | ) 62 | .addOption( 63 | new Option("--clone", "re-clone all fixtures on validator reset"), 64 | ) 65 | .addOption(COMMON_OPTIONS.outputOnly) 66 | .addOption( 67 | new Option( 68 | "-l, --ledger ", 69 | "location for the local ledger", 70 | ).default(DEFAULT_TEST_LEDGER_DIR), 71 | ) 72 | .addOption(COMMON_OPTIONS.accountDir) 73 | .addOption(COMMON_OPTIONS.keypair) 74 | .addOption(COMMON_OPTIONS.config) 75 | .addOption(COMMON_OPTIONS.url) 76 | .addOption(COMMON_OPTIONS.verbose) 77 | .action(async (options, { args: passThroughArgs }) => { 78 | if (!options.outputOnly) { 79 | titleMessage("solana-test-validator"); 80 | } 81 | // else options.output = options.outputOnly; 82 | 83 | await checkCommand("solana-test-validator --version", { 84 | exit: true, 85 | message: 86 | "Unable to detect the 'solana-test-validator'. Do you have it installed?", 87 | }); 88 | 89 | let config = loadConfigToml(options.config, options); 90 | 91 | updateGitignore([DEFAULT_CACHE_DIR, DEFAULT_TEST_LEDGER_DIR]); 92 | 93 | let authorityAddress: string | null = null; 94 | if (config.settings.keypair) { 95 | if (doesFileExist(config.settings.keypair)) { 96 | authorityAddress = ( 97 | await loadKeypairSignerFromFile(config.settings.keypair) 98 | ).address; 99 | } else { 100 | warnMessage( 101 | `Unable to locate keypair file: ${config.settings.keypair}`, 102 | ); 103 | warnMessage("Skipping auto creation and setting authorities"); 104 | } 105 | } 106 | 107 | // let localPrograms: SolanaTomlCloneLocalProgram = {}; 108 | let locatedPrograms: ReturnType< 109 | typeof listLocalPrograms 110 | >["locatedPrograms"] = {}; 111 | 112 | // attempt to load and combine the anchor toml clone settings 113 | const anchorToml = loadAnchorToml(config.configPath); 114 | if (anchorToml) { 115 | config = deconflictAnchorTomlWithConfig(anchorToml, config); 116 | 117 | // deep merge the solana and anchor config, taking priority with solana toml 118 | config.programs = deepMerge(config.programs, anchorToml.programs); 119 | } 120 | 121 | Object.assign( 122 | locatedPrograms, 123 | listLocalPrograms({ 124 | configPath: config.configPath, 125 | labels: config.programs, 126 | cluster: "localnet", // todo: handle the user selecting the `cluster` 127 | }).locatedPrograms, 128 | ); 129 | 130 | // todo: check if all the local programs were compiled/found, if not => prompt 131 | // if (!localListing.allFound) { 132 | // // todo: add the ability to prompt the user to build their anchor programs 133 | // warnMessage(`Have you built all your local programs?`); 134 | // } 135 | 136 | // auto run the clone on reset 137 | if (options.clone && options.reset) { 138 | // run the clone command with default options 139 | // todo: could we pass options in here if we want? 140 | await cloneCommand().parseAsync([]); 141 | } 142 | 143 | // todo: this is flaky and does not seem to detect if some are missing. fix it 144 | const cloneCounts = validateExpectedCloneCounts( 145 | config.settings.accountDir, 146 | config.clone, 147 | ); 148 | if (cloneCounts.actual !== cloneCounts.expected) { 149 | warnMessage( 150 | `Expected ${cloneCounts.expected} fixtures, but only ${cloneCounts.actual} found.`, 151 | ); 152 | 153 | if (!options.outputOnly) { 154 | await promptToAutoClone(); 155 | } 156 | } 157 | 158 | const command = buildTestValidatorCommand({ 159 | verbose: options.verbose, 160 | reset: options.reset || false, 161 | accountDir: config.settings.accountDir, 162 | ledgerDir: config.settings.ledgerDir, 163 | // todo: allow setting the authority from the cli args 164 | authority: authorityAddress, 165 | localPrograms: locatedPrograms, 166 | }); 167 | 168 | if (options.verbose) console.log(`\n${command}\n`); 169 | // only log the "run validator" command, do not execute it 170 | if (options.outputOnly) logger.exit(); 171 | 172 | if (options.reset && options.verbose) { 173 | console.log( 174 | "Loaded", 175 | loadFileNamesToMap(config.settings.accountDir, ".json").size, 176 | "accounts into the local validator", 177 | ); 178 | console.log( 179 | "Loaded", 180 | loadFileNamesToMap(config.settings.accountDir, ".so").size, 181 | "programs into the local validator", 182 | ); 183 | } 184 | 185 | console.log("\nSolana Explorer for your local test validator:"); 186 | console.log( 187 | "(on Brave Browser, you may need to turn Shields down for the Explorer website)", 188 | ); 189 | console.log(getExplorerLink({ cluster: "localnet" })); 190 | 191 | shellExecInSession({ 192 | command, 193 | args: passThroughArgs, 194 | outputOnly: options.outputOnly, 195 | }); 196 | }); 197 | } 198 | -------------------------------------------------------------------------------- /src/const/commands.ts: -------------------------------------------------------------------------------- 1 | import { Option } from "@commander-js/extra-typings"; 2 | import { DEFAULT_ACCOUNTS_DIR, DEFAULT_CONFIG_FILE } from "./solana"; 3 | import { loadSolanaCliConfig } from "@/lib/cli"; 4 | import { join } from "path"; 5 | import { DEFAULT_CLI_KEYPAIR_PATH } from "gill/node"; 6 | import { Commitment } from "gill"; 7 | 8 | export const cliConfig = loadSolanaCliConfig(); 9 | 10 | /** 11 | * Listing of the common and reusable command options 12 | */ 13 | export const COMMON_OPTIONS = { 14 | /** 15 | * path to the local Solana.toml config file 16 | * 17 | * note: this is a different config file than the solana cli's config file 18 | */ 19 | config: new Option( 20 | "-C --config ", 21 | "path to a Solana.toml config file", 22 | ).default(DEFAULT_CONFIG_FILE), 23 | /** 24 | * path to the local authority keypair 25 | */ 26 | keypair: new Option("--keypair ", "path to a keypair file").default( 27 | cliConfig?.keypair_path || DEFAULT_CLI_KEYPAIR_PATH, 28 | ), 29 | /** skip preflight checks for transactions */ 30 | skipPreflight: new Option( 31 | "--skip-preflight", 32 | "whether or not to skip preflight checks", 33 | ), 34 | commitment: new Option( 35 | "--commitment ", 36 | "desired commitment level for transactions", 37 | ) 38 | .choices(["confirmed", "finalized", "processed"] as Commitment[]) 39 | .default("confirmed" as Commitment), 40 | /** 41 | * rpc url or moniker to use 42 | */ 43 | url: new Option( 44 | "-u --url ", 45 | "URL for Solana's JSON RPC or moniker (or their first letter)", 46 | ), 47 | verbose: new Option("--verbose", "enable verbose output mode"), 48 | outputOnly: new Option( 49 | "--output-only", 50 | "only output the generated command, do not execute it", 51 | ), 52 | /** 53 | * local directory path to store and load any cloned accounts 54 | */ 55 | accountDir: new Option( 56 | "--account-dir ", 57 | "local directory path to store any cloned accounts", 58 | ).default(DEFAULT_ACCOUNTS_DIR), 59 | manifestPath: new Option( 60 | "--manifest-path ", 61 | "path to Cargo.toml", 62 | ).default(join(process.cwd(), "Cargo.toml")), 63 | /** 64 | * priority fee in micro-lamports to add to transactions 65 | */ 66 | priorityFee: new Option( 67 | "--priority-fee ", 68 | "priority fee in micro-lamports to add to transactions", 69 | ), 70 | }; 71 | -------------------------------------------------------------------------------- /src/const/setup.ts: -------------------------------------------------------------------------------- 1 | import { ToolCommandConfig, ToolNames } from "@/types"; 2 | 3 | export const TOOL_CONFIG: { [key in ToolNames]: ToolCommandConfig } = { 4 | node: { 5 | version: "node --version", 6 | }, 7 | rust: { 8 | pathSource: "$HOME/.cargo/env", 9 | version: "rustc --version", 10 | }, 11 | rustup: { 12 | version: "rustup --version", 13 | }, 14 | solana: { 15 | dependencies: ["rust"], 16 | pathSource: "$HOME/.local/share/solana/install/active_release/bin", 17 | version: "solana --version", 18 | }, 19 | avm: { 20 | dependencies: ["rust"], 21 | version: "avm --version", 22 | }, 23 | anchor: { 24 | dependencies: ["avm"], 25 | version: "anchor --version", 26 | }, 27 | yarn: { 28 | version: "yarn --version", 29 | }, 30 | trident: { 31 | dependencies: ["rust"], 32 | // the trident cli does not have a version command, so we grab it from cargo 33 | version: "cargo install --list | grep trident-cli", 34 | }, 35 | zest: { 36 | dependencies: ["rust"], 37 | // the zest cli does not have a version command, so we grab it from cargo 38 | version: "cargo install --list | grep zest", 39 | }, 40 | "cargo-update": { 41 | dependencies: ["rust"], 42 | version: "cargo install --list | grep cargo-update", 43 | }, 44 | verify: { 45 | dependencies: ["rust"], 46 | version: "solana-verify --version", 47 | }, 48 | }; 49 | 50 | export enum PathSourceStatus { 51 | SUCCESS, 52 | FAILED, 53 | OUTPUT_MISMATCH, 54 | MISSING_PATH, 55 | } 56 | -------------------------------------------------------------------------------- /src/const/solana.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_CONFIG_FILE = "Solana.toml"; 2 | 3 | export const DEFAULT_ACCOUNTS_DIR = "fixtures"; 4 | 5 | export const DEFAULT_CACHE_DIR = ".cache"; 6 | 7 | export const DEFAULT_TEST_LEDGER_DIR = "test-ledger"; 8 | 9 | export const DEFAULT_ACCOUNTS_DIR_TEMP = ".cache/staging/fixtures"; 10 | 11 | export const DEFAULT_ACCOUNTS_DIR_LOADED = ".cache/loaded/fixtures"; 12 | 13 | export const DEFAULT_CLI_YAML_PATH = "~/.config/solana/cli/config.yml"; 14 | 15 | export const DEFAULT_BUILD_DIR = "target/deploy"; 16 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { 4 | assertRuntimeVersion, 5 | patchBigint, 6 | suppressRuntimeWarnings, 7 | } from "@/lib/node"; 8 | 9 | assertRuntimeVersion(); 10 | suppressRuntimeWarnings(); 11 | patchBigint(); 12 | 13 | import { logger } from "@/lib/logger"; 14 | // we expect this to do nothing really but it ensures we have global 15 | // import access to the logger as soon as the command is run 16 | logger.getLogs(); 17 | 18 | // create the global error boundary 19 | process.on("uncaughtException", (err) => { 20 | logger.error("Uncaught exception", err); 21 | 22 | let message = "An uncaught exception has occurred"; 23 | if (typeof err == "string") message += `: ${err}`; 24 | else if (err instanceof Error) message += `: ${err.name}: ${err.message}`; 25 | 26 | errorOutro(message, "[Uncaught exception]"); 27 | }); 28 | 29 | import { checkForSelfUpdate } from "@/lib/npm"; 30 | import { errorOutro } from "@/lib/logs"; 31 | import { cliProgramRoot } from "@/commands"; 32 | 33 | import { installCommand } from "@/commands/install"; 34 | // import { doctorCommand } from "@/commands/doctor"; 35 | import { infoCommand } from "@/commands/info"; 36 | import { cloneCommand } from "@/commands/clone"; 37 | import { validatorCommand } from "@/commands/validator"; 38 | import { buildCommand } from "@/commands/build"; 39 | import { coverageCommand } from "@/commands/coverage"; 40 | import { deployCommand } from "@/commands/deploy"; 41 | import { tokenCommand } from "./commands/token"; 42 | import { docsCommand } from "@/commands/docs"; 43 | import { inspectCommand } from "@/commands/inspect"; 44 | import { balanceCommand } from "@/commands/balance"; 45 | import { selfUpdateCommand } from "./commands/self-update"; 46 | 47 | async function main() { 48 | // auto check for new version of the cli when not attempting the self-update 49 | if ( 50 | process.argv?.[2]?.toLowerCase() !== "self-update" || 51 | process.argv?.[3]?.toLowerCase() === "--help" 52 | ) { 53 | await checkForSelfUpdate(); 54 | } 55 | 56 | const program = cliProgramRoot(); 57 | 58 | program 59 | .addCommand(installCommand()) 60 | // .addCommand(doctorCommand()) 61 | .addCommand(validatorCommand()) 62 | .addCommand(inspectCommand()) 63 | .addCommand(cloneCommand()) 64 | .addCommand(buildCommand()) 65 | .addCommand(deployCommand()) 66 | .addCommand(tokenCommand()) 67 | .addCommand(balanceCommand()) 68 | .addCommand(coverageCommand()) 69 | .addCommand(infoCommand()) 70 | .addCommand(docsCommand()) 71 | .addCommand(selfUpdateCommand()); 72 | 73 | // set the default action to `help` without an error 74 | if (process.argv.length === 2) { 75 | process.argv.push("--help"); 76 | } 77 | 78 | await program.parseAsync(); 79 | 80 | // add a line spacer at the end to make the terminal easier to read 81 | console.log(); 82 | } 83 | 84 | main(); 85 | -------------------------------------------------------------------------------- /src/lib/anchor.ts: -------------------------------------------------------------------------------- 1 | import { join, dirname } from "path"; 2 | import { 3 | AnchorToml, 4 | AnchorTomlWithConfigPath, 5 | AnchorVersionData, 6 | } from "@/types/anchor"; 7 | import { 8 | directoryExists, 9 | doesFileExist, 10 | loadFileNamesToMap, 11 | loadTomlFile, 12 | } from "@/lib/utils"; 13 | import { loadConfigToml } from "@/lib/cli"; 14 | import { warningOutro, warnMessage } from "@/lib/logs"; 15 | import { SolanaTomlCloneLocalProgram } from "@/types/config"; 16 | import { checkCommand, VERSION_REGEX } from "./shell"; 17 | import { installAnchorVersionManager } from "./install"; 18 | 19 | const ANCHOR_TOML = "Anchor.toml"; 20 | 21 | /** 22 | * Load an Anchor.toml file, normally from the same dir as the Solana.toml 23 | */ 24 | export function loadAnchorToml( 25 | configPath: string, 26 | isConfigRequired: boolean = false, 27 | ): AnchorTomlWithConfigPath | false { 28 | // allow the config path to be a full filepath to search that same directory 29 | if (!configPath.endsWith(ANCHOR_TOML)) configPath = dirname(configPath); 30 | 31 | // allow the config path to be a directory, with an Anchor.toml in it 32 | if (directoryExists(configPath)) configPath = join(configPath, ANCHOR_TOML); 33 | 34 | let anchor: AnchorTomlWithConfigPath = { 35 | configPath, 36 | }; 37 | 38 | if (doesFileExist(configPath, true)) { 39 | anchor = loadTomlFile(configPath) || anchor; 40 | } else { 41 | if (isConfigRequired) { 42 | warningOutro(`No Anchor.toml config file found. Operation canceled.`); 43 | } 44 | return false; 45 | // else warnMessage(`No Anchor.toml config file found. Skipping.`); 46 | } 47 | 48 | anchor.configPath = configPath; 49 | return anchor as AnchorTomlWithConfigPath; 50 | } 51 | 52 | /** 53 | * Deconflict and merge Anchor.toml into the Solana.toml config 54 | * 55 | * note: intentionally mutates the `config` 56 | */ 57 | export function deconflictAnchorTomlWithConfig( 58 | anchorToml: AnchorToml, 59 | config: ReturnType, 60 | ) { 61 | // todo: use the provided `anchorToml.test.validator.url`? 62 | 63 | // copy anchor cloneable programs 64 | if (anchorToml.test?.validator?.clone) { 65 | for (const cloner in anchorToml.test.validator.clone) { 66 | if ( 67 | Object.prototype.hasOwnProperty.call( 68 | anchorToml.test.validator.clone, 69 | cloner, 70 | ) && 71 | !config.clone.program[anchorToml.test.validator.clone[cloner].address] 72 | ) { 73 | if (anchorToml.test.validator?.url) { 74 | anchorToml.test.validator.clone[cloner].cluster = 75 | anchorToml.test.validator.url; 76 | } 77 | 78 | // looks ugly, but we dont have to allocate anything 79 | config.clone.program[anchorToml.test.validator.clone[cloner].address] = 80 | anchorToml.test.validator.clone[cloner]; 81 | } 82 | } 83 | } 84 | 85 | // todo: for accounts that are owned by the token programs, 86 | // todo: attempt to resolve them as mints and clone them using our mint cloner 87 | 88 | // copy anchor cloneable accounts 89 | if (anchorToml.test?.validator?.account) { 90 | for (const cloner in anchorToml.test.validator.account) { 91 | if ( 92 | Object.prototype.hasOwnProperty.call( 93 | anchorToml.test.validator.account, 94 | cloner, 95 | ) && 96 | !config.clone.account[anchorToml.test.validator.account[cloner].address] 97 | ) { 98 | if (anchorToml.test.validator?.url) { 99 | anchorToml.test.validator.account[cloner].cluster = 100 | anchorToml.test.validator.url; 101 | } 102 | 103 | // looks ugly, but we dont have to allocate anything 104 | config.clone.account[ 105 | anchorToml.test.validator.account[cloner].address 106 | ] = anchorToml.test.validator.account[cloner]; 107 | } 108 | } 109 | } 110 | 111 | // console.log(config.clone.program); 112 | 113 | return config; 114 | } 115 | 116 | export function locateLocalAnchorPrograms( 117 | configPath: string, 118 | programListing: AnchorToml["programs"], 119 | ): SolanaTomlCloneLocalProgram { 120 | const buildDir = join(dirname(configPath), "target/deploy"); 121 | 122 | let localPrograms: SolanaTomlCloneLocalProgram = {}; 123 | 124 | if (!directoryExists(buildDir)) return localPrograms; 125 | 126 | const anchorPrograms = loadFileNamesToMap(buildDir, ".so"); 127 | 128 | // todo: handle the user selecting the cluster 129 | const cluster: keyof typeof programListing = "localnet"; 130 | 131 | if (!Object.prototype.hasOwnProperty.call(programListing, cluster)) { 132 | warnMessage(`Unable to locate 'programs.${cluster}' in Anchor.toml`); 133 | return localPrograms; 134 | } 135 | 136 | let missingCounter = 0; 137 | 138 | anchorPrograms.forEach((binaryName, programName) => { 139 | if ( 140 | Object.prototype.hasOwnProperty.call( 141 | programListing[cluster], 142 | programName, 143 | ) && 144 | !Object.hasOwn(localPrograms, programName) 145 | ) { 146 | localPrograms[programName] = { 147 | address: programListing[cluster][programName], 148 | filePath: join(buildDir, binaryName), 149 | }; 150 | } else { 151 | missingCounter++; 152 | warnMessage( 153 | `Unable to locate compiled program '${programName}' in Anchor.toml`, 154 | ); 155 | } 156 | }); 157 | 158 | if (missingCounter > 0) { 159 | // todo: add the ability to prompt the user to build their anchor programs 160 | warnMessage(`Have you built all your local Anchor programs?`); 161 | } 162 | 163 | return localPrograms; 164 | } 165 | 166 | /** 167 | * Use the `avm list` command to fetch the latest anchor version available 168 | */ 169 | export async function getAvailableAnchorVersions(): Promise { 170 | const res = await checkCommand("avm list", { 171 | exit: true, 172 | onError: async () => { 173 | warnMessage("Unable to detect the 'avm' command. Installing..."); 174 | 175 | await installAnchorVersionManager(); 176 | }, 177 | doubleCheck: true, 178 | }); 179 | 180 | const data: AnchorVersionData = { 181 | latest: null, 182 | current: null, 183 | installed: [], 184 | available: [], 185 | }; 186 | 187 | if (!res) return data; 188 | 189 | res.split("\n").map((line) => { 190 | line = line.trim().toLowerCase(); 191 | if (!line) return; 192 | const version = VERSION_REGEX.exec(line)?.[1]; 193 | if (!version) return; 194 | 195 | if (line.includes("current")) data.current = version; 196 | if (line.includes("latest")) data.latest = version; 197 | if (line.includes("installed")) data.installed.push(version); 198 | 199 | data.available.push(version); 200 | }); 201 | 202 | return data; 203 | } 204 | -------------------------------------------------------------------------------- /src/lib/app-info.ts: -------------------------------------------------------------------------------- 1 | import packageJson from "../../package.json"; 2 | 3 | /** 4 | * Load standard info about this tool 5 | */ 6 | export function getAppInfo(): { 7 | name: string; 8 | version: string; 9 | } { 10 | return { 11 | version: packageJson.version, 12 | name: packageJson.name, 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/cargo.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Assorted helper functions and wrappers for working in the CLI 3 | */ 4 | 5 | import { CargoTomlWithConfigPath } from "@/types/cargo"; 6 | import { directoryExists, doesFileExist, loadTomlFile } from "@/lib/utils"; 7 | import { readdirSync, statSync } from "fs"; 8 | import { dirname, join, relative } from "path"; 9 | 10 | const DEFAULT_CARGO_TOML_FILE = "Cargo.toml"; 11 | 12 | /** 13 | * Load a Cargo.toml file 14 | */ 15 | export function loadCargoToml( 16 | manifestPath: string = DEFAULT_CARGO_TOML_FILE, 17 | // settings: object = {}, 18 | // isManifestRequired: boolean = false, 19 | ): CargoTomlWithConfigPath | false { 20 | // allow the config path to be a directory, with a Solana.toml in it 21 | if (directoryExists(manifestPath)) { 22 | manifestPath = join(manifestPath, DEFAULT_CARGO_TOML_FILE); 23 | } 24 | 25 | if (doesFileExist(manifestPath, true)) { 26 | return loadTomlFile(manifestPath); 27 | } else { 28 | return false; 29 | // if (isManifestRequired) { 30 | // warningOutro(`No Cargo.toml file found. Operation canceled.`); 31 | // } else warnMessage(`No Cargo.toml file found. Skipping.`); 32 | } 33 | } 34 | 35 | export function findAllCargoToml( 36 | startDir: string, 37 | whitelist: string[] = [], 38 | blacklist: string[] = ["node_modules", "dist", "target"], 39 | maxDepth: number = 3, 40 | ): string[] { 41 | const cargoTomlPaths: string[] = []; 42 | 43 | // Convert whitelist patterns to regular expressions for wildcard matching 44 | const whitelistPatterns = whitelist.map( 45 | (pattern) => new RegExp("^" + pattern.replace(/\*/g, ".*") + "$"), 46 | ); 47 | 48 | // Helper function to check if a directory matches any whitelist pattern 49 | function isWhitelisted(relativeDir: string): boolean { 50 | if (whitelistPatterns.length === 0) { 51 | return true; 52 | } 53 | return whitelistPatterns.some((regex) => regex.test(relativeDir)); 54 | } 55 | 56 | // Helper function to recursively search directories 57 | function searchDir(dir: string, depth: number): void { 58 | // Stop searching if maxDepth is reached 59 | if (depth > maxDepth) { 60 | return; 61 | } 62 | 63 | const items = readdirSync(dir); 64 | for (const item of items) { 65 | const itemPath = join(dir, item); 66 | const stats = statSync(itemPath); 67 | 68 | if (stats.isFile() && item === "Cargo.toml") { 69 | cargoTomlPaths.push(itemPath); 70 | } 71 | 72 | if (stats.isDirectory()) { 73 | const relativeDir = relative(startDir, itemPath); 74 | 75 | if (blacklist.includes(relativeDir) && !isWhitelisted(relativeDir)) { 76 | continue; 77 | } 78 | 79 | searchDir(itemPath, depth + 1); 80 | } 81 | } 82 | } 83 | 84 | searchDir(startDir, 0); 85 | 86 | return cargoTomlPaths; 87 | } 88 | 89 | export function getProgramPathsInWorkspace( 90 | startDir: string, 91 | workspaceDirs: string[], 92 | ) { 93 | const programPaths = new Map(); 94 | 95 | let tempToml: false | ReturnType = false; 96 | 97 | if (doesFileExist(startDir)) startDir = dirname(startDir); 98 | 99 | const allTomls = findAllCargoToml(startDir, workspaceDirs); 100 | 101 | allTomls.map((progPath) => { 102 | if (!doesFileExist(progPath)) return; 103 | 104 | tempToml = loadCargoToml(progPath); 105 | if (!tempToml || tempToml.workspace) return; 106 | 107 | const name = tempToml?.lib?.name || tempToml?.package?.name; 108 | if (!name) return; 109 | // require that the package name matches the directory name 110 | // if (name !== basename(dirname(tempToml.configPath))) return; 111 | 112 | programPaths.set(name, tempToml.configPath); 113 | }); 114 | 115 | return programPaths; 116 | } 117 | 118 | export function autoLocateProgramsInWorkspace( 119 | manifestPath: string = join(process.cwd(), "Cargo.toml"), 120 | workspaceDirs: string[] = ["temp", "programs/*", "program"], 121 | ): { 122 | programs: Map; 123 | cargoToml: false | CargoTomlWithConfigPath; 124 | } { 125 | // determine if we are in a program specific dir or the workspace 126 | let cargoToml = loadCargoToml(manifestPath); 127 | 128 | if (!cargoToml) { 129 | workspaceDirs.some((workspace) => { 130 | const filePath = join( 131 | process.cwd(), 132 | workspace.replace(/\*+$/, ""), 133 | "Cargo.toml", 134 | ); 135 | if (doesFileExist(filePath)) { 136 | cargoToml = loadCargoToml(filePath); 137 | if (cargoToml) return; 138 | } 139 | }); 140 | } 141 | 142 | let programs = new Map(); 143 | 144 | if (cargoToml) { 145 | // always update the current manifest path to the one of the loaded Cargo.toml 146 | if (cargoToml.configPath) { 147 | manifestPath = cargoToml.configPath; 148 | } 149 | 150 | if (cargoToml.workspace?.members) { 151 | workspaceDirs = cargoToml.workspace.members; 152 | } 153 | 154 | programs = getProgramPathsInWorkspace(manifestPath, workspaceDirs); 155 | } 156 | 157 | return { programs, cargoToml }; 158 | } 159 | -------------------------------------------------------------------------------- /src/lib/cli/config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Assorted helper functions and wrappers for working in the CLI 3 | */ 4 | 5 | import { join } from "path"; 6 | import { OutputConfiguration } from "@commander-js/extra-typings"; 7 | import { 8 | directoryExists, 9 | doesFileExist, 10 | findClosestFile, 11 | isInCurrentDir, 12 | loadTomlFile, 13 | loadYamlFile, 14 | } from "@/lib/utils"; 15 | import { SolanaToml, SolanaTomlWithConfigPath } from "@/types/config"; 16 | import { 17 | DEFAULT_CLI_YAML_PATH, 18 | DEFAULT_CONFIG_FILE, 19 | DEFAULT_TEST_LEDGER_DIR, 20 | } from "@/const/solana"; 21 | import { COMMON_OPTIONS } from "@/const/commands"; 22 | import { warningOutro, warnMessage } from "@/lib/logs"; 23 | import { SolanaCliYaml } from "@/types/solana"; 24 | import { DEFAULT_CLI_KEYPAIR_PATH } from "gill/node"; 25 | import { getClusterMonikerFromUrl } from "../solana"; 26 | 27 | /** 28 | * Load the Solana CLI's config file 29 | */ 30 | export function loadSolanaCliConfig( 31 | filePath: string = DEFAULT_CLI_YAML_PATH, 32 | ): SolanaCliYaml { 33 | try { 34 | const cliConfig = loadYamlFile(filePath); 35 | 36 | // auto convert the rpc url to the cluster moniker 37 | if (cliConfig?.json_rpc_url) { 38 | cliConfig.json_rpc_url = getClusterMonikerFromUrl(cliConfig.json_rpc_url); 39 | } 40 | 41 | return cliConfig; 42 | } catch (err) { 43 | return { 44 | keypair_path: DEFAULT_CLI_KEYPAIR_PATH, 45 | }; 46 | } 47 | } 48 | 49 | /** 50 | * Load the Solana.toml config file and handle the default settings overrides 51 | */ 52 | export function loadConfigToml( 53 | configPath: string = DEFAULT_CONFIG_FILE, 54 | settings: object = {}, 55 | isConfigRequired: boolean = false, 56 | ): SolanaTomlWithConfigPath { 57 | // allow the config path to be a directory, with a Solana.toml in it 58 | if (directoryExists(configPath)) { 59 | configPath = join(configPath, DEFAULT_CONFIG_FILE); 60 | } 61 | 62 | // attempt to locate the closest config file 63 | if (configPath === DEFAULT_CONFIG_FILE) { 64 | // accepts both `Solana.toml` and `solana.toml` (case insensitive) 65 | const newPath = findClosestFile({ 66 | fileName: DEFAULT_CONFIG_FILE, 67 | }); 68 | if (newPath) { 69 | configPath = newPath; 70 | if (!isInCurrentDir(newPath)) { 71 | // todo: should we prompt the user if they want to use this one? 72 | warnMessage(`Using Solana.toml located at: ${newPath}`); 73 | } 74 | } 75 | } 76 | 77 | let config: SolanaToml = { configPath }; 78 | 79 | if (doesFileExist(configPath, true)) { 80 | config = loadTomlFile(configPath) || config; 81 | } else { 82 | if (isConfigRequired) { 83 | warningOutro(`No Solana.toml config file found. Operation canceled.`); 84 | } 85 | } 86 | 87 | const defaultSettings: SolanaToml["settings"] = { 88 | cluster: COMMON_OPTIONS.url.defaultValue, 89 | accountDir: COMMON_OPTIONS.accountDir.defaultValue, 90 | keypair: COMMON_OPTIONS.keypair.defaultValue, 91 | ledgerDir: DEFAULT_TEST_LEDGER_DIR, 92 | }; 93 | 94 | config.settings = Object.assign(defaultSettings, config.settings || {}); 95 | 96 | config = deconflictSolanaTomlConfig(config, settings); 97 | 98 | config.configPath = configPath; 99 | return config as SolanaTomlWithConfigPath; 100 | } 101 | 102 | /** 103 | * Used to deconflict a Solana.toml's declarations with the provided input, 104 | * setting the desired priority of values 105 | */ 106 | export function deconflictSolanaTomlConfig(config: SolanaToml, args: any) { 107 | if (!config.settings) config.settings = {}; 108 | 109 | if (args?.url && args.url !== COMMON_OPTIONS.url.defaultValue) { 110 | config.settings.cluster = args.url; 111 | } 112 | if ( 113 | args?.accountDir && 114 | args.accountDir !== COMMON_OPTIONS.accountDir.defaultValue 115 | ) { 116 | config.settings.accountDir = args.accountDir; 117 | } 118 | 119 | if (args?.keypair && args.keypair !== COMMON_OPTIONS.keypair.defaultValue) { 120 | config.settings.keypair = args.keypair; 121 | } 122 | 123 | return config; 124 | } 125 | 126 | /** 127 | * Default Commander output configuration to be passed into `configureOutput()` 128 | */ 129 | export const cliOutputConfig: OutputConfiguration = { 130 | writeErr(str: string) { 131 | // log.error(str.trim() + "\n"); 132 | console.log(str.trim() + "\n"); 133 | // console.log(); 134 | }, 135 | writeOut(str: string) { 136 | // log.info(str.trim() + "\n"); 137 | console.log(str.trim() + "\n"); 138 | // console.log(); 139 | }, 140 | }; 141 | -------------------------------------------------------------------------------- /src/lib/cli/help.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Make the default output for a given command be the `--help` response 3 | * 4 | * Offset: default arg offset to get the user supplied command 5 | * - [0] = node 6 | * - [1] = mucho 7 | * - [2+] = command and args 8 | */ 9 | export function setHelpCommandAsDefault( 10 | command: string, 11 | offset: number = 2, 12 | ): string { 13 | const splitter = command.toLowerCase().split(" "); 14 | const checkCommand = process.argv.slice(offset, offset + splitter.length); 15 | 16 | // only add the help flag when the configured command is used 17 | if (checkCommand.join(" ").toLowerCase() == splitter.join(" ")) { 18 | if (process.argv.length === offset + splitter.length) { 19 | process.argv.push("--help"); 20 | } 21 | } 22 | 23 | return splitter.slice(splitter.length - 1)[0]; 24 | } 25 | -------------------------------------------------------------------------------- /src/lib/cli/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./config"; 2 | export * from "./help"; 3 | -------------------------------------------------------------------------------- /src/lib/cli/parsers/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./url"; 2 | -------------------------------------------------------------------------------- /src/lib/cli/parsers/url.ts: -------------------------------------------------------------------------------- 1 | import { getPublicSolanaRpcUrl } from "gill"; 2 | import { SolanaCliClusterMonikers } from "@/types/config"; 3 | import { getClusterMonikerFromUrl, parseRpcUrlOrMoniker } from "@/lib/solana"; 4 | 5 | type ParsedUrlAndCluster = { cluster: SolanaCliClusterMonikers; url: URL }; 6 | 7 | /** 8 | * Parse the CLI flag input for the rpc urls: `--url` 9 | */ 10 | export function parseOptionsFlagForRpcUrl( 11 | input: URL | string | undefined, 12 | fallbackUrlOrMoniker?: ParsedUrlAndCluster["url"] | string, 13 | ): ParsedUrlAndCluster { 14 | try { 15 | if (!input && fallbackUrlOrMoniker) { 16 | if (fallbackUrlOrMoniker.toString().startsWith("http")) { 17 | input = new URL(fallbackUrlOrMoniker); 18 | } else { 19 | input = parseRpcUrlOrMoniker(fallbackUrlOrMoniker.toString()); 20 | } 21 | } 22 | } catch (err) { 23 | throw new Error( 24 | `Unable to parse fallbackUrlOrMoniker: ${fallbackUrlOrMoniker}`, 25 | ); 26 | } 27 | 28 | if (!input) { 29 | throw new Error("Invalid input provided for parsing the RPC url"); 30 | } 31 | 32 | input = input.toString(); 33 | 34 | let cluster: ParsedUrlAndCluster["cluster"]; 35 | 36 | if (input.startsWith("http")) { 37 | try { 38 | cluster = getClusterMonikerFromUrl(input); 39 | } catch (err) { 40 | /** 41 | * fallback to devnet for the cluster when unable to determine 42 | * todo: we need to figure out a better way to handle this 43 | * todo: without adjusting this, mucho does not really support custom rpc urls 44 | */ 45 | cluster = "devnet"; 46 | } 47 | } else { 48 | cluster = parseRpcUrlOrMoniker( 49 | input, 50 | false /* do not allow urls while parsing the cluster */, 51 | ); 52 | } 53 | 54 | let url: URL; 55 | 56 | if (input.startsWith("http")) { 57 | url = new URL(input); 58 | } else { 59 | url = new URL( 60 | getPublicSolanaRpcUrl( 61 | // FIXME(nick): next version of `gill` adds the `localhost` value here 62 | cluster == "localhost" ? "localnet" : cluster, 63 | ), 64 | ); 65 | } 66 | 67 | return { 68 | url, 69 | cluster, 70 | }; 71 | } 72 | -------------------------------------------------------------------------------- /src/lib/gill/errors.ts: -------------------------------------------------------------------------------- 1 | import { 2 | isSolanaError, 3 | SOLANA_ERROR__JSON_RPC__SERVER_ERROR_SEND_TRANSACTION_PREFLIGHT_FAILURE, 4 | SolanaError, 5 | type simulateTransactionFactory, 6 | } from "gill"; 7 | 8 | function encodeValue(value: unknown): string { 9 | if (Array.isArray(value)) { 10 | const commaSeparatedValues = value 11 | .map(encodeValue) 12 | .join("%2C%20" /* ", " */); 13 | return "%5B" /* "[" */ + commaSeparatedValues + /* "]" */ "%5D"; 14 | } else if (typeof value === "bigint") { 15 | return `${value}n`; 16 | } else { 17 | return encodeURIComponent( 18 | String( 19 | value != null && Object.getPrototypeOf(value) === null 20 | ? // Plain objects with no prototype don't have a `toString` method. 21 | // Convert them before stringifying them. 22 | { ...(value as object) } 23 | : value, 24 | ), 25 | ); 26 | } 27 | } 28 | 29 | function encodeObjectContextEntry([key, value]: [ 30 | string, 31 | unknown, 32 | ]): `${typeof key}=${string}` { 33 | return `${key}=${encodeValue(value)}`; 34 | } 35 | 36 | export function encodeContextObject(context: object): string { 37 | const searchParamsString = Object.entries(context) 38 | .map(encodeObjectContextEntry) 39 | .join("&"); 40 | return Buffer.from(searchParamsString, "utf8").toString("base64"); 41 | } 42 | 43 | /** 44 | * 45 | */ 46 | export async function simulateTransactionOnThrow( 47 | simulateTransaction: ReturnType, 48 | err: any, 49 | tx: Parameters>["0"], 50 | ) { 51 | if ( 52 | isSolanaError( 53 | err, 54 | SOLANA_ERROR__JSON_RPC__SERVER_ERROR_SEND_TRANSACTION_PREFLIGHT_FAILURE, 55 | ) 56 | ) { 57 | const { value } = await simulateTransaction(tx, { 58 | replaceRecentBlockhash: true, 59 | innerInstructions: true, 60 | }); 61 | 62 | // console.log( 63 | // `npx @solana/errors decode -- ${err.context.__code} ${encodeContextObject( 64 | // err.context, 65 | // )}`, 66 | // ); 67 | 68 | throw new SolanaError( 69 | SOLANA_ERROR__JSON_RPC__SERVER_ERROR_SEND_TRANSACTION_PREFLIGHT_FAILURE, 70 | { 71 | ...value, 72 | unitsConsumed: Number(value.unitsConsumed), 73 | }, 74 | ); 75 | } else throw err; 76 | } 77 | -------------------------------------------------------------------------------- /src/lib/gill/keys.ts: -------------------------------------------------------------------------------- 1 | import { address, Address, isAddress } from "gill"; 2 | import { loadKeypairSignerFromFile } from "gill/node"; 3 | import { doesFileExist } from "../utils"; 4 | 5 | /** 6 | * Get the Solana address from the provided input string, 7 | * either validating it as an `Address` directly or loading the Signer's address from filepath 8 | */ 9 | export async function parseOrLoadSignerAddress( 10 | input: string, 11 | ): Promise
{ 12 | if (isAddress(input)) return address(input); 13 | if (!doesFileExist(input)) { 14 | throw new Error("Invalid address or keypair file provided: " + input); 15 | } 16 | const signer = await loadKeypairSignerFromFile(input); 17 | return signer.address; 18 | } 19 | -------------------------------------------------------------------------------- /src/lib/git.ts: -------------------------------------------------------------------------------- 1 | import { join, resolve } from "path"; 2 | import { execSync } from "child_process"; 3 | import { existsSync } from "fs"; 4 | import { createFolders } from "./utils"; 5 | 6 | /** 7 | * Clone or force update a git repo into the target location 8 | */ 9 | export function cloneOrUpdateRepo( 10 | repoUrl: string, 11 | targetFolder: string, 12 | branch: string | null = null, 13 | ): boolean { 14 | try { 15 | targetFolder = resolve(targetFolder); 16 | const commands: string[] = []; 17 | if (existsSync(targetFolder)) { 18 | commands.push( 19 | `git -C ${targetFolder} fetch origin ${branch || ""}`, 20 | `git -C ${targetFolder} reset --hard ${ 21 | branch ? `origin/${branch}` : "origin" 22 | }`, 23 | `git -C ${targetFolder} pull origin ${branch || ""}`, 24 | ); 25 | } else { 26 | commands.push( 27 | `git clone ${ 28 | branch ? `--branch ${branch}` : "" 29 | } ${repoUrl} ${targetFolder}`, 30 | ); 31 | } 32 | 33 | execSync(commands.join(" && "), { 34 | stdio: "ignore", // hide output 35 | // stdio: "inherit", // show output 36 | }); 37 | 38 | if (existsSync(targetFolder)) return true; 39 | else return false; 40 | } catch (error) { 41 | console.error( 42 | "[cloneOrUpdateRepo]", 43 | "Unable to clone repo:", 44 | error.message, 45 | ); 46 | return false; 47 | } 48 | } 49 | 50 | export function isGitRepo(targetFolder: string): boolean { 51 | try { 52 | // Run git command to check if inside a git repository 53 | const result = execSync( 54 | `git -C ${targetFolder} rev-parse --is-inside-work-tree`, 55 | { 56 | stdio: "pipe", // so we can parse output 57 | }, 58 | ) 59 | .toString() 60 | .trim(); 61 | 62 | return result === "true"; 63 | } catch (error) { 64 | // If command fails, it means it's not a git repo 65 | return false; 66 | } 67 | } 68 | 69 | export function initGitRepo( 70 | targetFolder: string, 71 | commitMessage: string = "init", 72 | ): boolean { 73 | try { 74 | createFolders(targetFolder, true); 75 | const commands: string[] = []; 76 | 77 | // Combine all Git commands in a single line 78 | commands.push( 79 | `git init ${targetFolder}`, 80 | `git add ${join(targetFolder, ".")} --force`, 81 | `git -C ${targetFolder} commit -m "${commitMessage}"`, 82 | ); 83 | 84 | // Execute the command 85 | execSync(commands.join(" && "), { 86 | stdio: "ignore", // hide output 87 | // stdio: "inherit", // show output 88 | }); 89 | 90 | return true; 91 | } catch (error) { 92 | console.error( 93 | "[initGitRepo]", 94 | "Unable to execute 'git init'", 95 | // error.message, 96 | ); 97 | return false; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/lib/inspect/account.ts: -------------------------------------------------------------------------------- 1 | import ora from "ora"; 2 | import CliTable3 from "cli-table3"; 3 | import { InspectorBaseArgs } from "@/types/inspect"; 4 | import { 5 | getExplorerLink, 6 | lamportsToSol, 7 | type AccountInfoBase, 8 | type AccountInfoWithBase64EncodedData, 9 | type Address, 10 | type SolanaRpcResponse, 11 | } from "gill"; 12 | 13 | type GetAccountInfoApiResponse = (AccountInfoBase & T) | null; 14 | 15 | type BuildTableInput = { 16 | account: SolanaRpcResponse< 17 | GetAccountInfoApiResponse 18 | >; 19 | }; 20 | 21 | export async function inspectAddress({ 22 | rpc, 23 | cluster, 24 | address, 25 | commitment = "confirmed", 26 | }: InspectorBaseArgs & { address: Address }) { 27 | const spinner = ora("Fetching account").start(); 28 | try { 29 | const explorerUrl = getExplorerLink({ 30 | cluster, 31 | address, 32 | }); 33 | 34 | const account = await rpc 35 | .getAccountInfo(address, { 36 | commitment, 37 | // base58 is the default, but also deprecated and causes errors 38 | // for large 'data' values (like with program accounts) 39 | encoding: "base64", 40 | }) 41 | .send(); 42 | 43 | if (!account) { 44 | throw "Account not found. Try the Solana Explorer:\n" + explorerUrl; 45 | } 46 | 47 | const overviewTable = buildAccountOverview({ account }); 48 | 49 | // we must the spinner before logging any thing or else the spinner will be displayed as frozen 50 | spinner.stop(); 51 | 52 | console.log(overviewTable.toString()); 53 | 54 | console.log("Open on Solana Explorer:"); 55 | console.log(explorerUrl); 56 | } finally { 57 | spinner.stop(); 58 | } 59 | } 60 | 61 | function buildAccountOverview({ 62 | account: { value: account }, 63 | }: BuildTableInput): CliTable3.Table { 64 | if (!account) throw new Error("An account is required"); 65 | 66 | const table = new CliTable3({ 67 | head: ["Account Overview"], 68 | style: { 69 | head: ["green"], 70 | }, 71 | }); 72 | 73 | table.push(["Owner", account.owner]); 74 | table.push(["Executable", account.executable]); 75 | table.push([ 76 | "Balance (lamports)", 77 | new Intl.NumberFormat().format(account.lamports), 78 | ]); 79 | table.push(["Balance (sol)", lamportsToSol(account.lamports)]); 80 | table.push([ 81 | "Space", 82 | new Intl.NumberFormat().format(account.space) + " bytes", 83 | ]); 84 | 85 | return table; 86 | } 87 | -------------------------------------------------------------------------------- /src/lib/inspect/block.ts: -------------------------------------------------------------------------------- 1 | import ora from "ora"; 2 | import CliTable3 from "cli-table3"; 3 | import type { InspectorBaseArgs } from "@/types/inspect"; 4 | import { getExplorerLink, type Address, type GetBlockApi } from "gill"; 5 | import { COMPUTE_BUDGET_PROGRAM_ADDRESS } from "gill/programs"; 6 | import { unixTimestampToDate, VOTE_PROGRAM_ID } from "@/lib/web3"; 7 | import { numberStringToNumber, timeAgo } from "@/lib/utils"; 8 | 9 | export async function inspectBlock({ 10 | rpc, 11 | cluster, 12 | block: blockNumber, 13 | commitment = "confirmed", 14 | }: InspectorBaseArgs & { block: number | bigint | string }) { 15 | const spinner = ora("Fetching block, this could take a few moments").start(); 16 | 17 | try { 18 | if (typeof blockNumber == "string") { 19 | try { 20 | // accept locale based numbers (1,222 for US and 1.222 for EU, etc) 21 | blockNumber = parseInt(numberStringToNumber(blockNumber).toString()); 22 | } catch (err) { 23 | throw "Invalid block number provided"; 24 | } 25 | } 26 | 27 | if (typeof blockNumber != "bigint" && typeof blockNumber != "number") { 28 | throw "Provided block number is not an actual number"; 29 | } 30 | 31 | const explorerUrl = getExplorerLink({ 32 | cluster, 33 | block: blockNumber.toString(), 34 | }); 35 | 36 | const [block, leaders] = await Promise.all([ 37 | await rpc 38 | .getBlock(BigInt(blockNumber), { 39 | commitment, 40 | maxSupportedTransactionVersion: 0, 41 | rewards: true, 42 | }) 43 | .send(), 44 | rpc.getSlotLeaders(BigInt(blockNumber), 1).send(), 45 | ]); 46 | 47 | if (!block) { 48 | throw "Block not found. Try the Solana Explorer:\n" + explorerUrl; 49 | } 50 | 51 | const overviewTable = buildBlockOverview({ block, leader: leaders[0] }); 52 | 53 | // we must the spinner before logging any thing or else the spinner will be displayed as frozen 54 | spinner.stop(); 55 | 56 | console.log(overviewTable.toString()); 57 | 58 | console.log("Open on Solana Explorer:"); 59 | console.log(explorerUrl); 60 | } finally { 61 | spinner.stop(); 62 | } 63 | } 64 | 65 | function buildBlockOverview({ 66 | block, 67 | leader, 68 | }: { 69 | block: ReturnType; 70 | leader: Address; 71 | }): CliTable3.Table { 72 | if (!block) throw new Error("A block is required"); 73 | 74 | const table = new CliTable3({ 75 | head: ["Block Overview"], 76 | style: { 77 | head: ["green"], 78 | }, 79 | }); 80 | 81 | const blockTime = unixTimestampToDate(block.blockTime); 82 | 83 | const successfulTxs = block.transactions.filter( 84 | (tx) => tx.meta?.err === null, 85 | ); 86 | const voteTxs = block.transactions.filter((tx) => 87 | tx.transaction.message.accountKeys.find( 88 | (account) => account == VOTE_PROGRAM_ID, 89 | ), 90 | ); 91 | const failedTxCount = block.transactions.length - successfulTxs.length; 92 | const nonVoteTxCount = block.transactions.length - voteTxs.length; 93 | const computeBudgetTxs = block.transactions.filter((tx) => 94 | tx.transaction.message.accountKeys.find( 95 | (account) => account == COMPUTE_BUDGET_PROGRAM_ADDRESS, 96 | ), 97 | ); 98 | 99 | table.push([ 100 | "Timestamp", 101 | blockTime.toLocaleDateString(undefined, { 102 | dateStyle: "medium", 103 | }) + 104 | " " + 105 | blockTime.toLocaleTimeString(undefined, { 106 | timeZoneName: "short", 107 | }) + 108 | `\n(${timeAgo(blockTime)})`, 109 | ]); 110 | table.push(["Leader", leader]); 111 | table.push(["Blockhash", block.blockhash]); 112 | table.push(["Previous blockhash", block.previousBlockhash]); 113 | table.push([ 114 | "Block height", 115 | new Intl.NumberFormat().format(block.blockHeight), 116 | ]); 117 | table.push(["Parent slot", new Intl.NumberFormat().format(block.parentSlot)]); 118 | table.push([ 119 | "Total transactions", 120 | new Intl.NumberFormat().format(block.transactions.length), 121 | ]); 122 | table.push([ 123 | "Successful transactions", 124 | new Intl.NumberFormat().format(successfulTxs.length) + 125 | ` (${new Intl.NumberFormat(undefined, { 126 | style: "percent", 127 | }).format(successfulTxs.length / block.transactions.length)})`, 128 | ]); 129 | table.push([ 130 | "Failed transactions", 131 | new Intl.NumberFormat().format(failedTxCount) + 132 | ` (${new Intl.NumberFormat(undefined, { 133 | style: "percent", 134 | }).format(failedTxCount / block.transactions.length)})`, 135 | ]); 136 | table.push([ 137 | "Vote transactions", 138 | new Intl.NumberFormat().format(voteTxs.length) + 139 | ` (${new Intl.NumberFormat(undefined, { 140 | style: "percent", 141 | }).format(voteTxs.length / block.transactions.length)})`, 142 | ]); 143 | table.push([ 144 | "Non-vote transactions", 145 | new Intl.NumberFormat().format(nonVoteTxCount) + 146 | ` (${new Intl.NumberFormat(undefined, { 147 | style: "percent", 148 | }).format(nonVoteTxCount / block.transactions.length)})`, 149 | ]); 150 | table.push([ 151 | "Compute budget transactions", 152 | new Intl.NumberFormat().format(computeBudgetTxs.length) + 153 | ` (${new Intl.NumberFormat(undefined, { 154 | style: "percent", 155 | }).format(computeBudgetTxs.length / block.transactions.length)})`, 156 | ]); 157 | 158 | return table; 159 | } 160 | -------------------------------------------------------------------------------- /src/lib/inspect/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./transaction"; 2 | export * from "./account"; 3 | -------------------------------------------------------------------------------- /src/lib/logger.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as path from "path"; 3 | import * as os from "os"; 4 | import { getAppInfo } from "./app-info"; 5 | import picocolors from "picocolors"; 6 | 7 | class ErrorLogger { 8 | private logFilePath: string | null = null; 9 | private logs: string[] = []; 10 | private appName: string; 11 | private version: string; 12 | private timestamp: string; 13 | 14 | constructor(appName?: string) { 15 | const info = getAppInfo(); 16 | this.appName = appName || info.name; 17 | this.version = info.version; 18 | this.timestamp = new Date().toISOString(); 19 | } 20 | 21 | /** 22 | * Log the args used on the command 23 | */ 24 | public logArgs(): void { 25 | // const formattedMessage = `Args: ${process.argv.join(" ")}`; 26 | // this.logs.push(formattedMessage); 27 | } 28 | 29 | /** 30 | * Log an informational message 31 | */ 32 | public info(message: string): void { 33 | const formattedMessage = `[INFO] ${new Date().toISOString()}: ${message}`; 34 | this.logs.push(formattedMessage); 35 | } 36 | 37 | /** 38 | * Log a warning message 39 | */ 40 | public warn(message: string): void { 41 | const formattedMessage = `[WARN] ${new Date().toISOString()}: ${message}`; 42 | this.logs.push(formattedMessage); 43 | } 44 | 45 | /** 46 | * Log an error message and create log file if it doesn't exist yet 47 | */ 48 | public error(message: string, error?: Error | string): void { 49 | let formattedMessage = `[ERROR] ${new Date().toISOString()}: ${message}`; 50 | 51 | if (error) { 52 | if (typeof error == "string") { 53 | formattedMessage += `\nMessage: ${error}`; 54 | } else if (error instanceof Error) { 55 | formattedMessage += `\nMessage: ${error.message}`; 56 | if (error.stack) { 57 | formattedMessage += `\nStack trace: ${error.stack}`; 58 | } 59 | } 60 | } 61 | 62 | this.logs.push(formattedMessage); 63 | 64 | // Create the log file now that we have an error 65 | this.ensureLogFile(); 66 | 67 | // Write all accumulated logs to the file 68 | this.flushLogsToFile(); 69 | } 70 | 71 | /** 72 | * Get the path to the log file 73 | * Returns null if no errors have been logged yet 74 | */ 75 | public getLogFilePath(): string | null { 76 | return this.logFilePath; 77 | } 78 | 79 | /** 80 | * Print error notification to console 81 | * Only prints if an error has been logged 82 | */ 83 | public printErrorNotification(): void { 84 | if (this.logFilePath) { 85 | console.error( 86 | picocolors.red( 87 | `\nAn error occurred during execution.\n` + 88 | `See the full log at: ${this.logFilePath}`, 89 | ), 90 | ); 91 | } 92 | } 93 | 94 | /** 95 | * Exit the logger and print the error notification if needed 96 | */ 97 | public exit(code?: number | string | null | undefined): void { 98 | this.flushLogsToFile(); 99 | this.printErrorNotification(); 100 | process.exit(code); 101 | } 102 | 103 | /** 104 | * Get all logged messages 105 | */ 106 | public getLogs(): string[] { 107 | return [...this.logs]; 108 | } 109 | 110 | /** 111 | * Create the log file if it doesn't exist yet 112 | */ 113 | public ensureLogFile(): void { 114 | if (!this.logFilePath) { 115 | // Create a unique filename with timestamp 116 | const filename = `${this.appName}-error-${this.timestamp.replace( 117 | /:/g, 118 | "-", 119 | )}.log`; 120 | 121 | this.logFilePath = path.join(os.tmpdir(), filename); 122 | 123 | // Initialize the log file with header 124 | const header = 125 | [ 126 | `# Error Log for ${this.appName}`, 127 | `Version: ${this.version}`, 128 | `Timestamp: ${this.timestamp}`, 129 | `Command: ${process.argv.slice(0, 2).join(" ")}`, 130 | `Arguments: ${ 131 | process.argv.length > 2 ? process.argv.slice(2).join(" ") : "" 132 | }`, 133 | `----------`, 134 | ].join("\n") + "\n"; 135 | 136 | fs.writeFileSync(this.logFilePath, header, { encoding: "utf-8" }); 137 | } 138 | } 139 | 140 | /** 141 | * Write all accumulated logs to the file 142 | */ 143 | private flushLogsToFile(): void { 144 | if (!this.logFilePath) return; 145 | 146 | try { 147 | const content = this.logs.join("\n") + "\n"; 148 | fs.appendFileSync(this.logFilePath, content, { encoding: "utf-8" }); 149 | 150 | // Clear the logs array after flushing 151 | this.logs = []; 152 | } catch (err) { 153 | console.error("Failed to write to log file:", err); 154 | } 155 | } 156 | } 157 | 158 | export const logger = new ErrorLogger(getAppInfo().name); 159 | -------------------------------------------------------------------------------- /src/lib/logs.ts: -------------------------------------------------------------------------------- 1 | import picocolors from "picocolors"; 2 | import type { Formatter } from "picocolors/types"; 3 | import { logger } from "./logger"; 4 | 5 | /** 6 | * Print a plain message using `picocolors` 7 | * (including a process exit code) 8 | */ 9 | export function titleMessage( 10 | msg: string, 11 | colorFn: Formatter = picocolors.inverse, 12 | ) { 13 | console.log(colorFn(` ${msg} `)); 14 | } 15 | 16 | /** 17 | * Display a yellow warning message without exiting the cli 18 | */ 19 | export function warnMessage(msg: string) { 20 | console.log(picocolors.yellow(msg)); 21 | } 22 | 23 | /** 24 | * Show a cancel outro and exit the cli process 25 | */ 26 | export function warningOutro(msg: string = "Operation canceled") { 27 | warnMessage(msg); 28 | logger.exit(); 29 | } 30 | 31 | /** 32 | * Print a plain message using `picocolors` 33 | * (including a process exit code) 34 | */ 35 | export function cancelOutro(msg: string = "Operation canceled") { 36 | console.log(picocolors.inverse(` ${msg} `), "\n"); 37 | // outro(picocolors.inverse(` ${msg} `)); 38 | logger.exit(0); 39 | } 40 | 41 | /** 42 | * Print a blue notice message using `picocolors` 43 | * (including a process exit code) 44 | */ 45 | export function noticeOutro(msg: string) { 46 | // outro(picocolors.bgBlue(` ${msg} `)); 47 | console.log(picocolors.bgBlue(` ${msg} `), "\n"); 48 | logger.exit(0); 49 | } 50 | 51 | /** 52 | * Print a green success message using `picocolors` 53 | * (including a process exit code) 54 | */ 55 | export function successOutro(msg: string = "Operation successful") { 56 | console.log(picocolors.bgGreen(` ${msg} `), "\n"); 57 | // outro(picocolors.bgGreen(` ${msg} `)); 58 | logger.exit(0); 59 | } 60 | 61 | /** 62 | * Print a red error message using `picocolors` 63 | * (including a process exit code) 64 | */ 65 | export function errorOutro( 66 | msg: string, 67 | title: string = "An error occurred", 68 | extraLog?: any, 69 | ) { 70 | errorMessage(msg, title, extraLog); 71 | logger.exit(1); 72 | } 73 | 74 | /** 75 | * Display a error message with using `picocolors` 76 | * (including a process exit code) 77 | */ 78 | export function errorMessage( 79 | err: any, 80 | title: string | null = null, 81 | extraLog?: any, 82 | ) { 83 | let message = "Unknown error"; 84 | 85 | if (typeof err == "string") { 86 | message = err; 87 | } else if (err instanceof Error) { 88 | message = err.message; 89 | } 90 | 91 | if (title) { 92 | console.log(picocolors.bgRed(` ${title} `)); 93 | console.log(message); 94 | } else console.log(picocolors.bgRed(` ${message} `)); 95 | if (extraLog) console.log(extraLog); 96 | } 97 | -------------------------------------------------------------------------------- /src/lib/node.ts: -------------------------------------------------------------------------------- 1 | const MIN_NODE_VERSION = "22.0.0"; 2 | // const MIN_BUN_VERSION = "0.4.0"; 3 | 4 | /** 5 | * Compare two version to see if the `requiredVersion` is newer than the `currentVersion` 6 | */ 7 | export function isVersionNewer( 8 | currentVersion: string, 9 | requiredVersion: string, 10 | ) { 11 | if (currentVersion == requiredVersion) return false; 12 | const [major, minor, patch] = currentVersion.split(".").map(Number); 13 | const [reqMajor, reqMinor, reqPatch] = requiredVersion.split(".").map(Number); 14 | return ( 15 | major > reqMajor || 16 | (major === reqMajor && minor >= reqMinor) || 17 | (major === reqMajor && minor == reqMinor && patch >= reqPatch) 18 | ); 19 | } 20 | 21 | /** 22 | * Used to assert that the javascript runtime version of the user is above 23 | * the minimum version needed to actually execute the cli scripts 24 | */ 25 | export function assertRuntimeVersion() { 26 | // @ts-ignore 27 | const isBun = typeof Bun !== "undefined"; 28 | if (isBun) { 29 | // todo: we may need to actually check other javascript runtime versions 30 | // // @ts-ignore 31 | // if (!isVersionNewer(Bun.version, MIN_BUN_VERSION)) { 32 | // console.error( 33 | // `This tool requires Bun v${MIN_BUN_VERSION} or higher.`, 34 | // // @ts-ignore 35 | // `You are using v${Bun.version}.`, 36 | // ); 37 | // process.exit(1); 38 | // } 39 | } else { 40 | if (!isVersionNewer(process.versions.node, MIN_NODE_VERSION)) { 41 | console.error( 42 | `This tool requires Node.js v${MIN_NODE_VERSION} or higher.`, 43 | `You are using v${process.versions.node}.`, 44 | ); 45 | process.exit(1); 46 | } 47 | } 48 | } 49 | 50 | /** 51 | * Suppress various messages that get logged by NodeJS and other runtimes 52 | */ 53 | export function suppressRuntimeWarnings() { 54 | const FILTERED_WARNINGS = ["Ed25519", "Warning: WebSockets", "punycode"]; 55 | 56 | process.removeAllListeners("warning"); 57 | process.on("warning", (warning) => { 58 | // Only log warnings that shouldn't be suppressed 59 | if (!FILTERED_WARNINGS.some((filter) => warning.message.includes(filter))) { 60 | console.warn(warning); 61 | } 62 | }); 63 | } 64 | 65 | export function patchBigint() { 66 | // @ts-ignore 67 | interface BigInt { 68 | /** Convert a BigInt to string form when calling `JSON.stringify()` */ 69 | toJSON: () => string; 70 | } 71 | 72 | // @ts-ignore - Only add the toJSON method if it doesn't already exist 73 | if (BigInt.prototype.toJSON === undefined) { 74 | // @ts-ignore - toJSON does not exist which is why we are patching it 75 | BigInt.prototype.toJSON = function () { 76 | return String(this); 77 | }; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/lib/npm.ts: -------------------------------------------------------------------------------- 1 | import { getAppInfo } from "@/lib/app-info"; 2 | import { isVersionNewer } from "./node"; 3 | import { titleMessage } from "./logs"; 4 | import picocolors from "picocolors"; 5 | import { getCommandOutput, VERSION_REGEX } from "./shell"; 6 | 7 | type NpmRegistryResponse = { 8 | "dist-tags": { 9 | latest: string; 10 | }; 11 | versions: { 12 | [version: string]: { 13 | version: string; 14 | description?: string; 15 | }; 16 | }; 17 | }; 18 | 19 | /** 20 | * Get the installed package version using the `npm list` command 21 | */ 22 | export async function getCurrentNpmPackageVersion( 23 | packageName: string, 24 | global: boolean = false, 25 | ): Promise { 26 | // always check the global package for mucho to avoid issues when testing 27 | if (packageName == "mucho") global = true; 28 | 29 | return await getCommandOutput( 30 | `npm list ${global ? "-g " : ""}${packageName}`, 31 | ).then((res) => { 32 | if (!res) return res; 33 | return ( 34 | res 35 | .match(new RegExp(`${packageName}@(.*)`, "gi"))?.[0] 36 | .match(VERSION_REGEX)?.[1] || false 37 | ); 38 | }); 39 | } 40 | 41 | /** 42 | * Poll the npm registry to fetch the latest version of any package name 43 | */ 44 | export async function getNpmRegistryPackageVersion( 45 | packageName: string, 46 | ): Promise< 47 | | { 48 | latest: string; 49 | allVersions: string[]; 50 | error?: never; 51 | } 52 | | { 53 | latest?: never; 54 | allVersions?: never; 55 | error: string; 56 | } 57 | > { 58 | try { 59 | if (!packageName || typeof packageName !== "string") { 60 | throw new Error("Invalid package name"); 61 | } 62 | 63 | const response = await fetch( 64 | `https://registry.npmjs.org/${encodeURIComponent(packageName)}`, 65 | { 66 | headers: { 67 | "Cache-Control": "no-cache, no-store, must-revalidate", 68 | Pragma: "no-cache", 69 | Expires: "0", 70 | }, 71 | }, 72 | ); 73 | 74 | if (!response.ok) { 75 | if (response.status === 404) { 76 | throw new Error(`Package "${packageName}" not found`); 77 | } 78 | throw new Error(`HTTP error! status: ${response.status}`); 79 | } 80 | 81 | const data = (await response.json()) as NpmRegistryResponse; 82 | 83 | const allVersions = Object.keys(data.versions).sort((a, b) => { 84 | // Simple semver comparison for sorting 85 | const aParts = a.split(".").map(Number); 86 | const bParts = b.split(".").map(Number); 87 | 88 | for (let i = 0; i < 3; i++) { 89 | if (aParts[i] !== bParts[i]) { 90 | return aParts[i] - bParts[i]; 91 | } 92 | } 93 | return 0; 94 | }); 95 | 96 | return { 97 | latest: data["dist-tags"].latest, 98 | allVersions, 99 | }; 100 | } catch (error) { 101 | return { 102 | error: error instanceof Error ? error.message : "Unknown error occurred", 103 | }; 104 | } 105 | } 106 | 107 | /** 108 | * 109 | */ 110 | export async function checkForSelfUpdate() { 111 | const registry = await getNpmRegistryPackageVersion(getAppInfo().name); 112 | const current = await getCurrentNpmPackageVersion(getAppInfo().name, true); 113 | 114 | if ("error" in registry) { 115 | // console.error(`Unable to perform the mucho self update checks`); 116 | // console.error("Error:", npmVersion.error); 117 | return; 118 | } 119 | 120 | // do nothing if on the same version 121 | if (registry.latest == current) { 122 | return; 123 | } 124 | 125 | // if not installed globally, we will prompt to install 126 | if (!current || isVersionNewer(registry.latest, current)) { 127 | titleMessage( 128 | `${getAppInfo().name} update available - v${registry.latest}`, 129 | (val) => picocolors.inverse(picocolors.green(val)), 130 | ); 131 | // console.log(`A new version of ${getAppInfo().name} is available!`); 132 | console.log( 133 | " ", 134 | `To install the latest version, run the following command:`, 135 | ); 136 | console.log(" ", picocolors.green(`npx mucho@latest self-update`)); 137 | 138 | console.log(); // print a spacer 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/lib/programs.ts: -------------------------------------------------------------------------------- 1 | import { dirname, join, resolve } from "path"; 2 | import { 3 | ProgramsByClusterLabels, 4 | SolanaTomlCloneLocalProgram, 5 | } from "@/types/config"; 6 | import { directoryExists, loadFileNamesToMap } from "@/lib//utils"; 7 | import { DEFAULT_BUILD_DIR } from "@/const/solana"; 8 | import { warnMessage } from "@/lib/logs"; 9 | 10 | /** 11 | * List all the local program binaries in the specified build directory 12 | */ 13 | export function listLocalPrograms({ 14 | labels = {}, 15 | buildDir = DEFAULT_BUILD_DIR, 16 | basePath, 17 | configPath, 18 | cluster = "localnet", 19 | }: { 20 | buildDir?: string; 21 | basePath?: string; 22 | labels?: ProgramsByClusterLabels; 23 | configPath?: string; 24 | cluster?: keyof ProgramsByClusterLabels; 25 | } = {}): { 26 | locatedPrograms: SolanaTomlCloneLocalProgram; 27 | buildDirListing: Map; 28 | allFound: boolean; 29 | } { 30 | let allFound: boolean = false; 31 | let locatedPrograms: SolanaTomlCloneLocalProgram = {}; 32 | let buildDirListing: Map = new Map(); 33 | 34 | if (basePath) { 35 | buildDir = resolve(join(basePath, buildDir)); 36 | } else if (configPath) { 37 | buildDir = resolve(join(dirname(configPath), buildDir)); 38 | } else { 39 | buildDir = resolve(join(process.cwd(), buildDir)); 40 | } 41 | 42 | if (!directoryExists(buildDir)) { 43 | // warnMessage(`Unable to locate build output directory: ${buildDir}`); 44 | return { locatedPrograms, buildDirListing, allFound }; 45 | } 46 | 47 | buildDirListing = loadFileNamesToMap(buildDir, ".so"); 48 | 49 | if (!Object.prototype.hasOwnProperty.call(labels, cluster)) { 50 | // warnMessage(`Unable to locate 'programs.${cluster}'`); 51 | return { locatedPrograms, buildDirListing, allFound }; 52 | } 53 | 54 | let missingCounter = 0; 55 | 56 | buildDirListing.forEach((binaryName, programName) => { 57 | if ( 58 | Object.prototype.hasOwnProperty.call(labels[cluster], programName) && 59 | !Object.hasOwn(locatedPrograms, programName) 60 | ) { 61 | locatedPrograms[programName] = { 62 | address: labels[cluster][programName], 63 | filePath: join(buildDir, binaryName), 64 | }; 65 | } else { 66 | missingCounter++; 67 | 68 | if (!Object.prototype.hasOwnProperty.call(labels[cluster], programName)) { 69 | warnMessage( 70 | `Compiled program '${programName}' was found with no config info`, 71 | ); 72 | } else { 73 | warnMessage( 74 | `Unable to locate compiled program '${programName}' from config`, 75 | ); 76 | } 77 | } 78 | }); 79 | 80 | if (missingCounter == 0) allFound = true; 81 | 82 | return { locatedPrograms, buildDirListing, allFound }; 83 | } 84 | -------------------------------------------------------------------------------- /src/lib/prompts/build.ts: -------------------------------------------------------------------------------- 1 | import { select } from "@inquirer/prompts"; 2 | import { SolanaClusterMoniker } from "gill"; 3 | import { logger } from "../logger"; 4 | 5 | export async function promptToSelectCluster( 6 | message: string = "Select a cluster?", 7 | defaultValue: SolanaClusterMoniker = "mainnet", 8 | ): Promise { 9 | return select({ 10 | message, 11 | theme: { 12 | // style: { 13 | // todo: we could customize the message here? 14 | // error: (text) => text, 15 | // }, 16 | }, 17 | default: defaultValue, 18 | choices: [ 19 | { 20 | short: "m", 21 | name: "m) mainnet", 22 | value: "mainnet", 23 | }, 24 | { 25 | short: "d", 26 | name: "d) devnet", 27 | value: "devnet", 28 | }, 29 | { 30 | short: "t", 31 | name: "t) testnet", 32 | value: "testnet", 33 | }, 34 | { 35 | short: "l", 36 | name: "l) localnet", 37 | value: "localnet", 38 | // description: defaultValue.startsWith("l") 39 | // ? "Default value selected" 40 | // : "", 41 | }, 42 | ], 43 | }) 44 | .then(async (answer) => { 45 | // if (!answer) return false; 46 | 47 | if (answer.startsWith("m")) answer = "mainnet"; 48 | 49 | return answer; 50 | }) 51 | .catch(() => { 52 | /** 53 | * it seems that if we execute the run clone command within another command 54 | * (like nesting it under `mucho validator` and prompting the user) 55 | * the user may not be able to exit the `mucho validator` process via Ctrl+c 56 | * todo: investigate this more to see if we can still allow the command to continue 57 | */ 58 | // do nothing on user cancel instead of exiting the cli 59 | console.log("Operation canceled."); 60 | logger.exit(); 61 | // todo: support selecting a default value here? 62 | return defaultValue; 63 | }); 64 | } 65 | -------------------------------------------------------------------------------- /src/lib/prompts/clone.ts: -------------------------------------------------------------------------------- 1 | import { select } from "@inquirer/prompts"; 2 | import { cloneCommand } from "@/commands/clone"; 3 | import { logger } from "../logger"; 4 | 5 | export async function promptToAutoClone(): Promise { 6 | console.log(); // print a line separator 7 | return select({ 8 | message: "Would you like to perform the 'clone' command now?", 9 | default: "y", 10 | choices: [ 11 | { 12 | name: "(y) Yes", 13 | short: "y", 14 | value: true, 15 | description: `Yes, clone all the accounts and programs in Solana.toml`, 16 | }, 17 | { 18 | name: "(n) No", 19 | short: "n", 20 | value: false, 21 | description: "Do not run the 'clone' command now", 22 | }, 23 | ], 24 | }) 25 | .then(async (answer) => { 26 | if (answer !== true) return false; 27 | 28 | // run the clone command with default options 29 | // todo: could we pass options in here if we want? 30 | await cloneCommand().parseAsync([]); 31 | }) 32 | .catch(() => { 33 | /** 34 | * it seems that if we execute the run clone command within another command 35 | * (like nesting it under `mucho validator` and prompting the user) 36 | * the user may not be able to exit the `mucho validator` process via Ctrl+c 37 | * todo: investigate this more to see if we can still allow the command to continue 38 | */ 39 | // do nothing on user cancel instead of exiting the cli 40 | console.log("Operation canceled."); 41 | return logger.exit(); 42 | }); 43 | } 44 | -------------------------------------------------------------------------------- /src/lib/prompts/git.ts: -------------------------------------------------------------------------------- 1 | import { select } from "@inquirer/prompts"; 2 | import { initGitRepo, isGitRepo } from "@/lib/git"; 3 | import { warningOutro } from "@/lib/logs"; 4 | 5 | export async function promptToInitGitRepo( 6 | defaultGitDir: string, 7 | exitOnFailToCreate: boolean = true, 8 | ): Promise { 9 | return select({ 10 | message: "Would you like to initialize a git repo?", 11 | default: "y", 12 | choices: [ 13 | { 14 | name: "(y) Yes", 15 | short: "y", 16 | value: true, 17 | description: `Default repo directory: ${defaultGitDir}`, 18 | }, 19 | // todo: support the user manually defining the repo root, including from the cli args 20 | // { 21 | // name: "(m) Manually define the repo root", 22 | // short: "m", 23 | // value: "m", 24 | // description: "", 25 | // }, 26 | { 27 | name: "(n) No", 28 | short: "n", 29 | value: false, 30 | description: "Skip it", 31 | }, 32 | ], 33 | }) 34 | .then((answer) => { 35 | if (answer == true) { 36 | initGitRepo(defaultGitDir); 37 | if (!isGitRepo(defaultGitDir) && exitOnFailToCreate) { 38 | warningOutro( 39 | `Unable to initialize a new git repo at: ${defaultGitDir}`, 40 | ); 41 | } 42 | } 43 | 44 | return true; 45 | }) 46 | .catch((_err) => { 47 | // do nothing on user cancel 48 | return false; 49 | }); 50 | } 51 | -------------------------------------------------------------------------------- /src/lib/prompts/install.ts: -------------------------------------------------------------------------------- 1 | import { select } from "@inquirer/prompts"; 2 | import { ToolNames } from "@/types"; 3 | import { logger } from "../logger"; 4 | 5 | export async function promptToInstall( 6 | toolName: ToolNames, 7 | ): Promise { 8 | return select({ 9 | message: `Would you like to install '${toolName}' now?`, 10 | default: "y", 11 | choices: [ 12 | { 13 | name: "(y) Yes", 14 | short: "y", 15 | value: true, 16 | }, 17 | { 18 | name: "(n) No", 19 | short: "n", 20 | value: false, 21 | }, 22 | ], 23 | }) 24 | .then(async (answer) => { 25 | if (answer !== true) return false; 26 | return true; 27 | }) 28 | .catch(() => { 29 | /** 30 | * it seems that if we execute a Commander command within another command 31 | * (like nesting it under `mucho validator` and prompting the user) 32 | * the user may not be able to exit the parent command's process via CTRL+C 33 | * todo: investigate this more to see if we can still allow the command to continue 34 | */ 35 | // do nothing on user cancel instead of exiting the cli 36 | console.log("Operation canceled."); 37 | return logger.exit(); 38 | }); 39 | } 40 | -------------------------------------------------------------------------------- /src/lib/setup.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Helpers for setting up and managing a user's local environment 3 | */ 4 | 5 | import { ToolNames } from "@/types"; 6 | import { installedToolVersion } from "./shell"; 7 | import picocolors from "picocolors"; 8 | import shellExec from "shell-exec"; 9 | import { PathSourceStatus } from "@/const/setup"; 10 | 11 | /** 12 | * Check for each of the installed tools on the user system 13 | */ 14 | export async function checkInstalledTools({ 15 | outputToolStatus = false, 16 | }: { 17 | outputToolStatus?: boolean; 18 | } = {}) { 19 | // default to true since we set to false if any single tool is not installed 20 | let allInstalled = true; 21 | 22 | let status: { [key in ToolNames]: string | boolean } = { 23 | rustup: false, 24 | rust: false, 25 | solana: false, 26 | avm: false, 27 | anchor: false, 28 | node: false, 29 | yarn: false, 30 | trident: false, 31 | zest: false, 32 | "cargo-update": false, 33 | verify: false, 34 | }; 35 | 36 | await Promise.all( 37 | Object.keys(status).map(async (tool: ToolNames) => { 38 | const version = await installedToolVersion(tool); 39 | status[tool] = version; 40 | if (!status[tool]) allInstalled = false; 41 | }), 42 | ); 43 | 44 | if (outputToolStatus) { 45 | let noteContent = ""; 46 | for (const command in status) { 47 | if (Object.prototype.hasOwnProperty.call(status, command)) { 48 | noteContent += "- "; 49 | if (status[command]) { 50 | noteContent += picocolors.green(command); 51 | } else { 52 | noteContent += picocolors.red(command); 53 | } 54 | noteContent += ` ${status[command] || "(not installed)"}\n`; 55 | } 56 | } 57 | console.log(noteContent.trim(), "\n"); 58 | } 59 | 60 | return { 61 | allInstalled, 62 | status, 63 | }; 64 | } 65 | 66 | /** 67 | * Check if a given command is available in the current terminal session or required a refresh to update the PATH 68 | */ 69 | export async function checkShellPathSource( 70 | cmd: string, 71 | pathSource: string, 72 | ): Promise { 73 | const [withoutPath, withPath] = await Promise.allSettled([ 74 | // comment for better diffs 75 | shellExec(cmd), 76 | shellExec(`export PATH="${pathSource}:$PATH" && ${cmd}`), 77 | ]); 78 | // console.log(withoutPath); 79 | // console.log(withPath); 80 | 81 | if (withoutPath.status == "fulfilled" && withPath.status == "fulfilled") { 82 | // the original command worked 83 | if (withoutPath.value.code === 0) { 84 | if ( 85 | withoutPath.value.stdout.toLowerCase() == 86 | withPath.value.stdout.toLowerCase() 87 | ) { 88 | // console.log("The command worked!"); 89 | return PathSourceStatus.SUCCESS; 90 | } 91 | 92 | // console.log("The values were different"); 93 | return PathSourceStatus.OUTPUT_MISMATCH; 94 | } 95 | // the command worked with the injected pathSource 96 | else if (withPath.value.code === 0) { 97 | // console.log("Missing the path"); 98 | return PathSourceStatus.MISSING_PATH; 99 | } 100 | // if (withoutPath.value.stdout.toLowerCase() == withPath.value.stdout.toLowerCase()) 101 | } 102 | 103 | return PathSourceStatus.FAILED; 104 | } 105 | -------------------------------------------------------------------------------- /src/lib/shell/build.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from "child_process"; 2 | 3 | type BuildProgramCommandInput = { 4 | verbose?: boolean; 5 | workspace?: boolean; 6 | manifestPath?: string; 7 | toolsVersion?: string; 8 | }; 9 | 10 | export function buildProgramCommand({ 11 | // verbose = false, 12 | manifestPath, 13 | workspace = false, 14 | toolsVersion, 15 | }: BuildProgramCommandInput) { 16 | const command: string[] = ["cargo build-sbf"]; 17 | 18 | if (manifestPath) { 19 | command.push(`--manifest-path ${manifestPath}`); 20 | } 21 | if (workspace) { 22 | command.push(`--workspace`); 23 | } 24 | if (toolsVersion) { 25 | if (!toolsVersion.startsWith("v")) toolsVersion = `v${toolsVersion}`; 26 | command.push(`--tools-version ${toolsVersion}`); 27 | } 28 | 29 | // todo: research features and how best to include them 30 | // --features ... 31 | // Space-separated list of features to activate 32 | 33 | // --sbf-sdk 34 | // Path to the Solana SBF SDK [env: SBF_SDK_PATH=] [default: stable dir] 35 | 36 | // --tools-version 37 | // platform-tools version to use or to install, a version string, e.g. "v1.32" 38 | 39 | return command.join(" "); 40 | } 41 | 42 | type RunBuildCommandInput = { 43 | programName: string; 44 | command: string; 45 | args?: string[]; 46 | }; 47 | 48 | export async function runBuildCommand({ 49 | // programName, 50 | command, 51 | args, 52 | }: RunBuildCommandInput): Promise { 53 | return new Promise((resolve, reject) => { 54 | let output = ""; 55 | let errorOutput = ""; 56 | 57 | // // Spawn a child process 58 | const child = spawn(command, args, { 59 | detached: false, // run the command in the same session 60 | stdio: "inherit", // Stream directly to the user's terminal 61 | shell: true, // Runs in shell for compatibility with shell commands 62 | // cwd: // todo: do we want this? 63 | }); 64 | 65 | child.stdout.on("data", (data) => { 66 | output += data.toString(); 67 | }); 68 | 69 | child.stderr.on("data", (data) => { 70 | errorOutput += data.toString(); 71 | }); 72 | 73 | child.on("exit", (code) => { 74 | if (code === 0) { 75 | resolve(true); 76 | } else { 77 | console.error(errorOutput); 78 | resolve(false); 79 | } 80 | }); 81 | 82 | process.on("error", (_error) => { 83 | reject(false); 84 | }); 85 | }); 86 | } 87 | -------------------------------------------------------------------------------- /src/lib/shell/deploy.ts: -------------------------------------------------------------------------------- 1 | import { SolanaCliClusterMonikers } from "@/types/config"; 2 | import { parseRpcUrlOrMoniker } from "@/lib/solana"; 3 | import shellExec from "shell-exec"; 4 | import { parseJson } from "@/lib/utils"; 5 | import { ProgramInfoStruct } from "@/types/solana"; 6 | 7 | type DeployProgramCommandInput = { 8 | programId: string; 9 | programPath: string; 10 | verbose?: boolean; 11 | workspace?: boolean; 12 | manifestPath?: string; 13 | upgradeAuthority?: string; 14 | keypair?: string; 15 | url?: SolanaCliClusterMonikers | string; 16 | priorityFee?: string; 17 | }; 18 | 19 | export function buildDeployProgramCommand({ 20 | programPath, 21 | programId, 22 | // verbose = false, 23 | // manifestPath, 24 | // workspace = false, 25 | url, 26 | keypair, 27 | upgradeAuthority, 28 | priorityFee, 29 | }: DeployProgramCommandInput) { 30 | const command: string[] = ["solana program deploy"]; 31 | 32 | // command.push(`--output json`); 33 | 34 | // note: when no url/cluster is specified, the user's `solana config` url will be used 35 | if (url) { 36 | command.push(`--url ${parseRpcUrlOrMoniker(url)}`); 37 | } 38 | 39 | if (keypair) { 40 | // todo: detect if the keypair exists? 41 | command.push(`--keypair ${keypair}`); 42 | } 43 | 44 | // when not set, defaults to the cli keypair 45 | if (upgradeAuthority) { 46 | // todo: detect if this file path exists? 47 | command.push(`--upgrade-authority ${upgradeAuthority}`); 48 | } 49 | 50 | // programId must be a file path for initial deployments 51 | if (programId) { 52 | // todo: detect if this file path exists? 53 | command.push(`--program-id ${programId}`); 54 | } 55 | 56 | // Add priority fee if specified 57 | if (priorityFee) { 58 | command.push(`--with-compute-unit-price ${priorityFee}`); 59 | } 60 | 61 | // todo: validate the `programPath` file exists 62 | command.push(programPath); 63 | 64 | return command.join(" "); 65 | } 66 | 67 | export async function getDeployedProgramInfo( 68 | programId: string, 69 | cluster: string, 70 | ): Promise { 71 | const command: string[] = [ 72 | "solana program show", 73 | "--output json", 74 | `--url ${parseRpcUrlOrMoniker(cluster)}`, 75 | programId, 76 | ]; 77 | 78 | let { stderr, stdout } = await shellExec(command.join(" ")); 79 | 80 | if (stderr) { 81 | // const error = stderr.trim().split("\n"); 82 | // let parsed: string | null = null; 83 | 84 | // console.log("error:"); 85 | // console.log(error); 86 | 87 | return false; 88 | } 89 | 90 | if (!stdout.trim()) return false; 91 | 92 | const programInfo = parseJson(stdout); 93 | if (programInfo.authority == "none") programInfo.authority = false; 94 | 95 | return programInfo; 96 | } 97 | -------------------------------------------------------------------------------- /src/lib/shell/index.ts: -------------------------------------------------------------------------------- 1 | import os from "node:os"; 2 | import { PlatformOS, ShellExecInSessionArgs, ToolNames } from "@/types"; 3 | import fs from "fs"; 4 | import path from "path"; 5 | import shellExec from "shell-exec"; 6 | import { TOOL_CONFIG } from "@/const/setup"; 7 | import { warnMessage } from "@/lib/logs"; 8 | import { type ChildProcess, execSync, spawn } from "node:child_process"; 9 | import { logger } from "../logger"; 10 | 11 | export const VERSION_REGEX = /(?:[\w-]+\s+)?(\d+\.\d+\.?\d+?)/; 12 | 13 | /** 14 | * Check if a given command name is installed and available on the system 15 | */ 16 | export async function installedToolVersion(name: ToolNames) { 17 | let command: string = ""; 18 | 19 | if (Object.prototype.hasOwnProperty.call(TOOL_CONFIG, name)) { 20 | command = TOOL_CONFIG[name].version; 21 | // auto source the command's binary dir into the $PATH 22 | if (TOOL_CONFIG[name].pathSource) { 23 | command = `export PATH="${TOOL_CONFIG[name].pathSource}:$PATH" && ${command}`; 24 | } 25 | } 26 | 27 | if (!command) return false; 28 | 29 | const res = await checkCommand(command); 30 | 31 | if (res) return VERSION_REGEX.exec(res)[1] || res; 32 | return res; 33 | } 34 | 35 | /** 36 | * Attempt to run the given shell command, detecting if the command is available on the system 37 | */ 38 | export async function checkCommand( 39 | cmd: string, 40 | errorOptions: { 41 | message?: string; 42 | exit?: boolean; 43 | onError?: Function; 44 | doubleCheck?: boolean; 45 | } = null, 46 | ): Promise { 47 | try { 48 | const { stdout } = await shellExec(cmd); 49 | 50 | // Remove the dots from the output 51 | const cleanedOutput = stdout.replace(/^\s*\.\s*$/gm, ""); 52 | 53 | if (cleanedOutput) { 54 | return cleanedOutput.trim(); 55 | } 56 | 57 | if (errorOptions) throw "Command not found"; 58 | return false; 59 | } catch (err) { 60 | if (!errorOptions) return false; 61 | // execute the onError function 62 | if (typeof errorOptions?.onError == "function") { 63 | // the onError function can attempt to fix the error of the command not executing 64 | // (like prompting the user to install a command) 65 | const res = await errorOptions.onError(); 66 | 67 | if (errorOptions.doubleCheck) { 68 | return await checkCommand(cmd, { 69 | exit: errorOptions.exit, 70 | }); 71 | } 72 | 73 | if (!res && errorOptions?.exit) logger.exit(1); 74 | } else { 75 | warnMessage(errorOptions.message || `Unable to execute command: ${cmd}`); 76 | if (errorOptions.exit) logger.exit(1); 77 | } 78 | } 79 | } 80 | 81 | /** 82 | * Detect the users operating system 83 | */ 84 | export function detectOperatingSystem(): PlatformOS { 85 | switch (os.platform()) { 86 | case "darwin": 87 | return "mac"; 88 | case "win32": 89 | return "windows"; 90 | case "linux": 91 | return "linux"; 92 | default: 93 | return "unknown"; 94 | } 95 | } 96 | 97 | /** 98 | * Add a new PATH value into the user's `.bashrc` or `.zshrc` files 99 | */ 100 | export function appendPathToRCFiles( 101 | newPath: string, 102 | name: string | undefined = undefined, 103 | ): void { 104 | // Get the home directory of the current user 105 | const homeDir = process.env.HOME || process.env.USERPROFILE; 106 | 107 | if (!homeDir) { 108 | console.error( 109 | "[appendPathToRCFiles]", 110 | "Unable to find the user home directory.", 111 | ); 112 | return; 113 | } 114 | 115 | // List of potential RC files to check 116 | const rcFiles = [".bashrc", ".zshrc"].map((file) => path.join(homeDir, file)); 117 | 118 | // The line to add to the RC file 119 | let exportLine = `export PATH=${newPath}:\$PATH\n`; 120 | 121 | // handle a better PATH flow to keep the PATH light and remove duplicates 122 | if (name) { 123 | const exportName = `${name.toUpperCase()}_HOME`; 124 | exportLine = 125 | `\n# ${name}\n` + 126 | `export ${exportName}="${newPath}"\n` + 127 | `case ":$PATH:" in\n` + 128 | ` *":$${exportName}:"*) ;;\n` + 129 | ` *) export PATH="$${exportName}:$PATH" ;;\n` + 130 | `esac\n` + 131 | `# ${name} end\n\n`; 132 | } 133 | 134 | rcFiles.forEach((rcFile) => { 135 | // Check if the RC file exists 136 | if (fs.existsSync(rcFile)) { 137 | // console.log(`Checking ${rcFile}...`); 138 | 139 | // Read the file contents 140 | const fileContent = fs.readFileSync(rcFile, "utf-8"); 141 | 142 | // Check if the line is already present in the file 143 | if (!fileContent.includes(newPath)) { 144 | // Append the new export path to the RC file 145 | fs.appendFileSync(rcFile, exportLine); 146 | // console.log(`Appended PATH to ${rcFile}`); 147 | } else { 148 | // console.log(`The path is already present in ${rcFile}`); 149 | } 150 | } else { 151 | // console.log(`${rcFile} not found.`); 152 | } 153 | }); 154 | } 155 | 156 | /** 157 | * Execute a command using the user's current shell session, 158 | * allowing the user to CTRL+C to cancel 159 | */ 160 | export function shellExecInSession({ 161 | command, 162 | args = undefined, 163 | outputOnly, 164 | }: ShellExecInSessionArgs): ChildProcess | void { 165 | if (outputOnly) { 166 | args = args || []; 167 | args.unshift(command); 168 | return console.log(args.join(" ")); 169 | } 170 | 171 | return spawn(command, args, { 172 | detached: false, // run the command in the same session 173 | stdio: "inherit", // stream directly to the user's terminal 174 | shell: true, // uns in shell mode for compatibility with shell commands 175 | }); 176 | } 177 | 178 | /** 179 | * Run a single command and get its stringified output 180 | * If the command fails or throws, this will return `false` 181 | */ 182 | export async function getCommandOutput(cmd: string): Promise { 183 | try { 184 | return (await shellExec(cmd)).stdout.toString().trim(); 185 | } catch (error) { 186 | return false; 187 | } 188 | } 189 | 190 | /** 191 | * Run a single command and get its stringified output 192 | * If the command fails or throws, this will return `false` 193 | */ 194 | export function getCommandOutputSync(cmd: string): string | false { 195 | try { 196 | return execSync(cmd, { 197 | encoding: "utf-8", 198 | stdio: "pipe", 199 | }) 200 | .toString() 201 | .trim(); 202 | } catch (error) { 203 | return false; 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /src/lib/shell/test-validator.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "path"; 2 | import { rmSync } from "fs"; 3 | import { 4 | createFolders, 5 | directoryExists, 6 | loadFileNamesToMap, 7 | moveFiles, 8 | } from "@/lib/utils"; 9 | import { 10 | DEFAULT_ACCOUNTS_DIR, 11 | DEFAULT_ACCOUNTS_DIR_LOADED, 12 | DEFAULT_TEST_LEDGER_DIR, 13 | } from "@/const/solana"; 14 | import { warnMessage } from "@/lib/logs"; 15 | import { SolanaTomlClone } from "@/types/config"; 16 | import { getCommandOutputSync } from "."; 17 | 18 | type BuildTestValidatorCommandInput = { 19 | verbose?: boolean; 20 | reset?: boolean; 21 | accountDir?: string; 22 | ledgerDir?: string; 23 | authority?: string; 24 | localPrograms?: SolanaTomlClone["program"]; 25 | }; 26 | 27 | export function buildTestValidatorCommand({ 28 | reset = false, 29 | verbose = false, 30 | accountDir = DEFAULT_ACCOUNTS_DIR, 31 | ledgerDir = DEFAULT_TEST_LEDGER_DIR, 32 | authority, 33 | localPrograms, 34 | }: BuildTestValidatorCommandInput = {}) { 35 | const command: string[] = ["solana-test-validator"]; 36 | 37 | const stagingDir = resolve(DEFAULT_ACCOUNTS_DIR_LOADED); 38 | 39 | if (reset) { 40 | rmSync(stagingDir, { 41 | recursive: true, 42 | force: true, 43 | }); 44 | 45 | command.push("--reset"); 46 | } 47 | 48 | if (ledgerDir) { 49 | createFolders(ledgerDir); 50 | command.push(`--ledger ${ledgerDir}`); 51 | } 52 | 53 | // auto load in the account from the provided json files 54 | if (accountDir) { 55 | accountDir = resolve(accountDir); 56 | 57 | if (directoryExists(accountDir)) { 58 | // clone the dir to a different temp location 59 | 60 | createFolders(stagingDir, false); 61 | moveFiles(accountDir, stagingDir, true); 62 | 63 | // todo: update/reset the required account values (like `data` and maybe `owners`) 64 | 65 | command.push(`--account-dir ${stagingDir}`); 66 | 67 | // get the list of all the local binaries 68 | const clonedPrograms = loadFileNamesToMap(accountDir, ".so"); 69 | clonedPrograms.forEach((_value, address) => { 70 | // console.log(`address: ${address}`), 71 | 72 | // todo: what is the real difference between using `--upgradeable-program` and `--bpf-program` here? 73 | 74 | if (authority) { 75 | // todo: support setting a custom authority for each program (reading it from the config toml) 76 | command.push( 77 | `--upgradeable-program ${address}`, 78 | resolve(accountDir, `${address}.so`), 79 | authority, 80 | ); 81 | } else { 82 | command.push( 83 | `--bpf-program ${address}`, 84 | resolve(accountDir, `${address}.so`), 85 | ); 86 | } 87 | }); 88 | 89 | // todo: support loading in local binaries via `--bpf-program` 90 | } else { 91 | if (verbose) { 92 | warnMessage(`Accounts directory does not exist: ${accountDir}`); 93 | warnMessage("Skipping cloning of fixtures"); 94 | } 95 | } 96 | } 97 | 98 | // load the local programs in directly from their build dir 99 | if (localPrograms) { 100 | for (const key in localPrograms) { 101 | if (Object.prototype.hasOwnProperty.call(localPrograms, key)) { 102 | command.push( 103 | `--upgradeable-program ${localPrograms[key].address}`, 104 | localPrograms[key].filePath, 105 | authority, 106 | ); 107 | } 108 | } 109 | } 110 | 111 | // todo: support cloning programs via `--clone-upgradeable-program`? 112 | 113 | return command.join(" "); 114 | } 115 | 116 | export function getRunningTestValidatorCommand() { 117 | return getCommandOutputSync( 118 | // lsof limits the program name character length 119 | `ps aux | awk '/solana-test-validator/ && !/grep/ {$1=$2=$3=$4=$5=$6=$7=$8=$9=$10=""; print substr($0,11)}'`, 120 | ); 121 | } 122 | -------------------------------------------------------------------------------- /src/lib/solana.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AllSolanaClusters, 3 | ProgramsByClusterLabels, 4 | SolanaCliClusterMonikers, 5 | } from "@/types/config"; 6 | import { getCommandOutputSync, VERSION_REGEX } from "@/lib/shell"; 7 | import { PlatformToolsVersions } from "@/types"; 8 | import { address, isAddress } from "gill"; 9 | import { loadKeypairSignerFromFile } from "gill/node"; 10 | 11 | export async function getAddressFromStringOrFilePath(input: string) { 12 | if (isAddress(input)) return address(input); 13 | else { 14 | return (await loadKeypairSignerFromFile(input)).address; 15 | } 16 | } 17 | 18 | /** 19 | * Given a RPC url string, attempt to determine the cluster moniker 20 | */ 21 | export function getClusterMonikerFromUrl( 22 | punitiveUrl: string | URL, 23 | ): SolanaCliClusterMonikers { 24 | try { 25 | punitiveUrl = new URL(punitiveUrl); 26 | } catch (err) { 27 | throw new Error("Unable to parse RPC url"); 28 | } 29 | 30 | switch (punitiveUrl.hostname) { 31 | case "api.devnet.solana.com": { 32 | return "devnet"; 33 | } 34 | case "api.testnet.solana.com": { 35 | return "testnet"; 36 | } 37 | case "api.mainnet-beta.solana.com": { 38 | return "mainnet-beta"; 39 | } 40 | case "0.0.0.0": 41 | case "127.0.0.1": 42 | case "localhost": { 43 | return "localhost"; 44 | } 45 | } 46 | 47 | // todo(nick): add support for common rpc provider urls 48 | 49 | throw new Error("Unable to determine moniker from RPC url"); 50 | } 51 | 52 | /** 53 | * Parse the provided url to correct it into a valid moniker or rpc url 54 | */ 55 | export function parseRpcUrlOrMoniker( 56 | input: string, 57 | ): SolanaCliClusterMonikers | string; 58 | export function parseRpcUrlOrMoniker( 59 | input: string, 60 | allowUrl: true, 61 | ): SolanaCliClusterMonikers | string; 62 | export function parseRpcUrlOrMoniker( 63 | input: string, 64 | allowUrl: false, 65 | ): SolanaCliClusterMonikers; 66 | export function parseRpcUrlOrMoniker( 67 | input: string, 68 | allowUrl: boolean = true, 69 | ): SolanaCliClusterMonikers | string { 70 | if (input.match(/^https?/i)) { 71 | if (!allowUrl) { 72 | throw new Error(`RPC url not allowed. Please provide a moniker.`); 73 | } 74 | 75 | try { 76 | return new URL(input).toString(); 77 | } catch (err) { 78 | throw new Error(`Invalid RPC url provided: ${input}`); 79 | } 80 | } 81 | 82 | input = input.toLowerCase(); // case insensitive for monikers 83 | switch (input) { 84 | case "l": 85 | case "local": 86 | case "localnet": 87 | case "localhost": { 88 | return "localhost"; 89 | } 90 | case "m": 91 | case "mainnet": 92 | case "mainnet-beta": { 93 | return "mainnet-beta"; 94 | } 95 | case "l": 96 | case "localnet": 97 | case "localhost": { 98 | return "localhost"; 99 | } 100 | case "t": 101 | case "testnet": { 102 | return "testnet"; 103 | } 104 | case "d": 105 | case "devnet": { 106 | return "devnet"; 107 | } 108 | } 109 | 110 | throw new Error(`Invalid RPC url provided: ${input}`); 111 | } 112 | 113 | /** 114 | * Validate and sanitize the provided cluster moniker 115 | */ 116 | export function getSafeClusterMoniker( 117 | cluster: AllSolanaClusters | string, 118 | labels?: ProgramsByClusterLabels, 119 | ): false | keyof ProgramsByClusterLabels { 120 | cluster = parseRpcUrlOrMoniker( 121 | cluster, 122 | false /* do not allow parsing urls, only monikers */, 123 | ); 124 | 125 | if (!labels) { 126 | labels = { 127 | devnet: {}, 128 | localnet: {}, 129 | mainnet: {}, 130 | testnet: {}, 131 | }; 132 | } 133 | 134 | // allow equivalent cluster names 135 | switch (cluster) { 136 | case "localhost": 137 | case "localnet": { 138 | cluster = "localnet"; 139 | break; 140 | } 141 | case "mainnet": 142 | case "mainnet-beta": { 143 | cluster = "mainnet"; 144 | break; 145 | } 146 | // we do not need to handle these since there is not a common equivalent 147 | // case "devnet": 148 | // case "testnet": 149 | // default: 150 | } 151 | 152 | if (Object.hasOwn(labels, cluster)) { 153 | return cluster as keyof ProgramsByClusterLabels; 154 | } else return false; 155 | } 156 | 157 | /** 158 | * Get the listing of the user's platform tools versions 159 | */ 160 | export function getPlatformToolsVersions(): PlatformToolsVersions { 161 | const res = getCommandOutputSync("cargo build-sbf --version"); 162 | const tools: PlatformToolsVersions = {}; 163 | 164 | if (!res) return tools; 165 | 166 | res.split("\n").map((line) => { 167 | line = line.trim().toLowerCase(); 168 | if (!line) return; 169 | 170 | const version = VERSION_REGEX.exec(line)?.[1]; 171 | 172 | if (line.startsWith("rustc")) tools.rustc = version; 173 | if (line.startsWith("platform-tools")) tools["platform-tools"] = version; 174 | if (line.startsWith("solana-cargo-build-")) tools["build-sbf"] = version; 175 | }); 176 | 177 | return tools; 178 | } 179 | -------------------------------------------------------------------------------- /src/lib/update.ts: -------------------------------------------------------------------------------- 1 | import { PackageUpdate } from "@/types"; 2 | import { installCargoUpdate } from "./install"; 3 | import { warnMessage } from "./logs"; 4 | import { checkCommand } from "./shell"; 5 | import { 6 | getCurrentNpmPackageVersion, 7 | getNpmRegistryPackageVersion, 8 | } from "./npm"; 9 | import { isVersionNewer } from "./node"; 10 | 11 | /** 12 | * Check for updates to any npm package 13 | * 14 | */ 15 | export async function getNpmPackageUpdates( 16 | packageName: string, 17 | global: boolean = false, 18 | ): Promise { 19 | const updates: PackageUpdate[] = []; 20 | 21 | // always check the global package for mucho to avoid issues when testing 22 | if (packageName == "mucho") global = true; 23 | 24 | const current = await getCurrentNpmPackageVersion(packageName, global); 25 | const registry = await getNpmRegistryPackageVersion(packageName); 26 | if ( 27 | !current || 28 | (registry.latest && isVersionNewer(registry.latest, current)) 29 | ) { 30 | updates.push({ 31 | installed: current, 32 | needsUpdate: true, 33 | latest: registry.latest || "", 34 | name: packageName, 35 | }); 36 | } 37 | 38 | return updates; 39 | } 40 | 41 | /** 42 | * Use the `install-update` crate to help manage the various rust 43 | * based cli tool version, like avm and trident 44 | */ 45 | export async function getCargoUpdateOutput(): Promise { 46 | const res = await checkCommand("cargo install-update --git --list", { 47 | exit: false, 48 | onError: async () => { 49 | warnMessage( 50 | "Unable to detect the 'cargo install-update' command. Installing...", 51 | ); 52 | 53 | await installCargoUpdate(); 54 | }, 55 | doubleCheck: true, 56 | }); 57 | 58 | if (!res) return []; 59 | 60 | const results: PackageUpdate[] = []; 61 | const lines = res.split("\nPackage").slice(1).join("Package"); 62 | const linesToParse = ("Package" + lines) 63 | .split("\n") 64 | .filter((line) => line.trim().length > 0); 65 | 66 | let currentChunk: string[] = []; 67 | 68 | for (const line of linesToParse) { 69 | if (line.includes("Checking") && line.includes("git packages")) { 70 | // Skip this line as it's just a header for git packages 71 | continue; 72 | } 73 | 74 | if (line.startsWith("Package")) { 75 | if (currentChunk.length > 1) { 76 | processChunk(currentChunk, results); 77 | } 78 | currentChunk = [line]; 79 | } else { 80 | currentChunk.push(line); 81 | } 82 | } 83 | 84 | // process the final chunk 85 | if (currentChunk.length > 1) { 86 | processChunk(currentChunk, results); 87 | } 88 | 89 | return results; 90 | } 91 | 92 | function processChunk(chunk: string[], results: PackageUpdate[]) { 93 | const headerLine = chunk[0]; 94 | const dataLines = chunk.slice(1); 95 | 96 | // determine if we're dealing with version numbers or commit hashes 97 | const isCommitHash = 98 | headerLine.toLowerCase().includes("installed") && 99 | headerLine.split(/\s+/).length > 4; 100 | 101 | for (const line of dataLines) { 102 | const parts = line.split(/\s+/).filter((part) => part.length > 0); 103 | 104 | if (parts.length < 4) { 105 | continue; 106 | } 107 | 108 | if (isCommitHash) { 109 | results.push({ 110 | name: parts[0], 111 | installed: parts[1], 112 | latest: parts[2], 113 | needsUpdate: parts[3].toLowerCase() === "yes", 114 | }); 115 | } else { 116 | results.push({ 117 | name: parts[0], 118 | installed: parts[1], 119 | latest: parts[2], 120 | needsUpdate: parts[3].toLowerCase() === "yes", 121 | }); 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/lib/web3.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ComputeBudgetInstruction, 3 | identifyComputeBudgetInstruction, 4 | parseRequestHeapFrameInstruction, 5 | parseRequestUnitsInstruction, 6 | parseSetComputeUnitLimitInstruction, 7 | parseSetComputeUnitPriceInstruction, 8 | parseSetLoadedAccountsDataSizeLimitInstruction, 9 | COMPUTE_BUDGET_PROGRAM_ADDRESS, 10 | } from "gill/programs"; 11 | import { 12 | address, 13 | getBase58Encoder, 14 | type GetTransactionApi, 15 | type UnixTimestamp, 16 | } from "gill"; 17 | 18 | export function unixTimestampToDate( 19 | blockTime: UnixTimestamp | bigint | number, 20 | ) { 21 | return new Date(Number(blockTime) * 1000); 22 | } 23 | 24 | export const VOTE_PROGRAM_ID = address( 25 | "Vote111111111111111111111111111111111111111", 26 | ); 27 | 28 | type ComputeBudgetData = { 29 | /** Number of compute units consumed by the transaction */ 30 | unitsConsumed: number; 31 | /** Units to request for transaction-wide compute */ 32 | unitsRequested?: null | number; 33 | /** Transaction-wide compute unit limit */ 34 | unitLimit?: null | number; 35 | /** Transaction compute unit price used for prioritization fees */ 36 | unitPrice?: null | number; 37 | /** */ 38 | accountDataSizeLimit?: null | number; 39 | /** Requested transaction-wide program heap size in bytes */ 40 | heapFrameSize?: null | number; 41 | }; 42 | 43 | export function getComputeBudgetDataFromTransaction( 44 | tx: ReturnType, 45 | ): ComputeBudgetData { 46 | if (!tx) throw new Error("A transaction is required"); 47 | const budget: ComputeBudgetData = { 48 | unitsConsumed: Number(tx.meta?.computeUnitsConsumed), 49 | unitsRequested: null, 50 | unitLimit: null, 51 | unitPrice: null, 52 | accountDataSizeLimit: null, 53 | heapFrameSize: null, 54 | }; 55 | 56 | const computeBudgetIndex = tx.transaction.message.accountKeys.findIndex( 57 | (address) => address == COMPUTE_BUDGET_PROGRAM_ADDRESS, 58 | ); 59 | 60 | tx.transaction.message.instructions 61 | .filter((ix) => ix.programIdIndex == computeBudgetIndex) 62 | .map((ix) => { 63 | const data = getBase58Encoder().encode(ix.data) as Uint8Array; 64 | const type = identifyComputeBudgetInstruction(data); 65 | switch (type) { 66 | case ComputeBudgetInstruction.SetComputeUnitPrice: { 67 | const { 68 | data: { microLamports }, 69 | } = parseSetComputeUnitPriceInstruction({ 70 | data, 71 | programAddress: COMPUTE_BUDGET_PROGRAM_ADDRESS, 72 | }); 73 | budget.unitPrice = Number(microLamports); 74 | return; 75 | } 76 | case ComputeBudgetInstruction.SetComputeUnitLimit: { 77 | const { 78 | data: { units }, 79 | } = parseSetComputeUnitLimitInstruction({ 80 | data, 81 | programAddress: COMPUTE_BUDGET_PROGRAM_ADDRESS, 82 | }); 83 | budget.unitLimit = units; 84 | return; 85 | } 86 | case ComputeBudgetInstruction.RequestUnits: { 87 | const { 88 | data: { units }, 89 | } = parseRequestUnitsInstruction({ 90 | data, 91 | programAddress: COMPUTE_BUDGET_PROGRAM_ADDRESS, 92 | }); 93 | budget.unitsRequested = units; 94 | return; 95 | } 96 | case ComputeBudgetInstruction.SetLoadedAccountsDataSizeLimit: { 97 | const { 98 | data: { accountDataSizeLimit }, 99 | } = parseSetLoadedAccountsDataSizeLimitInstruction({ 100 | data, 101 | programAddress: COMPUTE_BUDGET_PROGRAM_ADDRESS, 102 | }); 103 | budget.accountDataSizeLimit = accountDataSizeLimit; 104 | return; 105 | } 106 | case ComputeBudgetInstruction.RequestHeapFrame: { 107 | const { 108 | data: { bytes }, 109 | } = parseRequestHeapFrameInstruction({ 110 | data, 111 | programAddress: COMPUTE_BUDGET_PROGRAM_ADDRESS, 112 | }); 113 | budget.heapFrameSize = bytes; 114 | return; 115 | } 116 | } 117 | }); 118 | 119 | return budget; 120 | } 121 | -------------------------------------------------------------------------------- /src/types/anchor.ts: -------------------------------------------------------------------------------- 1 | import { ProgramsByClusterLabels, SolanaTomlCloneConfig } from "./config"; 2 | 3 | export type AnchorToml = { 4 | configPath?: string; 5 | programs?: ProgramsByClusterLabels; 6 | test?: { 7 | validator?: { 8 | /** rpc url to use in order to clone */ 9 | url?: string; 10 | /** program to clone */ 11 | clone?: SolanaTomlCloneConfig[]; 12 | /** accounts to clone */ 13 | // note: `account` also supports `filename` but we do nothing with that here 14 | account?: SolanaTomlCloneConfig[]; 15 | }; 16 | }; 17 | }; 18 | 19 | export type AnchorTomlWithConfigPath = Omit & 20 | NonNullable<{ configPath: AnchorToml["configPath"] }>; 21 | 22 | export type AnchorVersionData = { 23 | current: null | string; 24 | latest: null | string; 25 | installed: string[]; 26 | available: string[]; 27 | }; 28 | -------------------------------------------------------------------------------- /src/types/cargo.ts: -------------------------------------------------------------------------------- 1 | export type CargoToml = { 2 | configPath?: string; 3 | workspace?: { 4 | members: string[]; 5 | }; 6 | package: { 7 | name: string; 8 | version: string; 9 | description?: string; 10 | edition: string; 11 | }; 12 | lib?: { 13 | "crate-type"?: ("cdylib" | "lib")[]; 14 | name?: string; 15 | }; 16 | dependencies?: { 17 | [dependencyName: string]: string; 18 | }; 19 | }; 20 | 21 | export type CargoTomlWithConfigPath = Omit & 22 | NonNullable<{ configPath: CargoToml["configPath"] }>; 23 | -------------------------------------------------------------------------------- /src/types/config.ts: -------------------------------------------------------------------------------- 1 | import { JsonAccountStruct } from "@/lib/shell/clone"; 2 | import { SolanaClusterMoniker } from "gill"; 3 | 4 | export type SolanaTomlWithConfigPath = Omit & 5 | NonNullable<{ configPath: SolanaToml["configPath"] }>; 6 | 7 | /** 8 | * The Solana CLI does not support: `mainnet` or `localnet` 9 | */ 10 | export type SolanaCliClusterMonikers = 11 | | "mainnet-beta" 12 | | "devnet" 13 | | "testnet" 14 | | "localhost"; 15 | 16 | export type AllSolanaClusters = SolanaClusterMoniker | SolanaCliClusterMonikers; 17 | 18 | export type SolanaToml = { 19 | configPath?: string; 20 | settings?: Partial<{ 21 | /** 22 | * default cluster to use for all operations. 23 | * when not set, fallback to the Solana CLI cluster 24 | */ 25 | cluster: AllSolanaClusters; 26 | /** local directory for the ledger */ 27 | ledgerDir?: string; 28 | /** local directory path to store any cloned accounts */ 29 | accountDir: string; 30 | /** path to the local authority keypair */ 31 | keypair: string; 32 | /** 33 | * custom rpc urls for each network that will be used to override the default public endpoints 34 | */ 35 | networks: Partial<{ 36 | mainnet: string; 37 | devnet: string; 38 | testnet: string; 39 | localnet: string; 40 | }>; 41 | }>; 42 | programs?: ProgramsByClusterLabels; 43 | clone?: Partial; 44 | }; 45 | 46 | export type SolanaTomlCloneConfig = { 47 | address: string; 48 | filePath?: string; 49 | name?: string; 50 | cluster?: AllSolanaClusters | string; 51 | // default - `cached` 52 | frequency?: "cached" | "always"; 53 | }; 54 | 55 | export type SolanaTomlClone = { 56 | program: { 57 | [key: string]: SolanaTomlCloneConfig; 58 | }; 59 | account: { 60 | [key: string]: SolanaTomlCloneConfig; 61 | }; 62 | token: { 63 | [key: string]: SolanaTomlCloneConfig & { 64 | amount?: number; 65 | mintAuthority?: string; 66 | freezeAuthority?: string; 67 | holders?: Array<{ 68 | owner: string; 69 | amount?: number; 70 | }>; 71 | }; 72 | }; 73 | }; 74 | 75 | /** 76 | * Composite type of SolanaTomlClone["program"] but with the `filePath` required 77 | */ 78 | export type SolanaTomlCloneLocalProgram = { 79 | [K in keyof SolanaTomlClone["program"]]: Omit< 80 | SolanaTomlClone["program"][K], 81 | "filePath" 82 | > & { filePath: string }; 83 | }; 84 | 85 | export type CloneSettings = { 86 | autoClone?: boolean; 87 | force?: boolean; 88 | prompt?: boolean; 89 | }; 90 | 91 | export type CloneAccountsFromConfigResult = { 92 | owners: Map; 93 | changedAccounts: Map; 94 | }; 95 | 96 | export type ProgramsByClusterLabels = Partial<{ 97 | localnet?: Record; 98 | devnet?: Record; 99 | testnet?: Record; 100 | mainnet?: Record; 101 | }>; 102 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export type PlatformOS = "unknown" | "linux" | "mac" | "windows"; 2 | 3 | export type ToolNames = 4 | | "rust" 5 | | "rustup" 6 | | "solana" 7 | | "avm" 8 | | "anchor" 9 | | "node" 10 | | "yarn" 11 | | "zest" 12 | | "cargo-update" 13 | | "verify" 14 | | "trident"; 15 | 16 | export type ToolCommandConfig = { 17 | /** $PATH location for the command's tools */ 18 | pathSource?: string; 19 | /** command to get the tool version */ 20 | version: string; 21 | /** command to install the tool */ 22 | // install: string; 23 | /** command to update the tool */ 24 | // update: string; 25 | dependencies?: ToolNames[]; 26 | }; 27 | 28 | export type PlatformToolsVersions = Partial<{ 29 | rustc: string; 30 | "platform-tools": string; 31 | "build-sbf": string; 32 | }>; 33 | 34 | /** 35 | * 36 | */ 37 | export type InstallCommandPropsBase = { 38 | /** */ 39 | os?: PlatformOS; 40 | /** */ 41 | version?: string; 42 | /** */ 43 | verbose?: boolean; 44 | /** */ 45 | verifyParentCommand?: boolean; 46 | /** */ 47 | updateAvailable?: PackageUpdate | undefined; 48 | 49 | /** 50 | * Reference to an existing `spinner` 51 | */ 52 | // spinner: ReturnType; 53 | }; 54 | 55 | export type ShellExecInSessionArgs = { 56 | command: string; 57 | args?: string[]; 58 | outputOnly?: boolean; 59 | }; 60 | 61 | export type PackageUpdate = { 62 | name: string; 63 | installed: string | false; 64 | latest: string; 65 | needsUpdate: boolean; 66 | }; 67 | -------------------------------------------------------------------------------- /src/types/inspect.ts: -------------------------------------------------------------------------------- 1 | import { AllSolanaClusters } from "@/types/config"; 2 | import type { Commitment, createSolanaRpc } from "gill"; 3 | 4 | export type InspectorBaseArgs = { 5 | cluster: AllSolanaClusters; 6 | rpc: ReturnType; 7 | commitment?: Commitment; 8 | }; 9 | -------------------------------------------------------------------------------- /src/types/solana.ts: -------------------------------------------------------------------------------- 1 | export type ProgramInfoStruct = { 2 | programId: string; 3 | owner: string; 4 | programdataAddress: string; 5 | authority: false | "none" | string; 6 | lastDeploySlot: number; 7 | dataLen: number; 8 | lamports: number; 9 | }; 10 | 11 | export type SolanaCliYaml = Partial<{ 12 | json_rpc_url: string; 13 | websocket_url: string; 14 | keypair_path: string; 15 | address_labels: { 16 | [key in string]: string; 17 | }; 18 | commitment: "processed" | "confirmed" | "finalized"; 19 | }>; 20 | -------------------------------------------------------------------------------- /tests/Solana.toml: -------------------------------------------------------------------------------- 1 | # Solana.toml is used to declare settings for use with `npx mucho` 2 | 3 | [settings] 4 | # define the directory to store all the fixtures (accounts/programs) you want to clone 5 | # accountDir = ".cache/accounts" # default="fixtures" 6 | 7 | # desired cluster to clone all fixtures from (if not individually overriden per fixture) 8 | # cluster = "mainnet" # default="mainnet" 9 | # cluster = "devnet" 10 | # cluster = "testnet" 11 | 12 | # path to a local keypair file that will be used as the default authority for various operations 13 | # keypair = "any-path" # default="~/.config/solana/id.json" 14 | 15 | [programs.localnet] 16 | counter_solana_native = "Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS" 17 | 18 | [clone.account.wallet] 19 | # this is a random wallet account, owned by system program 20 | address = "GQuioVe2yA6KZfstgmirAvugfUZBcdxSi7sHK7JGk3gk" 21 | # override the cluster for this particular account, if not defined: uses `settings.cluster` 22 | # cluster = "mainnet" 23 | # cluster = "devnet" 24 | 25 | # if you want to always force clone the account every clone operation 26 | # frequency = "always" # default="cached" 27 | 28 | [clone.account.tm-rando] 29 | # random metaplex token metadata account 30 | address = "5uZQ4GExZXwwKRNmpxwxTW2ZbHxh8KjDDwKne7Whqm4H" 31 | # this account is owned by `metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s` (Metaplex's Token Metadata program) 32 | # the owner program will be auto cloned (from the same cluster) 33 | # without needing to declare it anywhere 34 | 35 | [clone.account.rando] 36 | # some random account from devnet 37 | address = "HaMxedZofAdLasiTggfGiKLzQVt759Gcw1JBP5KUPPkA" 38 | # owned by: AHDckVNCgytZg6jkZPFUKTFJLMvnj3qmiigVNA5Z3yZu (some random program on devnet) 39 | cluster = "devnet" 40 | 41 | [clone.program.bubblegum] 42 | address = "BGUMAp9Gq7iTEuizy4pqaxsTyUCBK68MDfK752saRPUY" 43 | 44 | # override the cluster for this particular program, if not defined: uses `settings.cluster` 45 | # cluster = "mainnet" 46 | # cluster = "devnet" 47 | 48 | # if you want to always force clone the account 49 | # frequency = "always" 50 | 51 | [clone.account.bonk] 52 | # for tokens: list the mint address 53 | address = "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263" 54 | 55 | # override the cluster for this particular account, if not defined: uses `settings.cluster` 56 | # cluster = "devnet" 57 | cluster = "mainnet" 58 | 59 | # if you want to always force clone the account 60 | # frequency = "always" 61 | 62 | [clone.account.usdc] 63 | address = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" 64 | frequency = "always" 65 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/**/*"], 3 | "compilerOptions": { 4 | "rootDir": "./", 5 | "outDir": "bin", 6 | 7 | "target": "es2022", 8 | "module": "esnext", 9 | "allowJs": true, 10 | "checkJs": true, 11 | 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "moduleResolution": "bundler", 15 | "resolveJsonModule": true, 16 | "allowSyntheticDefaultImports": true, 17 | 18 | "noEmit": true, 19 | "strict": false, 20 | "useUnknownInCatchVariables": false, 21 | 22 | "baseUrl": "./src", 23 | "paths": { 24 | "@/*": ["./*"] 25 | } 26 | } 27 | } 28 | --------------------------------------------------------------------------------