├── .gitmodules ├── VERSION ├── .github ├── CODEOWNERS ├── workflows │ ├── build.yml │ ├── tests.yml │ ├── codeql-scanning.yaml │ ├── golangci-lint.yml │ ├── automation.yml │ ├── create.yml │ ├── checks.yml │ └── devnet.yml ├── pull_request_template.md └── configs │ └── typos-cli.toml ├── .zeus ├── config ├── .zeus ├── config.yaml ├── configs │ ├── v0.0.1.yaml │ ├── v0.0.2.yaml │ ├── v0.0.3.yaml │ ├── migrations │ │ ├── v0.0.2-v0.0.3.go │ │ └── v0.0.1-v0.0.2.go │ └── registry.go ├── .env.example ├── templates.yaml ├── config_embeds.go ├── .gitignore ├── keystores │ ├── operator1.ecdsa.keystore.json │ ├── operator2.ecdsa.keystore.json │ ├── operator3.ecdsa.keystore.json │ ├── operator4.ecdsa.keystore.json │ ├── operator5.ecdsa.keystore.json │ ├── operator1.bls.keystore.json │ ├── operator2.bls.keystore.json │ ├── operator3.bls.keystore.json │ ├── operator4.bls.keystore.json │ └── operator5.bls.keystore.json ├── contexts │ ├── migrations │ │ ├── v0.0.4-v0.0.5.go │ │ ├── v0.1.0-v0.1.1.go │ │ ├── v0.0.3-v0.0.4.go │ │ ├── v0.0.2-v0.0.3.go │ │ └── v0.0.1-v0.0.2.go │ ├── v0.0.2.yaml │ ├── v0.0.1.yaml │ ├── v0.0.3.yaml │ ├── v0.0.4.yaml │ └── v0.0.5.yaml └── keystore_embeds.go ├── assets └── devkit-user-flow.png ├── embeds.go ├── test └── integration │ └── migration │ ├── .zeus │ ├── keystores │ ├── operator1.ecdsa.keystore.json │ ├── operator2.ecdsa.keystore.json │ ├── operator3.ecdsa.keystore.json │ ├── operator4.ecdsa.keystore.json │ ├── operator5.ecdsa.keystore.json │ ├── operator1.bls.keystore.json │ ├── operator2.bls.keystore.json │ ├── operator3.bls.keystore.json │ ├── operator4.bls.keystore.json │ └── operator5.bls.keystore.json │ └── avs_context_0_1_0_to_0_1_1_test.go ├── pkg ├── common │ ├── devnet │ │ ├── logging.go │ │ ├── embed.go │ │ └── constants.go │ ├── progress │ │ ├── decect_tty.go │ │ ├── log_progress.go │ │ └── tty_progress.go │ ├── contracts │ │ ├── eigen.go │ │ └── steth.go │ ├── iface │ │ ├── progress.go │ │ └── logger.go │ ├── flags.go │ ├── logger │ │ ├── progress_logger.go │ │ ├── zap_logger.go │ │ └── basic_logger.go │ ├── user_config.go │ ├── output │ │ └── prompt.go │ ├── context.go │ ├── scripts_caller.go │ ├── scripts_caller_test.go │ ├── dockerutils.go │ ├── utils_test.go │ ├── telemetry_prompt_test.go │ ├── global_config.go │ └── constants.go ├── commands │ ├── keystore │ │ ├── keystore.go │ │ └── read_keystore.go │ ├── avs.go │ ├── version │ │ └── version.go │ ├── template │ │ ├── info.go │ │ ├── info_test.go │ │ ├── template.go │ │ └── template_test.go │ ├── deploy.go │ ├── test.go │ ├── release.go │ ├── transporter_test.go │ ├── upgrade_test.go │ ├── run.go │ ├── context │ │ └── context_selection.go │ ├── test_test.go │ ├── call.go │ ├── config │ │ └── config.go │ ├── basic_e2e_test.go │ ├── devnet.go │ ├── run_test.go │ └── telemetry.go ├── telemetry │ ├── noop.go │ ├── telemetry.go │ ├── telemetry_test.go │ ├── metric.go │ └── posthog.go ├── template │ ├── git_fetcher.go │ ├── config.go │ ├── config_test.go │ ├── git_reporter.go │ └── git_reporter_test.go └── migration │ └── migrator_test.go ├── .cursor └── rules │ └── project.mdc ├── scripts ├── bundleReleases.sh └── version.sh ├── internal └── version │ └── version.go ├── .pre-commit-config.yaml ├── .gitignore ├── autocomplete ├── zsh_autocomplete └── bash_autocomplete ├── docker └── anvil │ ├── docker-compose.yaml │ └── devnetembed.go ├── LICENSE ├── install-devkit.sh ├── cmd └── devkit │ └── main.go └── Makefile /.gitmodules: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | v0.1.0-preview.9.rc 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Give the devex-cli team ownership over everything 2 | * @Layr-Labs/devex-cli -------------------------------------------------------------------------------- /.zeus: -------------------------------------------------------------------------------- 1 | { 2 | "zeusHost": "https://github.com/Layr-Labs/eigenlayer-contracts-zeus-metadata" 3 | } -------------------------------------------------------------------------------- /config/.zeus: -------------------------------------------------------------------------------- 1 | { 2 | "zeusHost": "https://github.com/Layr-Labs/eigenlayer-contracts-zeus-metadata" 3 | } -------------------------------------------------------------------------------- /assets/devkit-user-flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Layr-Labs/devkit-cli/HEAD/assets/devkit-user-flow.png -------------------------------------------------------------------------------- /embeds.go: -------------------------------------------------------------------------------- 1 | package project 2 | 3 | import _ "embed" 4 | 5 | //go:embed README.md 6 | var RawReadme []byte 7 | -------------------------------------------------------------------------------- /test/integration/migration/.zeus: -------------------------------------------------------------------------------- 1 | { 2 | "zeusHost": "https://github.com/Layr-Labs/eigenlayer-contracts-zeus-metadata" 3 | } -------------------------------------------------------------------------------- /config/config.yaml: -------------------------------------------------------------------------------- 1 | version: 0.0.1 2 | config: 3 | project: 4 | name: "my-avs" 5 | version: "0.1.0" 6 | context: "devnet" -------------------------------------------------------------------------------- /config/configs/v0.0.1.yaml: -------------------------------------------------------------------------------- 1 | version: 0.0.1 2 | config: 3 | project: 4 | name: "my-avs" 5 | version: "0.1.0" 6 | context: "devnet" 7 | -------------------------------------------------------------------------------- /config/.env.example: -------------------------------------------------------------------------------- 1 | # .env example 2 | 3 | # Ethereum sepolia fork URL used for AVS devnet 4 | L1_FORK_URL= 5 | 6 | # Ethereum base sepolia fork URL used for AVS devnet 7 | L2_FORK_URL= -------------------------------------------------------------------------------- /pkg/common/devnet/logging.go: -------------------------------------------------------------------------------- 1 | package devnet 2 | 3 | const ( 4 | Blue = "\033[34m" 5 | Cyan = "\033[36m" 6 | Green = "\033[32m" 7 | Yellow = "\033[33m" 8 | Reset = "\033[0m" 9 | ) 10 | -------------------------------------------------------------------------------- /config/templates.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | hourglass: 3 | template: "https://github.com/Layr-Labs/hourglass-avs-template" 4 | version: "v0.1.0" 5 | languages: 6 | - go 7 | - ts 8 | - rust 9 | -------------------------------------------------------------------------------- /pkg/common/progress/decect_tty.go: -------------------------------------------------------------------------------- 1 | package progress 2 | 3 | import ( 4 | "os" 5 | 6 | "golang.org/x/term" 7 | ) 8 | 9 | func IsTTY() bool { 10 | return term.IsTerminal(int(os.Stdout.Fd())) 11 | } 12 | -------------------------------------------------------------------------------- /pkg/common/contracts/eigen.go: -------------------------------------------------------------------------------- 1 | package contracts 2 | 3 | const EIGEN_CONTRACT_ABI = `[{"constant":false,"inputs":[{"name":"amount","type":"uint256"}],"name":"unwrap","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"}]` 4 | -------------------------------------------------------------------------------- /pkg/common/contracts/steth.go: -------------------------------------------------------------------------------- 1 | package contracts 2 | 3 | const ST_ETH_CONTRACT_ABI = `[{"constant":false,"inputs":[{"name":"_referral","type":"address"}],"name":"submit","outputs":[],"payable":true,"stateMutability":"payable","type":"function"}]` 4 | -------------------------------------------------------------------------------- /.cursor/rules/project.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: true 5 | --- 6 | ## Rules to apply always 7 | 8 | * When running the cli to test creating projects, always create them in the ./test-projects directory 9 | * Always run `make build` to build the binary to test 10 | -------------------------------------------------------------------------------- /config/config_embeds.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import _ "embed" 4 | 5 | //go:embed templates.yaml 6 | var TemplatesYaml string 7 | 8 | //go:embed .gitignore 9 | var GitIgnore string 10 | 11 | //go:embed .env.example 12 | var EnvExample string 13 | 14 | //go:embed .zeus 15 | var ZeusConfig string 16 | -------------------------------------------------------------------------------- /pkg/commands/keystore/keystore.go: -------------------------------------------------------------------------------- 1 | package keystore 2 | 3 | import ( 4 | "github.com/urfave/cli/v2" 5 | ) 6 | 7 | var KeystoreCommand = &cli.Command{ 8 | Name: "keystore", 9 | Usage: "Manage keystore operations", 10 | Subcommands: []*cli.Command{ 11 | CreateCommand, 12 | ReadCommand, 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /config/configs/v0.0.2.yaml: -------------------------------------------------------------------------------- 1 | version: 0.0.2 2 | config: 3 | project: 4 | name: "my-avs" 5 | version: "0.1.0" 6 | context: "devnet" 7 | project_uuid: "" 8 | telemetry_enabled: false 9 | templateBaseUrl: "https://github.com/Layr-Labs/hourglass-avs-template" 10 | templateVersion: "v0.0.10" 11 | -------------------------------------------------------------------------------- /scripts/bundleReleases.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | VERSION=$1 4 | 5 | if [[ -z $VERSION ]]; then 6 | echo "Usage: $0 " 7 | exit 1 8 | fi 9 | 10 | 11 | for i in $(ls release); do 12 | fileName="devkit-${i}-${VERSION}.tar.gz" 13 | 14 | tar -cvf "./release/${fileName}" -C "./release/${i}/" devkit 15 | done 16 | -------------------------------------------------------------------------------- /internal/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | var ( 4 | Version string 5 | Commit string 6 | ) 7 | 8 | func GetVersion() string { 9 | if Version == "" { 10 | return "unknown" 11 | } 12 | return Version 13 | } 14 | 15 | func GetCommit() string { 16 | if Commit == "" { 17 | return "unknown" 18 | } 19 | return Commit 20 | } 21 | -------------------------------------------------------------------------------- /config/configs/v0.0.3.yaml: -------------------------------------------------------------------------------- 1 | version: 0.0.3 2 | config: 3 | project: 4 | name: "my-avs" 5 | version: "0.1.0" 6 | context: "devnet" 7 | project_uuid: "" 8 | templateBaseUrl: "https://github.com/Layr-Labs/hourglass-avs-template" 9 | templateVersion: "v0.0.14" 10 | templateLanguage: "go" 11 | telemetry_enabled: false 12 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_stages: [pre-commit,pre-push] 2 | 3 | repos: 4 | - repo: https://github.com/golangci/golangci-lint 5 | rev: v1.64.8 6 | hooks: 7 | - id: golangci-lint 8 | name: golangci-lint 9 | entry: golangci-lint run 10 | types: [go] 11 | pass_filenames: false 12 | args: ["--timeout=1m"] 13 | -------------------------------------------------------------------------------- /pkg/common/iface/progress.go: -------------------------------------------------------------------------------- 1 | package iface 2 | 3 | // ProgressRow is a snapshot of a completed progress update. 4 | type ProgressRow struct { 5 | Module string 6 | Pct int 7 | Label string 8 | } 9 | 10 | type ProgressTracker interface { 11 | ProgressRows() []ProgressRow 12 | Set(id string, pct int, label string) 13 | Render() 14 | Clear() 15 | } 16 | 17 | type ProgressInfo struct { 18 | Percentage int 19 | DisplayText string 20 | Timestamp string 21 | } 22 | -------------------------------------------------------------------------------- /pkg/common/devnet/embed.go: -------------------------------------------------------------------------------- 1 | package devnet 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/Layr-Labs/devkit-cli/docker/anvil" 7 | ) 8 | 9 | // WriteEmbeddedArtifacts writes the embedded docker-compose.yaml. 10 | // Returns the paths to the written files. 11 | func WriteEmbeddedArtifacts() (composePath string) { 12 | var err error 13 | 14 | composePath, err = assets.WriteDockerComposeToPath() 15 | if err != nil { 16 | log.Fatalf("❌ Could not write embedded docker-compose.yaml: %v", err) 17 | } 18 | 19 | return composePath 20 | } 21 | -------------------------------------------------------------------------------- /pkg/common/iface/logger.go: -------------------------------------------------------------------------------- 1 | package iface 2 | 3 | type Logger interface { 4 | Title(msg string, args ...any) 5 | Info(msg string, args ...any) 6 | Warn(msg string, args ...any) 7 | Error(msg string, args ...any) 8 | Debug(msg string, args ...any) 9 | } 10 | 11 | type ProgressLogger interface { 12 | Title(msg string, args ...any) 13 | Info(msg string, args ...any) 14 | Warn(msg string, args ...any) 15 | Error(msg string, args ...any) 16 | Progress(name string, percent int, displayText string) 17 | PrintProgress() 18 | ClearProgress() 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | devkit 8 | 9 | # Build outputs 10 | /bin/ 11 | /out/ 12 | *.test 13 | 14 | # Dependency directories (if you're not committing vendor) 15 | vendor/ 16 | 17 | # Go module caches (optional, as these are local) 18 | # /go/pkg/ 19 | 20 | # OS-specific files 21 | .DS_Store 22 | Thumbs.db 23 | 24 | # Editor and IDE files 25 | .vscode/ 26 | .idea/ 27 | *.swp 28 | 29 | # Release outputs 30 | temp_external/ 31 | /test-projects 32 | .cursor/execution-plans 33 | {$ 34 | -------------------------------------------------------------------------------- /autocomplete/zsh_autocomplete: -------------------------------------------------------------------------------- 1 | #!/bin/zsh 2 | 3 | _cli_zsh_autocomplete() { 4 | 5 | local -a opts 6 | local cur 7 | cur=${words[-1]} 8 | if [[ "$cur" == "-"* ]]; then 9 | opts=("${(@f)$(_CLI_ZSH_AUTOCOMPLETE_HACK=1 ${words[@]:0:#words-1} ${cur} --generate-bash-completion)}") 10 | else 11 | opts=("${(@f)$(_CLI_ZSH_AUTOCOMPLETE_HACK=1 ${words[@]:0:#words-1} --generate-bash-completion)}") 12 | fi 13 | 14 | if [[ "${opts[1]}" != "" ]]; then 15 | _describe 'completions' opts 16 | return 0 17 | fi 18 | 19 | return 1 20 | } 21 | 22 | compdef _cli_zsh_autocomplete $PROG -------------------------------------------------------------------------------- /pkg/common/flags.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import "github.com/urfave/cli/v2" 4 | 5 | // GlobalFlags defines flags that apply to the entire application (global flags). 6 | var GlobalFlags = []cli.Flag{ 7 | &cli.BoolFlag{ 8 | Name: "verbose", 9 | Aliases: []string{"v"}, 10 | Usage: "Enable verbose logging", 11 | }, 12 | &cli.BoolFlag{ 13 | Name: "enable-telemetry", 14 | Usage: "Enable telemetry collection on first run without prompting", 15 | }, 16 | &cli.BoolFlag{ 17 | Name: "disable-telemetry", 18 | Usage: "Disable telemetry collection on first run without prompting", 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: ["**"] 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | Test: 15 | name: Build 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout devkit-cli 19 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 20 | 21 | - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 22 | with: 23 | go-version: '1.23' 24 | - name: Build 25 | run: | 26 | go mod tidy 27 | make build 28 | -------------------------------------------------------------------------------- /autocomplete/bash_autocomplete: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | _cli_bash_autocomplete() { 4 | if [[ "${COMP_WORDS[0]}" != "source" ]]; then 5 | local cur opts base 6 | COMPREPLY=() 7 | cur="${COMP_WORDS[COMP_CWORD]}" 8 | if [[ "$cur" == "-"* ]]; then 9 | opts=$( ${COMP_WORDS[@]:0:$COMP_CWORD} ${cur} --generate-bash-completion ) 10 | else 11 | opts=$( ${COMP_WORDS[@]:0:$COMP_CWORD} --generate-bash-completion ) 12 | fi 13 | COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) ) 14 | return 0 15 | fi 16 | } 17 | 18 | complete -o bashdefault -o default -o nospace -F _cli_bash_autocomplete $PROG -------------------------------------------------------------------------------- /scripts/version.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | REF=$1 4 | 5 | versionFile=$(cat VERSION | tr -d '[:space:]') 6 | echo "Using version '${versionFile}'" 7 | if [[ $REF == refs/tags/* ]]; then 8 | # check if versionFile equals the tag. 9 | if [[ $versionFile != "${REF#refs/tags/}" ]]; then 10 | echo "Version in VERSION file does not match the tag" 11 | exit 1 12 | fi 13 | echo "Version correctly matches tag" 14 | else 15 | # if this isnt a release, add the commit hash to the end of the version 16 | v=$(git rev-parse --short HEAD) 17 | updatedVersion="${versionFile}+${v}" 18 | echo "Updated version to '${updatedVersion}'" 19 | echo -n $updatedVersion > VERSION 20 | fi 21 | -------------------------------------------------------------------------------- /pkg/commands/avs.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "github.com/Layr-Labs/devkit-cli/pkg/commands/config" 5 | "github.com/Layr-Labs/devkit-cli/pkg/commands/context" 6 | "github.com/Layr-Labs/devkit-cli/pkg/commands/template" 7 | "github.com/urfave/cli/v2" 8 | ) 9 | 10 | var AVSCommand = &cli.Command{ 11 | Name: "avs", 12 | Usage: "Manage EigenCloud AVS (Autonomous Verifiable Services) projects", 13 | Subcommands: []*cli.Command{ 14 | CreateCommand, 15 | config.Command, 16 | context.Command, 17 | BuildCommand, 18 | DevnetCommand, 19 | DeployCommand, 20 | TransportCommand, 21 | RunCommand, 22 | TestCommand, 23 | CallCommand, 24 | ReleaseCommand, 25 | template.Command, 26 | }, 27 | } 28 | -------------------------------------------------------------------------------- /config/.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | devkit 8 | 9 | # Build outputs 10 | /bin/ 11 | /out/ 12 | *.test 13 | 14 | # Dependency directories (if you're not committing vendor) 15 | vendor/ 16 | 17 | # Go module caches (optional, as these are local) 18 | # /go/pkg/ 19 | 20 | # OS-specific files 21 | .DS_Store 22 | Thumbs.db 23 | 24 | # Editor and IDE files 25 | .vscode/ 26 | .idea/ 27 | *.swp 28 | 29 | # Release outputs 30 | temp_external/ 31 | 32 | # Internal upgrade directory 33 | temp_internal/ 34 | 35 | # DevKit context configurations (only devnet.yaml is indexed) 36 | config/contexts/**/* 37 | !config/contexts/devnet.yaml 38 | 39 | # Environment 40 | .env -------------------------------------------------------------------------------- /config/keystores/operator1.ecdsa.keystore.json: -------------------------------------------------------------------------------- 1 | { 2 | "address": "90f79bf6eb2c4f870365e785982e1f101e93b906", 3 | "crypto": { 4 | "cipher": "aes-128-ctr", 5 | "ciphertext": "cb9721b2bb12f67f63eadcbc42e7a2ac5721b03dbe50d07c723cd7693921b477", 6 | "cipherparams": { 7 | "iv": "d3395f83b964967800b3071195cee867" 8 | }, 9 | "kdf": "scrypt", 10 | "kdfparams": { 11 | "dklen": 32, 12 | "n": 262144, 13 | "p": 1, 14 | "r": 8, 15 | "salt": "0fe7be44e9d127275d57a0e841be14f0e722467d69136f28683c3a1ad8c98333" 16 | }, 17 | "mac": "d43b0060ac1e5a95f66fc99493bf7bb0c180eee12aadfe389e5706b8bda42c64" 18 | }, 19 | "id": "4df14231-53b3-49c4-8d29-823197cb217c", 20 | "version": 3 21 | } -------------------------------------------------------------------------------- /config/keystores/operator2.ecdsa.keystore.json: -------------------------------------------------------------------------------- 1 | { 2 | "address": "15d34aaf54267db7d7c367839aaf71a00a2c6a65", 3 | "crypto": { 4 | "cipher": "aes-128-ctr", 5 | "ciphertext": "e2376b84e186e148dbcaabd6ccce475b58e1ea0bf336dc44a7b431cda211a83e", 6 | "cipherparams": { 7 | "iv": "c94e317e7ff695f9aa637f6890b78d17" 8 | }, 9 | "kdf": "scrypt", 10 | "kdfparams": { 11 | "dklen": 32, 12 | "n": 262144, 13 | "p": 1, 14 | "r": 8, 15 | "salt": "6c642b7154147826278bb6d7b6fdbc8415fb2858641fa8a4edc4b0b8f550fa7f" 16 | }, 17 | "mac": "e8dc1ab58434fac3f76fc9dbf2ab0f720bbfa4e721e6763c9e982f55a5ef139a" 18 | }, 19 | "id": "c25256a8-bd0b-41b6-b432-323cd26db0fc", 20 | "version": 3 21 | } -------------------------------------------------------------------------------- /config/keystores/operator3.ecdsa.keystore.json: -------------------------------------------------------------------------------- 1 | { 2 | "address": "9965507d1a55bcc2695c58ba16fb37d819b0a4dc", 3 | "crypto": { 4 | "cipher": "aes-128-ctr", 5 | "ciphertext": "baedc9a338b8cfc18742e95bf76bb5344215d69a6978baf80d82c643e60b6de8", 6 | "cipherparams": { 7 | "iv": "ce362678c9a3252c25498910ab091d5c" 8 | }, 9 | "kdf": "scrypt", 10 | "kdfparams": { 11 | "dklen": 32, 12 | "n": 262144, 13 | "p": 1, 14 | "r": 8, 15 | "salt": "e47a6427f37f7a847e25fc15041ffc6bacbb2710d16b94f4e3b05309bd0938a0" 16 | }, 17 | "mac": "3a17346e4642a7f2b3eba2eff8e728998748ef21a93a2d6753630dcc1d970be3" 18 | }, 19 | "id": "926b6b31-871f-4095-8341-c252cd7f6109", 20 | "version": 3 21 | } -------------------------------------------------------------------------------- /config/keystores/operator4.ecdsa.keystore.json: -------------------------------------------------------------------------------- 1 | { 2 | "address": "976ea74026e726554db657fa54763abd0c3a0aa9", 3 | "crypto": { 4 | "cipher": "aes-128-ctr", 5 | "ciphertext": "6413921ec3a30c91e565883c466c064467498dc5eb311cf83d8fafaedf520298", 6 | "cipherparams": { 7 | "iv": "b2c27fd1ab886c523fbc1d2b9c935e06" 8 | }, 9 | "kdf": "scrypt", 10 | "kdfparams": { 11 | "dklen": 32, 12 | "n": 262144, 13 | "p": 1, 14 | "r": 8, 15 | "salt": "c71a9874630f71400434722c42d0ea68552f911f88cb1aa97dc0b72255f9a173" 16 | }, 17 | "mac": "2ae78f29e164723ea87a5b9b2d3dd9925f45e5883d0e165b30b87f13e6cf27ef" 18 | }, 19 | "id": "fa0d87ff-2f03-4648-a453-c302b2acc371", 20 | "version": 3 21 | } -------------------------------------------------------------------------------- /config/keystores/operator5.ecdsa.keystore.json: -------------------------------------------------------------------------------- 1 | { 2 | "address": "14dc79964da2c08b23698b3d3cc7ca32193d9955", 3 | "crypto": { 4 | "cipher": "aes-128-ctr", 5 | "ciphertext": "b02d5574fc9a2b813dd47b97a5ac51f84480dd22716f7bbfe72514a5f4b14176", 6 | "cipherparams": { 7 | "iv": "5dba5dc0c04577f5f9a4b5719258c317" 8 | }, 9 | "kdf": "scrypt", 10 | "kdfparams": { 11 | "dklen": 32, 12 | "n": 262144, 13 | "p": 1, 14 | "r": 8, 15 | "salt": "a40f35d9349c56d6515ab02e04e0e58a90d9551685b72700e6b80a4235007477" 16 | }, 17 | "mac": "9f4be0ed7757c85ee3e932c03bc3b8dcf4e3dde5027ad98253488c56223a23db" 18 | }, 19 | "id": "ae4c9631-0cbc-49ab-a561-20b8030d3a93", 20 | "version": 3 21 | } -------------------------------------------------------------------------------- /test/integration/migration/keystores/operator1.ecdsa.keystore.json: -------------------------------------------------------------------------------- 1 | { 2 | "address": "90f79bf6eb2c4f870365e785982e1f101e93b906", 3 | "crypto": { 4 | "cipher": "aes-128-ctr", 5 | "ciphertext": "cb9721b2bb12f67f63eadcbc42e7a2ac5721b03dbe50d07c723cd7693921b477", 6 | "cipherparams": { 7 | "iv": "d3395f83b964967800b3071195cee867" 8 | }, 9 | "kdf": "scrypt", 10 | "kdfparams": { 11 | "dklen": 32, 12 | "n": 262144, 13 | "p": 1, 14 | "r": 8, 15 | "salt": "0fe7be44e9d127275d57a0e841be14f0e722467d69136f28683c3a1ad8c98333" 16 | }, 17 | "mac": "d43b0060ac1e5a95f66fc99493bf7bb0c180eee12aadfe389e5706b8bda42c64" 18 | }, 19 | "id": "4df14231-53b3-49c4-8d29-823197cb217c", 20 | "version": 3 21 | } -------------------------------------------------------------------------------- /test/integration/migration/keystores/operator2.ecdsa.keystore.json: -------------------------------------------------------------------------------- 1 | { 2 | "address": "15d34aaf54267db7d7c367839aaf71a00a2c6a65", 3 | "crypto": { 4 | "cipher": "aes-128-ctr", 5 | "ciphertext": "e2376b84e186e148dbcaabd6ccce475b58e1ea0bf336dc44a7b431cda211a83e", 6 | "cipherparams": { 7 | "iv": "c94e317e7ff695f9aa637f6890b78d17" 8 | }, 9 | "kdf": "scrypt", 10 | "kdfparams": { 11 | "dklen": 32, 12 | "n": 262144, 13 | "p": 1, 14 | "r": 8, 15 | "salt": "6c642b7154147826278bb6d7b6fdbc8415fb2858641fa8a4edc4b0b8f550fa7f" 16 | }, 17 | "mac": "e8dc1ab58434fac3f76fc9dbf2ab0f720bbfa4e721e6763c9e982f55a5ef139a" 18 | }, 19 | "id": "c25256a8-bd0b-41b6-b432-323cd26db0fc", 20 | "version": 3 21 | } -------------------------------------------------------------------------------- /test/integration/migration/keystores/operator3.ecdsa.keystore.json: -------------------------------------------------------------------------------- 1 | { 2 | "address": "9965507d1a55bcc2695c58ba16fb37d819b0a4dc", 3 | "crypto": { 4 | "cipher": "aes-128-ctr", 5 | "ciphertext": "baedc9a338b8cfc18742e95bf76bb5344215d69a6978baf80d82c643e60b6de8", 6 | "cipherparams": { 7 | "iv": "ce362678c9a3252c25498910ab091d5c" 8 | }, 9 | "kdf": "scrypt", 10 | "kdfparams": { 11 | "dklen": 32, 12 | "n": 262144, 13 | "p": 1, 14 | "r": 8, 15 | "salt": "e47a6427f37f7a847e25fc15041ffc6bacbb2710d16b94f4e3b05309bd0938a0" 16 | }, 17 | "mac": "3a17346e4642a7f2b3eba2eff8e728998748ef21a93a2d6753630dcc1d970be3" 18 | }, 19 | "id": "926b6b31-871f-4095-8341-c252cd7f6109", 20 | "version": 3 21 | } -------------------------------------------------------------------------------- /test/integration/migration/keystores/operator4.ecdsa.keystore.json: -------------------------------------------------------------------------------- 1 | { 2 | "address": "976ea74026e726554db657fa54763abd0c3a0aa9", 3 | "crypto": { 4 | "cipher": "aes-128-ctr", 5 | "ciphertext": "6413921ec3a30c91e565883c466c064467498dc5eb311cf83d8fafaedf520298", 6 | "cipherparams": { 7 | "iv": "b2c27fd1ab886c523fbc1d2b9c935e06" 8 | }, 9 | "kdf": "scrypt", 10 | "kdfparams": { 11 | "dklen": 32, 12 | "n": 262144, 13 | "p": 1, 14 | "r": 8, 15 | "salt": "c71a9874630f71400434722c42d0ea68552f911f88cb1aa97dc0b72255f9a173" 16 | }, 17 | "mac": "2ae78f29e164723ea87a5b9b2d3dd9925f45e5883d0e165b30b87f13e6cf27ef" 18 | }, 19 | "id": "fa0d87ff-2f03-4648-a453-c302b2acc371", 20 | "version": 3 21 | } -------------------------------------------------------------------------------- /test/integration/migration/keystores/operator5.ecdsa.keystore.json: -------------------------------------------------------------------------------- 1 | { 2 | "address": "14dc79964da2c08b23698b3d3cc7ca32193d9955", 3 | "crypto": { 4 | "cipher": "aes-128-ctr", 5 | "ciphertext": "b02d5574fc9a2b813dd47b97a5ac51f84480dd22716f7bbfe72514a5f4b14176", 6 | "cipherparams": { 7 | "iv": "5dba5dc0c04577f5f9a4b5719258c317" 8 | }, 9 | "kdf": "scrypt", 10 | "kdfparams": { 11 | "dklen": 32, 12 | "n": 262144, 13 | "p": 1, 14 | "r": 8, 15 | "salt": "a40f35d9349c56d6515ab02e04e0e58a90d9551685b72700e6b80a4235007477" 16 | }, 17 | "mac": "9f4be0ed7757c85ee3e932c03bc3b8dcf4e3dde5027ad98253488c56223a23db" 18 | }, 19 | "id": "ae4c9631-0cbc-49ab-a561-20b8030d3a93", 20 | "version": 3 21 | } -------------------------------------------------------------------------------- /pkg/telemetry/noop.go: -------------------------------------------------------------------------------- 1 | package telemetry 2 | 3 | import "context" 4 | 5 | // NoopClient implements the Client interface with no-op methods 6 | type NoopClient struct{} 7 | 8 | // NewNoopClient creates a new no-op client 9 | func NewNoopClient() *NoopClient { 10 | return &NoopClient{} 11 | } 12 | 13 | // AddMetric implements the Client interface 14 | func (c *NoopClient) AddMetric(_ context.Context, _ Metric) error { 15 | return nil 16 | } 17 | 18 | // Close implements the Client interface 19 | func (c *NoopClient) Close() error { 20 | return nil 21 | } 22 | 23 | // IsNoopClient checks if the client is a NoopClient (disabled telemetry) 24 | func IsNoopClient(client Client) bool { 25 | _, isNoop := client.(*NoopClient) 26 | return isNoop 27 | } 28 | -------------------------------------------------------------------------------- /pkg/commands/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "fmt" 5 | "github.com/Layr-Labs/devkit-cli/internal/version" 6 | "github.com/Layr-Labs/devkit-cli/pkg/common" 7 | 8 | "github.com/urfave/cli/v2" 9 | ) 10 | 11 | // RunCommand defines the "run" command 12 | var VersionCommand = &cli.Command{ 13 | Name: "version", 14 | Usage: "Print the version of the devkit", 15 | Flags: append([]cli.Flag{}, common.GlobalFlags...), 16 | Action: func(cCtx *cli.Context) error { 17 | // Invoke and return AVSRun 18 | return VersionRun(cCtx) 19 | }, 20 | } 21 | 22 | func VersionRun(cCtx *cli.Context) error { 23 | v := version.GetVersion() 24 | commit := version.GetCommit() 25 | 26 | fmt.Printf("Version: %s\nCommit: %s\n", v, commit) 27 | 28 | return nil 29 | } 30 | -------------------------------------------------------------------------------- /docker/anvil/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | devkit-devnet-l1: 3 | image: ${FOUNDRY_IMAGE} 4 | container_name: ${L1_AVS_CONTAINER_NAME} 5 | entrypoint: anvil 6 | command: "--host 0.0.0.0 --fork-url ${L1_FORK_RPC_URL} --fork-block-number ${L1_FORK_BLOCK_NUMBER} ${L1_ANVIL_ARGS}" 7 | ports: 8 | - "${L1_DEVNET_PORT}:8545" 9 | extra_hosts: 10 | - "host.docker.internal:host-gateway" 11 | devkit-devnet-l2: 12 | image: ${FOUNDRY_IMAGE} 13 | container_name: ${L2_AVS_CONTAINER_NAME} 14 | entrypoint: anvil 15 | command: "--host 0.0.0.0 --fork-url ${L2_FORK_RPC_URL} --fork-block-number ${L2_FORK_BLOCK_NUMBER} ${L2_ANVIL_ARGS}" 16 | ports: 17 | - "${L2_DEVNET_PORT}:8545" 18 | extra_hosts: 19 | - "host.docker.internal:host-gateway" -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: ["**"] 9 | 10 | permissions: 11 | contents: read 12 | 13 | env: 14 | L1_FORK_URL: ${{ secrets.L1_FORK_URL }} 15 | L2_FORK_URL: ${{ secrets.L2_FORK_URL }} 16 | 17 | jobs: 18 | Test: 19 | name: Unit Test 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Checkout repo 23 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 24 | 25 | - name: Set up Go 26 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 27 | with: 28 | go-version: '1.23' 29 | 30 | - name: Run Tests 31 | run: | 32 | go mod tidy 33 | make tests 34 | -------------------------------------------------------------------------------- /config/configs/migrations/v0.0.2-v0.0.3.go: -------------------------------------------------------------------------------- 1 | package configMigrations 2 | 3 | import ( 4 | "github.com/Layr-Labs/devkit-cli/pkg/migration" 5 | 6 | "gopkg.in/yaml.v3" 7 | ) 8 | 9 | func Migration_0_0_2_to_0_0_3(user, old, new *yaml.Node) (*yaml.Node, error) { 10 | engine := migration.PatchEngine{ 11 | Old: old, 12 | New: new, 13 | User: user, 14 | Rules: []migration.PatchRule{ 15 | // Add template version that should be present (leave unchanged if different) 16 | {Path: []string{"config", "project", "templateLanguage"}, Condition: migration.IfUnchanged{}}, 17 | }, 18 | } 19 | err := engine.Apply() 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | // bump version node 25 | if v := migration.ResolveNode(user, []string{"version"}); v != nil { 26 | v.Value = "0.0.3" 27 | } 28 | return user, nil 29 | } 30 | -------------------------------------------------------------------------------- /.github/workflows/codeql-scanning.yaml: -------------------------------------------------------------------------------- 1 | name: "codeql-scanning" 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - "release/*" 8 | pull_request: 9 | schedule: 10 | - cron: "0 9 * * *" 11 | 12 | env: 13 | MISE_VERSION: 2024.12.14 14 | 15 | jobs: 16 | CodeQL-Scanning: 17 | runs-on: ubuntu-latest 18 | 19 | permissions: 20 | contents: read 21 | security-events: write 22 | pull-requests: read 23 | 24 | steps: 25 | - name: Checkout repository 26 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 4.2.2 27 | with: 28 | submodules: recursive 29 | 30 | - name: Run shared CodeQL scan 31 | uses: Layr-Labs/security-shared-workflows/actions/codeql-scans@418d735c1c4e5cc650c8addaeb8909b36b9dca27 32 | with: 33 | github-token: ${{ secrets.GITHUB_TOKEN }} 34 | 35 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: ["**"] 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | Lint: 15 | name: Lint 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout repo 19 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 20 | 21 | - name: Set up Go 22 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 23 | with: 24 | go-version: '1.23' 25 | 26 | - name: Clone hourglass-monorepo privately 27 | run: | 28 | go mod tidy 29 | 30 | - name: Run golangci-lint 31 | uses: golangci/golangci-lint-action@55c2c1448f86e01eaae002a5a3a9624417608d84 32 | with: 33 | version: latest 34 | args: --timeout 3m 35 | -------------------------------------------------------------------------------- /pkg/telemetry/telemetry.go: -------------------------------------------------------------------------------- 1 | package telemetry 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // Embedded devkit telemetry api key from release 8 | var embeddedTelemetryApiKey string 9 | 10 | // Client defines the interface for telemetry operations 11 | type Client interface { 12 | // AddMetric emits a single metric 13 | AddMetric(ctx context.Context, metric Metric) error 14 | // Close cleans up any resources 15 | Close() error 16 | } 17 | 18 | type clientContextKey struct{} 19 | 20 | // ContextWithClient returns a new context with the telemetry client 21 | func ContextWithClient(ctx context.Context, client Client) context.Context { 22 | return context.WithValue(ctx, clientContextKey{}, client) 23 | } 24 | 25 | // ClientFromContext retrieves the telemetry client from context 26 | func ClientFromContext(ctx context.Context) (Client, bool) { 27 | client, ok := ctx.Value(clientContextKey{}).(Client) 28 | return client, ok 29 | } 30 | -------------------------------------------------------------------------------- /config/contexts/migrations/v0.0.4-v0.0.5.go: -------------------------------------------------------------------------------- 1 | package contextMigrations 2 | 3 | import ( 4 | "github.com/Layr-Labs/devkit-cli/pkg/migration" 5 | 6 | "gopkg.in/yaml.v3" 7 | ) 8 | 9 | func Migration_0_0_4_to_0_0_5(user, old, new *yaml.Node) (*yaml.Node, error) { 10 | engine := migration.PatchEngine{} 11 | if err := engine.Apply(); err != nil { 12 | return nil, err 13 | } 14 | 15 | // Append comments+keys at bottom if missing 16 | migration.EnsureKeyWithComment(user, []string{"context", "deployed_contracts"}, "Contracts deployed on `devnet start`") 17 | migration.EnsureKeyWithComment(user, []string{"context", "operator_sets"}, "Operator Sets registered on `devnet start`") 18 | migration.EnsureKeyWithComment(user, []string{"context", "operator_registrations"}, "Operators registered on `devnet start`") 19 | 20 | // Upgrade the version 21 | if v := migration.ResolveNode(user, []string{"version"}); v != nil { 22 | v.Value = "0.0.5" 23 | } 24 | return user, nil 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/automation.yml: -------------------------------------------------------------------------------- 1 | name: PR 2 | 3 | on: 4 | pull_request: 5 | types: [opened, edited, synchronize] 6 | 7 | permissions: 8 | contents: read 9 | pull-requests: read 10 | 11 | jobs: 12 | lint-pr-title: 13 | runs-on: ubuntu-latest 14 | name: Title 15 | steps: 16 | - name: Fetch PR Title 17 | run: | 18 | PR_TITLE=$(jq -r '.pull_request.title' "$GITHUB_EVENT_PATH") 19 | echo "PR title: $PR_TITLE" 20 | 21 | # Define the valid pattern (supports conventional commit format) 22 | if [[ ! "$PR_TITLE" =~ ^(release|feat|fix|chore|docs|refactor|test|style|ci|perf)(\(.*?\))?:\ .* ]]; then 23 | echo "❌ Invalid PR title: '$PR_TITLE'" 24 | echo "Expected format: 'type: description' or 'type(scope): description'" 25 | echo "Allowed types: release, feat, fix, chore, docs, refactor, test, style, ci, perf." 26 | exit 1 27 | fi 28 | 29 | echo "✅ PR title is valid" -------------------------------------------------------------------------------- /pkg/commands/template/info.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "github.com/Layr-Labs/devkit-cli/pkg/common" 5 | "github.com/urfave/cli/v2" 6 | ) 7 | 8 | // InfoCommand defines the "template info" subcommand 9 | var InfoCommand = &cli.Command{ 10 | Name: "info", 11 | Usage: "Display information about the current project template", 12 | Action: func(cCtx *cli.Context) error { 13 | // Get logger 14 | logger := common.LoggerFromContext(cCtx) 15 | 16 | // Get template information 17 | projectName, templateBaseURL, templateVersion, templateLanguage, err := GetTemplateInfo() 18 | if err != nil { 19 | return err 20 | } 21 | 22 | // Display template information 23 | logger.Info("Project template information:") 24 | if projectName != "" { 25 | logger.Info(" Project name: %s", projectName) 26 | } 27 | logger.Info(" Template URL: %s", templateBaseURL) 28 | logger.Info(" Version: %s", templateVersion) 29 | logger.Info(" Language: %s", templateLanguage) 30 | 31 | return nil 32 | }, 33 | } 34 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 11 | 12 | **Motivation:** 13 | 16 | **Modifications:** 17 | 20 | **Result:** 21 | 24 | **Testing:** 25 | 28 | **Open questions:** 29 | -------------------------------------------------------------------------------- /docker/anvil/devnetembed.go: -------------------------------------------------------------------------------- 1 | package assets 2 | 3 | import ( 4 | _ "embed" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | ) 9 | 10 | //go:embed docker-compose.yaml 11 | var DockerCompose []byte 12 | 13 | // WriteDockerComposeToPath writes docker-compose.yaml to a fixed path. 14 | func WriteDockerComposeToPath() (string, error) { 15 | // Get project's absolute path 16 | absProjectPath, err := os.Getwd() 17 | if err != nil { 18 | return "", fmt.Errorf("failed to get current working directory: %w", err) 19 | } 20 | // Store anvils docker-compose.yaml in devnet dir at project root 21 | dir := filepath.Join(absProjectPath, "devnet") 22 | if err := os.MkdirAll(dir, 0o755); err != nil { 23 | return "", fmt.Errorf("failed to create %s: %w", dir, err) 24 | } 25 | // Write embed each devnet start to ensure any changes are propagated 26 | path := filepath.Join(dir, "docker-compose.yaml") 27 | if err = os.WriteFile(path, DockerCompose, 0o644); err != nil { 28 | return "", fmt.Errorf("failed to write %s: %w", path, err) 29 | } 30 | return path, nil 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Layr-Labs 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 | -------------------------------------------------------------------------------- /config/keystores/operator1.bls.keystore.json: -------------------------------------------------------------------------------- 1 | { 2 | "crypto": { 3 | "kdf": { 4 | "function": "scrypt", 5 | "params": { 6 | "dklen": 32, 7 | "n": 262144, 8 | "p": 1, 9 | "r": 8, 10 | "salt": "d6f87d41f3e579f5c01842c97bd06a8d3ed2bbb927f1af0b0b84597ba8973462" 11 | }, 12 | "message": "" 13 | }, 14 | "checksum": { 15 | "function": "sha256", 16 | "params": {}, 17 | "message": "edf33551b6dfcbcb9c1fd284b3df211121992694c896d07a6aa270f08bfad691" 18 | }, 19 | "cipher": { 20 | "function": "aes-128-ctr", 21 | "params": { 22 | "iv": "f78c4113516550a1166f631bd17f17a5" 23 | }, 24 | "message": "bc833e7f5d91f70fc1f452b19648b6c49976fe93d394789047ecc6ece6006fa6df0a4e63c885f26d514af5171d8c6c32ad5286be3ac6867df42921eca2ddd97a" 25 | } 26 | }, 27 | "pubkey": "19fb7a235dbb9d09a549bde919fd738ad9eea8829462f90b0cb1d305497509fb2b1cb5c63fc2225c4e014ae056be73c568c34bbeadf37eaf019aec13e11f7e93136694c1eefb9b8853185ad57abb356edd4a0e8187dc35297a1adc26d05c6d062d64523c7380de3a4b9607035e5789544b453c408564b6ae57ee4ebb20c5f38e", 28 | "path": "m/1/0/0", 29 | "uuid": "64e38071-7634-496e-b10a-89cdf59ac3ab", 30 | "version": 4, 31 | "curveType": "bn254" 32 | } -------------------------------------------------------------------------------- /config/keystores/operator2.bls.keystore.json: -------------------------------------------------------------------------------- 1 | { 2 | "crypto": { 3 | "kdf": { 4 | "function": "scrypt", 5 | "params": { 6 | "dklen": 32, 7 | "n": 262144, 8 | "p": 1, 9 | "r": 8, 10 | "salt": "943edb5c49e538b9e7707e87abda46ad5960c0568f465c9eb289b6d56eee50cf" 11 | }, 12 | "message": "" 13 | }, 14 | "checksum": { 15 | "function": "sha256", 16 | "params": {}, 17 | "message": "a2d841d2182ed96d7d5f3fc663109c77b692229ea12cacfa9a1f569191360f39" 18 | }, 19 | "cipher": { 20 | "function": "aes-128-ctr", 21 | "params": { 22 | "iv": "f113dda8133c9640ec7a08d21d5f8629" 23 | }, 24 | "message": "6b7ec13bbe3091326f37b2a801a5d8b5c61c5d4214d1d75d944781ed68bc214f257e3cf9fc7daa9752d68c92e30cc106c35ff385af0c0b88a2fd1c84e9750957" 25 | } 26 | }, 27 | "pubkey": "22585e9dde848ba29e014c9a2c8ab2e398bd9a0d136c2090d0c397f5175f489d0585466f0a0c2a5085879675dd345ad8602041c5b1aa14bf532060e62d595c610e82952b99729adb02023985f89973c29f115b4648d4d0a21c1c32a76027d6151407aaed9a860d41e610db40bfe89f47e022716f663a50c1a0b131eab581495e", 28 | "path": "m/1/0/0", 29 | "uuid": "d9bee745-5bb4-4946-a1c0-b6183a17138c", 30 | "version": 4, 31 | "curveType": "bn254" 32 | } -------------------------------------------------------------------------------- /test/integration/migration/keystores/operator1.bls.keystore.json: -------------------------------------------------------------------------------- 1 | { 2 | "crypto": { 3 | "kdf": { 4 | "function": "scrypt", 5 | "params": { 6 | "dklen": 32, 7 | "n": 262144, 8 | "p": 1, 9 | "r": 8, 10 | "salt": "d6f87d41f3e579f5c01842c97bd06a8d3ed2bbb927f1af0b0b84597ba8973462" 11 | }, 12 | "message": "" 13 | }, 14 | "checksum": { 15 | "function": "sha256", 16 | "params": {}, 17 | "message": "edf33551b6dfcbcb9c1fd284b3df211121992694c896d07a6aa270f08bfad691" 18 | }, 19 | "cipher": { 20 | "function": "aes-128-ctr", 21 | "params": { 22 | "iv": "f78c4113516550a1166f631bd17f17a5" 23 | }, 24 | "message": "bc833e7f5d91f70fc1f452b19648b6c49976fe93d394789047ecc6ece6006fa6df0a4e63c885f26d514af5171d8c6c32ad5286be3ac6867df42921eca2ddd97a" 25 | } 26 | }, 27 | "pubkey": "19fb7a235dbb9d09a549bde919fd738ad9eea8829462f90b0cb1d305497509fb2b1cb5c63fc2225c4e014ae056be73c568c34bbeadf37eaf019aec13e11f7e93136694c1eefb9b8853185ad57abb356edd4a0e8187dc35297a1adc26d05c6d062d64523c7380de3a4b9607035e5789544b453c408564b6ae57ee4ebb20c5f38e", 28 | "path": "m/1/0/0", 29 | "uuid": "64e38071-7634-496e-b10a-89cdf59ac3ab", 30 | "version": 4, 31 | "curveType": "bn254" 32 | } -------------------------------------------------------------------------------- /test/integration/migration/keystores/operator2.bls.keystore.json: -------------------------------------------------------------------------------- 1 | { 2 | "crypto": { 3 | "kdf": { 4 | "function": "scrypt", 5 | "params": { 6 | "dklen": 32, 7 | "n": 262144, 8 | "p": 1, 9 | "r": 8, 10 | "salt": "943edb5c49e538b9e7707e87abda46ad5960c0568f465c9eb289b6d56eee50cf" 11 | }, 12 | "message": "" 13 | }, 14 | "checksum": { 15 | "function": "sha256", 16 | "params": {}, 17 | "message": "a2d841d2182ed96d7d5f3fc663109c77b692229ea12cacfa9a1f569191360f39" 18 | }, 19 | "cipher": { 20 | "function": "aes-128-ctr", 21 | "params": { 22 | "iv": "f113dda8133c9640ec7a08d21d5f8629" 23 | }, 24 | "message": "6b7ec13bbe3091326f37b2a801a5d8b5c61c5d4214d1d75d944781ed68bc214f257e3cf9fc7daa9752d68c92e30cc106c35ff385af0c0b88a2fd1c84e9750957" 25 | } 26 | }, 27 | "pubkey": "22585e9dde848ba29e014c9a2c8ab2e398bd9a0d136c2090d0c397f5175f489d0585466f0a0c2a5085879675dd345ad8602041c5b1aa14bf532060e62d595c610e82952b99729adb02023985f89973c29f115b4648d4d0a21c1c32a76027d6151407aaed9a860d41e610db40bfe89f47e022716f663a50c1a0b131eab581495e", 28 | "path": "m/1/0/0", 29 | "uuid": "d9bee745-5bb4-4946-a1c0-b6183a17138c", 30 | "version": 4, 31 | "curveType": "bn254" 32 | } -------------------------------------------------------------------------------- /config/keystores/operator3.bls.keystore.json: -------------------------------------------------------------------------------- 1 | { 2 | "crypto": { 3 | "kdf": { 4 | "function": "scrypt", 5 | "params": { 6 | "dklen": 32, 7 | "n": 262144, 8 | "p": 1, 9 | "r": 8, 10 | "salt": "c9c35c33db1d672a5be63cc2006ee1d5c34c747c068b4cd41f1b3360b412939f" 11 | }, 12 | "message": "" 13 | }, 14 | "checksum": { 15 | "function": "sha256", 16 | "params": {}, 17 | "message": "3b0156ae5750c7d6c343c0158199be41f7cc1d9fe5924473baec0b973c906eea" 18 | }, 19 | "cipher": { 20 | "function": "aes-128-ctr", 21 | "params": { 22 | "iv": "0ee49004c22d1fafd963bb0bc4410e17" 23 | }, 24 | "message": "6b6c27896adca59d564d550f04ffbd5d637a9ac05381db68e1d4d3a90547399ae58a39dbb8e8b4257c2fa8a3b11a1bbdf60f35d5faf120ad08fe7d986cc4dc0cb2bc2746621e7449cff6d2a7" 25 | } 26 | }, 27 | "pubkey": "1726a24de8eb80b5cb2d94292b6c24bfd9f1277dbc9b98cbb8e12b4f59e3ea73127ffcedd665f59c0c111656c6228d52c201c912bdd6f884d3ad1a3bba1a7729102769c000aa16536d6b815b2073a918e9762a8d5d7dd870c0e1fd2eac72a3ec0ddd6d631960605d114037e2e6d994be47ec76769184e7128fd75a3f3465f7a2", 28 | "path": "m/1/0/0", 29 | "uuid": "57ec71d0-d208-4930-b63e-ec97f5ecc485", 30 | "version": 4, 31 | "curveType": "bn254" 32 | } -------------------------------------------------------------------------------- /config/keystores/operator4.bls.keystore.json: -------------------------------------------------------------------------------- 1 | { 2 | "crypto": { 3 | "kdf": { 4 | "function": "scrypt", 5 | "params": { 6 | "dklen": 32, 7 | "n": 262144, 8 | "p": 1, 9 | "r": 8, 10 | "salt": "4c7e1940ab39b77dd3cc5fdd46c528ce693ad5e92db6a039c645269d08f924c8" 11 | }, 12 | "message": "" 13 | }, 14 | "checksum": { 15 | "function": "sha256", 16 | "params": {}, 17 | "message": "9d5287ea5fa4dbf637fbc4bf4594b5032fc31fc8e2d52a7aa1f6b0f4be9e09c0" 18 | }, 19 | "cipher": { 20 | "function": "aes-128-ctr", 21 | "params": { 22 | "iv": "0079914f2bb7cc5896bd3031c9fcec2f" 23 | }, 24 | "message": "0e5017c6d05d4c585014e93a66f18ca2ce30923e78edab3a104af814388ebc9d732b58b1842e20201545cf064bcb96bb4517888af0d2eea65f2c07bcb7bce00b0a3b686d34d29e919c15ad9af8" 25 | } 26 | }, 27 | "pubkey": "047d85621de07791c99239575ed4bff825ddb0db93c778f828106177a23d9a801fce3590e2d5857514031b27aab45531fbbdfb545bf599a8b164ccf8ba5e23761e33fdebecf7503dc92cb2ddd4ac53b353a7ec0ba746b8156ffc806acad9eac6088069e7f46c7640ffa68875030bb7c12c80dab810fe9da89a5bcd6cb2e776ed", 28 | "path": "m/1/0/0", 29 | "uuid": "bb1cb479-acbe-46ba-a0bc-8628ac90a57d", 30 | "version": 4, 31 | "curveType": "bn254" 32 | } -------------------------------------------------------------------------------- /config/keystores/operator5.bls.keystore.json: -------------------------------------------------------------------------------- 1 | { 2 | "crypto": { 3 | "kdf": { 4 | "function": "scrypt", 5 | "params": { 6 | "dklen": 32, 7 | "n": 262144, 8 | "p": 1, 9 | "r": 8, 10 | "salt": "6a490e96747d7910f4b1676339a84008050acb42e1e17c995e32a14ff9623aca" 11 | }, 12 | "message": "" 13 | }, 14 | "checksum": { 15 | "function": "sha256", 16 | "params": {}, 17 | "message": "8e4ab6c381324ca9dc3d42450cd4d15112d62ae93d8e101cb6817974d4aea64f" 18 | }, 19 | "cipher": { 20 | "function": "aes-128-ctr", 21 | "params": { 22 | "iv": "088201eb8b542a54f03cdce955bd51d0" 23 | }, 24 | "message": "2d0ffbf4e3977a69d482aa48b9759bcdce1a7fa9a7c457842d0b7a11e2d08a6b399a9b210455709eb111c18bfc269a4eb71f9ac6b2d6f2d472e825723af98219287f58f4dda7eb9e3a9a382e72" 25 | } 26 | }, 27 | "pubkey": "2c924049048027681f78ccea164c502a480cb56df3d902bc25a1953828d5e3251678c81589fc694871d52b9dcbdda6179d7ef933eba0bee7348ec0f47ee3c6760eeb2bef80df113ef65facce1fc37fb184f37dc02c37b2f15768c4ddf6566d87302c07c98c6e3627f7bbd9d3cb7fa0939217ebe07fdac79e69c6cc51035159bb", 28 | "path": "m/1/0/0", 29 | "uuid": "53fa8abe-f23b-475a-b6fe-57647711937a", 30 | "version": 4, 31 | "curveType": "bn254" 32 | } -------------------------------------------------------------------------------- /test/integration/migration/keystores/operator3.bls.keystore.json: -------------------------------------------------------------------------------- 1 | { 2 | "crypto": { 3 | "kdf": { 4 | "function": "scrypt", 5 | "params": { 6 | "dklen": 32, 7 | "n": 262144, 8 | "p": 1, 9 | "r": 8, 10 | "salt": "c9c35c33db1d672a5be63cc2006ee1d5c34c747c068b4cd41f1b3360b412939f" 11 | }, 12 | "message": "" 13 | }, 14 | "checksum": { 15 | "function": "sha256", 16 | "params": {}, 17 | "message": "3b0156ae5750c7d6c343c0158199be41f7cc1d9fe5924473baec0b973c906eea" 18 | }, 19 | "cipher": { 20 | "function": "aes-128-ctr", 21 | "params": { 22 | "iv": "0ee49004c22d1fafd963bb0bc4410e17" 23 | }, 24 | "message": "6b6c27896adca59d564d550f04ffbd5d637a9ac05381db68e1d4d3a90547399ae58a39dbb8e8b4257c2fa8a3b11a1bbdf60f35d5faf120ad08fe7d986cc4dc0cb2bc2746621e7449cff6d2a7" 25 | } 26 | }, 27 | "pubkey": "1726a24de8eb80b5cb2d94292b6c24bfd9f1277dbc9b98cbb8e12b4f59e3ea73127ffcedd665f59c0c111656c6228d52c201c912bdd6f884d3ad1a3bba1a7729102769c000aa16536d6b815b2073a918e9762a8d5d7dd870c0e1fd2eac72a3ec0ddd6d631960605d114037e2e6d994be47ec76769184e7128fd75a3f3465f7a2", 28 | "path": "m/1/0/0", 29 | "uuid": "57ec71d0-d208-4930-b63e-ec97f5ecc485", 30 | "version": 4, 31 | "curveType": "bn254" 32 | } -------------------------------------------------------------------------------- /test/integration/migration/keystores/operator4.bls.keystore.json: -------------------------------------------------------------------------------- 1 | { 2 | "crypto": { 3 | "kdf": { 4 | "function": "scrypt", 5 | "params": { 6 | "dklen": 32, 7 | "n": 262144, 8 | "p": 1, 9 | "r": 8, 10 | "salt": "4c7e1940ab39b77dd3cc5fdd46c528ce693ad5e92db6a039c645269d08f924c8" 11 | }, 12 | "message": "" 13 | }, 14 | "checksum": { 15 | "function": "sha256", 16 | "params": {}, 17 | "message": "9d5287ea5fa4dbf637fbc4bf4594b5032fc31fc8e2d52a7aa1f6b0f4be9e09c0" 18 | }, 19 | "cipher": { 20 | "function": "aes-128-ctr", 21 | "params": { 22 | "iv": "0079914f2bb7cc5896bd3031c9fcec2f" 23 | }, 24 | "message": "0e5017c6d05d4c585014e93a66f18ca2ce30923e78edab3a104af814388ebc9d732b58b1842e20201545cf064bcb96bb4517888af0d2eea65f2c07bcb7bce00b0a3b686d34d29e919c15ad9af8" 25 | } 26 | }, 27 | "pubkey": "047d85621de07791c99239575ed4bff825ddb0db93c778f828106177a23d9a801fce3590e2d5857514031b27aab45531fbbdfb545bf599a8b164ccf8ba5e23761e33fdebecf7503dc92cb2ddd4ac53b353a7ec0ba746b8156ffc806acad9eac6088069e7f46c7640ffa68875030bb7c12c80dab810fe9da89a5bcd6cb2e776ed", 28 | "path": "m/1/0/0", 29 | "uuid": "bb1cb479-acbe-46ba-a0bc-8628ac90a57d", 30 | "version": 4, 31 | "curveType": "bn254" 32 | } -------------------------------------------------------------------------------- /test/integration/migration/keystores/operator5.bls.keystore.json: -------------------------------------------------------------------------------- 1 | { 2 | "crypto": { 3 | "kdf": { 4 | "function": "scrypt", 5 | "params": { 6 | "dklen": 32, 7 | "n": 262144, 8 | "p": 1, 9 | "r": 8, 10 | "salt": "6a490e96747d7910f4b1676339a84008050acb42e1e17c995e32a14ff9623aca" 11 | }, 12 | "message": "" 13 | }, 14 | "checksum": { 15 | "function": "sha256", 16 | "params": {}, 17 | "message": "8e4ab6c381324ca9dc3d42450cd4d15112d62ae93d8e101cb6817974d4aea64f" 18 | }, 19 | "cipher": { 20 | "function": "aes-128-ctr", 21 | "params": { 22 | "iv": "088201eb8b542a54f03cdce955bd51d0" 23 | }, 24 | "message": "2d0ffbf4e3977a69d482aa48b9759bcdce1a7fa9a7c457842d0b7a11e2d08a6b399a9b210455709eb111c18bfc269a4eb71f9ac6b2d6f2d472e825723af98219287f58f4dda7eb9e3a9a382e72" 25 | } 26 | }, 27 | "pubkey": "2c924049048027681f78ccea164c502a480cb56df3d902bc25a1953828d5e3251678c81589fc694871d52b9dcbdda6179d7ef933eba0bee7348ec0f47ee3c6760eeb2bef80df113ef65facce1fc37fb184f37dc02c37b2f15768c4ddf6566d87302c07c98c6e3627f7bbd9d3cb7fa0939217ebe07fdac79e69c6cc51035159bb", 28 | "path": "m/1/0/0", 29 | "uuid": "53fa8abe-f23b-475a-b6fe-57647711937a", 30 | "version": 4, 31 | "curveType": "bn254" 32 | } -------------------------------------------------------------------------------- /config/configs/migrations/v0.0.1-v0.0.2.go: -------------------------------------------------------------------------------- 1 | package configMigrations 2 | 3 | import ( 4 | "github.com/Layr-Labs/devkit-cli/pkg/migration" 5 | 6 | "gopkg.in/yaml.v3" 7 | ) 8 | 9 | func Migration_0_0_1_to_0_0_2(user, old, new *yaml.Node) (*yaml.Node, error) { 10 | engine := migration.PatchEngine{ 11 | Old: old, 12 | New: new, 13 | User: user, 14 | Rules: []migration.PatchRule{ 15 | // Add project_uuid field (empty string by default) 16 | {Path: []string{"config", "project", "project_uuid"}, Condition: migration.Always{}}, 17 | // Add telemetry_enabled field (false by default) 18 | {Path: []string{"config", "project", "telemetry_enabled"}, Condition: migration.Always{}}, 19 | // Add template baseUrl that should be present (leave unchanged if different) 20 | {Path: []string{"config", "project", "templateBaseUrl"}, Condition: migration.IfUnchanged{}}, 21 | // Add template version that should be present (leave unchanged if different) 22 | {Path: []string{"config", "project", "templateVersion"}, Condition: migration.IfUnchanged{}}, 23 | }, 24 | } 25 | err := engine.Apply() 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | // bump version node 31 | if v := migration.ResolveNode(user, []string{"version"}); v != nil { 32 | v.Value = "0.0.2" 33 | } 34 | return user, nil 35 | } 36 | -------------------------------------------------------------------------------- /config/contexts/migrations/v0.1.0-v0.1.1.go: -------------------------------------------------------------------------------- 1 | package contextMigrations 2 | 3 | import ( 4 | "github.com/Layr-Labs/devkit-cli/pkg/migration" 5 | "gopkg.in/yaml.v3" 6 | ) 7 | 8 | func Migration_0_1_0_to_0_1_1(user, old, new *yaml.Node) (*yaml.Node, error) { 9 | // Update fork block heights to match ponos project 10 | engine := migration.PatchEngine{ 11 | Old: old, 12 | New: new, 13 | User: user, 14 | Rules: []migration.PatchRule{ 15 | // Update L1 fork block 16 | { 17 | Path: []string{"context", "chains", "l1", "fork", "block"}, 18 | Condition: migration.Always{}, 19 | Transform: func(_ *yaml.Node) *yaml.Node { 20 | return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: "9259079"} 21 | }, 22 | }, 23 | // Update L2 fork block 24 | { 25 | Path: []string{"context", "chains", "l2", "fork", "block"}, 26 | Condition: migration.Always{}, 27 | Transform: func(_ *yaml.Node) *yaml.Node { 28 | return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: "31408197"} 29 | }, 30 | }, 31 | }, 32 | } 33 | 34 | if err := engine.Apply(); err != nil { 35 | return nil, err 36 | } 37 | 38 | // Upgrade the version 39 | if v := migration.ResolveNode(user, []string{"version"}); v != nil { 40 | v.Value = "0.1.1" 41 | } 42 | 43 | return user, nil 44 | } 45 | -------------------------------------------------------------------------------- /pkg/common/logger/progress_logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "github.com/Layr-Labs/devkit-cli/pkg/common/iface" 5 | ) 6 | 7 | type ProgressLogger struct { 8 | base iface.Logger // core Zap logger 9 | tracker iface.ProgressTracker // TTY or Log tracker 10 | } 11 | 12 | func NewProgressLogger(base iface.Logger, tracker iface.ProgressTracker) *ProgressLogger { 13 | return &ProgressLogger{ 14 | base: base, 15 | tracker: tracker, 16 | } 17 | } 18 | 19 | func (p *ProgressLogger) ProgressRows() []iface.ProgressRow { 20 | return p.tracker.ProgressRows() 21 | } 22 | 23 | func (p *ProgressLogger) Title(msg string, args ...any) { 24 | p.base.Title(msg, args...) 25 | } 26 | 27 | func (p *ProgressLogger) Info(msg string, args ...any) { 28 | p.base.Info(msg, args...) 29 | } 30 | 31 | func (p *ProgressLogger) Warn(msg string, args ...any) { 32 | p.base.Warn(msg, args...) 33 | } 34 | 35 | func (p *ProgressLogger) Error(msg string, args ...any) { 36 | p.base.Error(msg, args...) 37 | } 38 | 39 | func (p *ProgressLogger) SetProgress(name string, percent int, displayText string) { 40 | p.tracker.Set(name, percent, displayText) 41 | } 42 | 43 | func (p *ProgressLogger) PrintProgress() { 44 | p.tracker.Render() 45 | } 46 | 47 | func (p *ProgressLogger) ClearProgress() { 48 | p.tracker.Clear() 49 | } 50 | -------------------------------------------------------------------------------- /pkg/common/logger/zap_logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "go.uber.org/zap" 8 | ) 9 | 10 | type ZapLogger struct { 11 | log *zap.SugaredLogger 12 | } 13 | 14 | func NewZapLogger(verbose bool) *ZapLogger { 15 | var logger *zap.Logger 16 | 17 | if verbose { 18 | logger, _ = zap.NewDevelopment() 19 | } else { 20 | logger, _ = zap.NewProduction() 21 | } 22 | 23 | return &ZapLogger{log: logger.Sugar()} 24 | } 25 | 26 | func (l *ZapLogger) Title(msg string, args ...any) { 27 | // format the message once 28 | formatted := fmt.Sprintf("\n"+msg+"\n", args...) 29 | 30 | // split into lines 31 | lines := strings.Split(formatted, "\n") 32 | 33 | // print the lines with log 34 | for _, line := range lines { 35 | l.log.Infof("%s", line) 36 | } 37 | } 38 | 39 | func (l *ZapLogger) Info(msg string, args ...any) { 40 | msg = strings.Trim(msg, "\n") 41 | if msg == "" { 42 | return 43 | } 44 | l.log.Infof(msg, args...) 45 | } 46 | 47 | func (l *ZapLogger) Warn(msg string, args ...any) { 48 | msg = strings.Trim(msg, "\n") 49 | if msg == "" { 50 | return 51 | } 52 | l.log.Warnf(msg, args...) 53 | } 54 | 55 | func (l *ZapLogger) Error(msg string, args ...any) { 56 | msg = strings.Trim(msg, "\n") 57 | if msg == "" { 58 | return 59 | } 60 | l.log.Errorf(msg, args...) 61 | } 62 | 63 | func (l *ZapLogger) Debug(msg string, args ...any) { 64 | msg = strings.Trim(msg, "\n") 65 | if msg == "" { 66 | return 67 | } 68 | l.log.Debugf(msg, args...) 69 | } 70 | -------------------------------------------------------------------------------- /.github/workflows/create.yml: -------------------------------------------------------------------------------- 1 | name: Devkit AVS Create Test 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: ["**"] 8 | 9 | permissions: 10 | contents: read 11 | 12 | env: 13 | FOUNDRY_PROFILE: ci 14 | 15 | jobs: 16 | create-avs: 17 | strategy: 18 | fail-fast: true 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 23 | with: 24 | submodules: recursive 25 | 26 | - name: Set up Go 27 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 28 | with: 29 | go-version: "1.24" 30 | 31 | - name: Install Foundry 32 | uses: foundry-rs/foundry-toolchain@82dee4ba654bd2146511f85f0d013af94670c4de # v1.4.0 33 | with: 34 | version: stable 35 | 36 | - name: Install devkit CLI 37 | run: make install 38 | 39 | - name: Add ~/bin to PATH 40 | run: echo "$HOME/bin" >> $GITHUB_PATH 41 | 42 | - name: Run devkit avs create 43 | run: | 44 | devkit avs create my-awesome-avs 45 | 46 | - name: Verify AVS project created 47 | run: | 48 | if [ ! -f "./my-awesome-avs/config/config.yaml" ]; then 49 | echo "❌ AVS project config/config.yaml not found!" 50 | exit 1 51 | fi 52 | echo "✅ AVS project created successfully at ${GITHUB_WORKSPACE}/my-awesome-avs/" 53 | -------------------------------------------------------------------------------- /pkg/template/git_fetcher.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/Layr-Labs/devkit-cli/pkg/common/logger" 8 | ) 9 | 10 | // GitFetcherConfig holds options; we only care about Verbose here 11 | type GitFetcherConfig struct { 12 | Verbose bool 13 | } 14 | 15 | // TODO: implement metric transport 16 | type GitMetrics interface { 17 | CloneStarted(repo string) 18 | CloneFinished(repo string, err error) 19 | } 20 | 21 | // GitFetcher wraps clone with metrics and reporting 22 | type GitFetcher struct { 23 | Client *GitClient 24 | Metrics GitMetrics 25 | Config GitFetcherConfig 26 | Logger logger.ProgressLogger 27 | } 28 | 29 | func (f *GitFetcher) Fetch(ctx context.Context, repoURL, ref, targetDir string) error { 30 | if repoURL == "" { 31 | return fmt.Errorf("repoURL is required") 32 | } 33 | 34 | // Print job initiation 35 | f.Logger.Info("\nCloning repo: %s → %s\n\n", repoURL, targetDir) 36 | 37 | // Report to metrics 38 | if f.Metrics != nil { 39 | f.Metrics.CloneStarted(repoURL) 40 | } 41 | 42 | // Build a reporter that knows how to drive our ProgressLogger 43 | var reporter Reporter 44 | if !f.Config.Verbose { 45 | reporter = NewCloneReporter(repoURL, f.Logger, f.Metrics) 46 | } 47 | 48 | // Initiate clone 49 | err := f.Client.Clone(ctx, repoURL, ref, targetDir, f.Config, reporter) 50 | if err != nil { 51 | return fmt.Errorf("clone failed: %w", err) 52 | } 53 | 54 | // Print job completion 55 | f.Logger.Info("Clone repo complete: %s\n\n", repoURL) 56 | return nil 57 | } 58 | -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: Checks 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: ["**"] 9 | workflow_dispatch: 10 | 11 | permissions: 12 | contents: read 13 | pull-requests: read 14 | 15 | concurrency: 16 | group: checks-${{ github.workflow }}-${{ github.ref }} 17 | cancel-in-progress: true 18 | 19 | env: 20 | CLICOLOR: 1 21 | 22 | jobs: 23 | typos: 24 | name: Typo Linting 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 28 | - uses: crate-ci/typos@51f257b946f503b768e522781f56e9b7b5570d48 # v1.29.7 29 | with: 30 | config: .github/configs/typos-cli.toml 31 | 32 | check-make-fmt: 33 | name: Check make fmt 34 | runs-on: ubuntu-latest 35 | steps: 36 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 37 | 38 | - name: Setup Go 39 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 40 | with: 41 | go-version: "1.23" 42 | cache: true 43 | 44 | - name: Run make fmt 45 | run: make fmt 46 | 47 | - name: Check for formatting diffs 48 | run: | 49 | if [ -n "$(git status --porcelain)" ]; then 50 | echo "::error::make fmt generated changes; please run 'make fmt' and commit the results." 51 | git diff 52 | exit 1 53 | fi 54 | -------------------------------------------------------------------------------- /.github/configs/typos-cli.toml: -------------------------------------------------------------------------------- 1 | [files] 2 | extend-exclude = [ 3 | "go.mod", 4 | "go.sum", 5 | "**/lib/**", 6 | "**/docs/images/**", 7 | # Not present locally, but is in remote (github). 8 | "**/doc/**" 9 | ] 10 | ignore-hidden = true 11 | ignore-files = true 12 | ignore-dot = true 13 | ignore-vcs = true 14 | ignore-global = true 15 | ignore-parent = true 16 | 17 | [default] 18 | binary = false 19 | check-filename = true 20 | check-file = true 21 | unicode = true 22 | ignore-hex = true 23 | identifier-leading-digits = false 24 | locale = "en" 25 | extend-ignore-identifiers-re = [] 26 | extend-ignore-words-re = [] 27 | extend-ignore-re = [] 28 | 29 | [default.extend-identifiers] 30 | 31 | # Weird syntax, but this how you ignore corrections for certain words. 32 | [default.extend-words] 33 | strat = "strat" 34 | froms = "froms" 35 | 36 | [type.go] 37 | extend-glob = [] 38 | extend-ignore-identifiers-re = [] 39 | extend-ignore-words-re = [] 40 | extend-ignore-re = [] 41 | 42 | [type.go.extend-identifiers] 43 | flate = "flate" 44 | 45 | [type.go.extend-words] 46 | 47 | [type.sh] 48 | extend-glob = [] 49 | extend-ignore-identifiers-re = [] 50 | extend-ignore-words-re = [] 51 | extend-ignore-re = [] 52 | 53 | [type.sh.extend-identifiers] 54 | ot = "ot" 55 | stap = "stap" 56 | 57 | [type.sh.extend-words] 58 | 59 | [type.py] 60 | extend-glob = [] 61 | extend-ignore-identifiers-re = [] 62 | extend-ignore-words-re = [] 63 | extend-ignore-re = [] 64 | 65 | [type.py.extend-identifiers] 66 | NDArray = "NDArray" 67 | arange = "arange" 68 | EOFError = "EOFError" 69 | 70 | [type.py.extend-words] -------------------------------------------------------------------------------- /pkg/commands/deploy.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "github.com/Layr-Labs/devkit-cli/pkg/common" 5 | "github.com/urfave/cli/v2" 6 | ) 7 | 8 | // DeployCommand defines the "deploy" command 9 | var DeployCommand = &cli.Command{ 10 | Name: "deploy", 11 | Usage: "Deploy AVS components to specified network", 12 | Subcommands: []*cli.Command{ 13 | { 14 | Name: "contracts", 15 | Usage: "Deploy contracts to specified network", 16 | Subcommands: []*cli.Command{ 17 | { 18 | Name: "l1", 19 | Usage: "Deploy L1 contracts to specified network", 20 | Flags: append([]cli.Flag{ 21 | &cli.StringFlag{ 22 | Name: "context", 23 | Usage: "Select the context to use in this command (devnet, testnet or mainnet)", 24 | }, 25 | &cli.BoolFlag{ 26 | Name: "skip-setup", 27 | Usage: "Skip AVS setup steps (metadata update, registrar setup, etc.) after contract deployment", 28 | Value: false, 29 | }, 30 | &cli.BoolFlag{ 31 | Name: "use-zeus", 32 | Usage: "Use Zeus CLI to fetch l1(*) and l2(*) core addresses", 33 | Value: true, 34 | }, 35 | }, common.GlobalFlags...), 36 | Action: StartDeployL1Action, 37 | }, 38 | { 39 | Name: "l2", 40 | Usage: "Deploy L2 contracts to specified network", 41 | Flags: append([]cli.Flag{ 42 | &cli.StringFlag{ 43 | Name: "context", 44 | Usage: "Select the context to use in this command (devnet, testnet or mainnet)", 45 | }, 46 | }, common.GlobalFlags...), 47 | Action: StartDeployL2Action, 48 | }, 49 | }, 50 | }, 51 | }, 52 | } 53 | -------------------------------------------------------------------------------- /pkg/common/user_config.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "gopkg.in/yaml.v3" 9 | ) 10 | 11 | // SaveUserId saves user settings to the global config, but preserves existing UUID if present 12 | func SaveUserId(userUuid string) error { 13 | // Try to load existing settings first to preserve UUID if it exists 14 | var settings GlobalConfig 15 | existingSettings, err := LoadGlobalConfig() 16 | if err == nil && existingSettings != nil { 17 | settings = *existingSettings 18 | if settings.UserUUID == "" { 19 | settings.UserUUID = userUuid 20 | } 21 | } else { 22 | // Create new settings with provided UUID 23 | settings = GlobalConfig{ 24 | FirstRun: true, 25 | UserUUID: userUuid, 26 | } 27 | } 28 | 29 | data, err := yaml.Marshal(settings) 30 | if err != nil { 31 | return fmt.Errorf("failed to marshal settings: %w", err) 32 | } 33 | 34 | // Get the global config dir so that we can create it 35 | globalConfigDir, err := GetGlobalConfigDir() 36 | if err != nil { 37 | return err 38 | } 39 | 40 | // Create global dir 41 | if err := os.MkdirAll(globalConfigDir, 0755); err != nil { 42 | return fmt.Errorf("failed to create project directory: %w", err) 43 | } 44 | 45 | globalConfigPath := filepath.Join(globalConfigDir, GlobalConfigFile) 46 | if err := os.WriteFile(globalConfigPath, data, 0644); err != nil { 47 | return fmt.Errorf("failed to write config file: %w", err) 48 | } 49 | 50 | return nil 51 | } 52 | 53 | func getUserUUIDFromGlobalConfig() string { 54 | config, err := LoadGlobalConfig() 55 | if err != nil { 56 | return "" 57 | } 58 | 59 | return config.UserUUID 60 | } 61 | -------------------------------------------------------------------------------- /pkg/template/config.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/Layr-Labs/devkit-cli/config" 7 | 8 | "gopkg.in/yaml.v3" 9 | ) 10 | 11 | type Config struct { 12 | Framework map[string]FrameworkSpec `yaml:"framework"` 13 | } 14 | 15 | type FrameworkSpec struct { 16 | Template string `yaml:"template"` 17 | Version string `yaml:"version"` 18 | Languages []string `yaml:"languages"` 19 | } 20 | 21 | func LoadConfig() (*Config, error) { 22 | // pull from embedded string 23 | data := []byte(config.TemplatesYaml) 24 | 25 | var config Config 26 | if err := yaml.Unmarshal(data, &config); err != nil { 27 | return nil, err 28 | } 29 | 30 | return &config, nil 31 | } 32 | 33 | // GetTemplateURLs returns template URL & version for the requested framework + language. 34 | // Fails fast if the framework does not exist, the template URL is blank, or the 35 | // language is not declared in the framework's Languages slice. 36 | func GetTemplateURLs(config *Config, framework, lang string) (string, string, error) { 37 | fw, ok := config.Framework[framework] 38 | if !ok { 39 | return "", "", fmt.Errorf("unknown framework %q", framework) 40 | } 41 | if fw.Template == "" { 42 | return "", "", fmt.Errorf("template URL missing for framework %q", framework) 43 | } 44 | 45 | // Language gate – only enforce if Languages slice is populated 46 | if len(fw.Languages) != 0 { 47 | for _, l := range fw.Languages { 48 | if l == lang { 49 | return fw.Template, fw.Version, nil 50 | } 51 | } 52 | return "", "", fmt.Errorf("language %q not available for framework %q", lang, framework) 53 | } 54 | 55 | return fw.Template, fw.Version, nil 56 | } 57 | -------------------------------------------------------------------------------- /config/keystore_embeds.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import _ "embed" 4 | 5 | //go:embed keystores/operator1.bls.keystore.json 6 | var operator1Keystore string 7 | 8 | //go:embed keystores/operator2.bls.keystore.json 9 | var operator2Keystore string 10 | 11 | //go:embed keystores/operator3.bls.keystore.json 12 | var operator3Keystore string 13 | 14 | //go:embed keystores/operator4.bls.keystore.json 15 | var operator4Keystore string 16 | 17 | //go:embed keystores/operator5.bls.keystore.json 18 | var operator5Keystore string 19 | 20 | //go:embed keystores/operator1.ecdsa.keystore.json 21 | var operator1ECDSAKeystore string 22 | 23 | //go:embed keystores/operator2.ecdsa.keystore.json 24 | var operator2ECDSAKeystore string 25 | 26 | //go:embed keystores/operator3.ecdsa.keystore.json 27 | var operator3ECDSAKeystore string 28 | 29 | //go:embed keystores/operator4.ecdsa.keystore.json 30 | var operator4ECDSAKeystore string 31 | 32 | //go:embed keystores/operator5.ecdsa.keystore.json 33 | var operator5ECDSAKeystore string 34 | 35 | // Map of context name → content 36 | var KeystoreEmbeds = map[string]string{ 37 | "operator1.bls.keystore.json": operator1Keystore, 38 | "operator2.bls.keystore.json": operator2Keystore, 39 | "operator3.bls.keystore.json": operator3Keystore, 40 | "operator4.bls.keystore.json": operator4Keystore, 41 | "operator5.bls.keystore.json": operator5Keystore, 42 | "operator1.ecdsa.keystore.json": operator1ECDSAKeystore, 43 | "operator2.ecdsa.keystore.json": operator2ECDSAKeystore, 44 | "operator3.ecdsa.keystore.json": operator3ECDSAKeystore, 45 | "operator4.ecdsa.keystore.json": operator4ECDSAKeystore, 46 | "operator5.ecdsa.keystore.json": operator5ECDSAKeystore, 47 | } 48 | -------------------------------------------------------------------------------- /pkg/telemetry/telemetry_test.go: -------------------------------------------------------------------------------- 1 | package telemetry 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/Layr-Labs/devkit-cli/pkg/common" 8 | ) 9 | 10 | func TestNoopClient(t *testing.T) { 11 | client := NewNoopClient() 12 | if !IsNoopClient(client) { 13 | t.Error("Expected IsNoopClient to return true for NoopClient") 14 | } 15 | 16 | // Test AddMetric doesn't panic 17 | err := client.AddMetric(context.Background(), Metric{ 18 | Name: "test.metric", 19 | Value: 42, 20 | Dimensions: map[string]string{"test": "value"}, 21 | }) 22 | if err != nil { 23 | t.Errorf("AddMetric returned error: %v", err) 24 | } 25 | 26 | // Test Close doesn't panic 27 | err = client.Close() 28 | if err != nil { 29 | t.Errorf("Close returned error: %v", err) 30 | } 31 | } 32 | 33 | func TestContext(t *testing.T) { 34 | client := NewNoopClient() 35 | ctx := ContextWithClient(context.Background(), client) 36 | 37 | retrieved, ok := ClientFromContext(ctx) 38 | if !ok { 39 | t.Error("Failed to retrieve client from context") 40 | } 41 | if retrieved != client { 42 | t.Error("Retrieved client does not match original") 43 | } 44 | 45 | _, ok = ClientFromContext(context.Background()) 46 | if ok { 47 | t.Error("Should not find client in empty context") 48 | } 49 | } 50 | 51 | func TestProperties(t *testing.T) { 52 | props := common.NewAppEnvironment("darwin", "amd64", "test-uuid", "user-uuid") 53 | if props.CLIVersion == "" { 54 | t.Error("Version not using default") 55 | } 56 | if props.OS != "darwin" { 57 | t.Error("OS mismatch") 58 | } 59 | if props.Arch != "amd64" { 60 | t.Error("Arch mismatch") 61 | } 62 | if props.ProjectUUID != "test-uuid" { 63 | t.Error("ProjectUUID mismatch") 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /pkg/commands/test.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | 7 | "github.com/Layr-Labs/devkit-cli/pkg/common" 8 | 9 | "github.com/urfave/cli/v2" 10 | ) 11 | 12 | // TestCommand defines the "test" command 13 | var TestCommand = &cli.Command{ 14 | Name: "test", 15 | Usage: "Run AVS tests", 16 | Flags: append([]cli.Flag{ 17 | &cli.StringFlag{ 18 | Name: "context", 19 | Usage: "Select the context to use in this command (devnet, testnet or mainnet)", 20 | }, 21 | }, common.GlobalFlags...), 22 | Action: func(cCtx *cli.Context) error { 23 | // Invoke and return AVSTest 24 | return AVSTest(cCtx) 25 | }, 26 | } 27 | 28 | func AVSTest(cCtx *cli.Context) error { 29 | // Get logger 30 | logger := common.LoggerFromContext(cCtx) 31 | 32 | // Print task if verbose 33 | logger.Debug("Running AVS tests...") 34 | 35 | // Run the script from root of project dir 36 | const dir = "" 37 | 38 | // Set path for .devkit scripts 39 | scriptPath := filepath.Join(".devkit", "scripts", "test") 40 | 41 | // Check for flagged contextName 42 | contextName := cCtx.String("context") 43 | 44 | // Set path for context yaml 45 | var err error 46 | var contextJSON []byte 47 | if contextName == "" { 48 | contextJSON, _, err = common.LoadDefaultRawContext() 49 | } else { 50 | contextJSON, _, err = common.LoadRawContext(contextName) 51 | } 52 | if err != nil { 53 | return fmt.Errorf("failed to load context: %w", err) 54 | } 55 | 56 | // Run test on the template test script 57 | if _, err := common.CallTemplateScript(cCtx.Context, logger, dir, scriptPath, common.ExpectNonJSONResponse, contextJSON); err != nil { 58 | return fmt.Errorf("test failed: %w", err) 59 | } 60 | 61 | logger.Info("AVS tests completed successfully!") 62 | 63 | return nil 64 | } 65 | -------------------------------------------------------------------------------- /pkg/template/config_test.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestLoadConfig(t *testing.T) { 8 | config, err := LoadConfig() 9 | if err != nil { 10 | t.Fatalf("Failed to load config: %v", err) 11 | } 12 | 13 | // Test template URL lookup 14 | mainBaseURL, mainVersion, err := GetTemplateURLs(config, "hourglass", "go") 15 | if err != nil { 16 | t.Fatalf("Failed to get template URLs: %v", err) 17 | } 18 | 19 | expectedBaseURL := "https://github.com/Layr-Labs/hourglass-avs-template" 20 | expectedVersion := "v0.1.0" 21 | 22 | if mainBaseURL != expectedBaseURL { 23 | t.Errorf("Unexpected main template base URL: got %s, want %s", mainBaseURL, expectedBaseURL) 24 | } 25 | 26 | if mainVersion != expectedVersion { 27 | t.Errorf("Unexpected main template version: got %s, want %s", mainVersion, expectedVersion) 28 | } 29 | 30 | // Test non-existent architecture 31 | mainBaseURL, mainVersion, err = GetTemplateURLs(config, "nonexistent", "go") 32 | if err == nil { 33 | t.Fatalf("Expected to fail to get template URLs: %v", err) 34 | } 35 | if mainBaseURL != "" { 36 | t.Errorf("Expected empty URL for nonexistent architecture, got %s", mainBaseURL) 37 | } 38 | if mainVersion != "" { 39 | t.Errorf("Expected empty version for nonexistent architecture, got %s", mainVersion) 40 | } 41 | 42 | // Test non-existent language 43 | mainBaseURL, mainVersion, err = GetTemplateURLs(config, "hourglass", "nonexistent") 44 | if err == nil { 45 | t.Fatalf("Expected to fail to get template URLs: %v", err) 46 | } 47 | if mainBaseURL != "" { 48 | t.Errorf("Expected empty URL for nonexistent language, got %s", mainBaseURL) 49 | } 50 | if mainVersion != "" { 51 | t.Errorf("Expected empty version for nonexistent language, got %s", mainVersion) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /pkg/common/devnet/constants.go: -------------------------------------------------------------------------------- 1 | package devnet 2 | 3 | // Foundry Image Date : 21 April 2025 4 | const FOUNDRY_IMAGE = "ghcr.io/foundry-rs/foundry:stable" 5 | const L1_CHAIN_ARGS = "--gas-limit 140000000 --base-fee 0 --gas-price 1000000 --no-rate-limit" 6 | const L2_CHAIN_ARGS = "--gas-limit 140000000 --base-fee 0 --gas-price 1000000 --no-rate-limit" 7 | 8 | const FUND_VALUE = "1000000000000000000" 9 | const DEVNET_CONTEXT = "devnet" 10 | const ANVIL_1_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" 11 | const ANVIL_2_KEY = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" 12 | 13 | // Ref https://github.com/Layr-Labs/eigenlayer-contracts/blob/c08c9e849c27910f36f3ab746f3663a18838067f/src/contracts/core/AllocationManagerStorage.sol#L63 14 | const ALLOCATION_DELAY_INFO_SLOT = 155 15 | 16 | // Curve type constants for KeyRegistrar 17 | const CURVE_TYPE_KEY_REGISTRAR_UNKNOWN = 0 18 | const CURVE_TYPE_KEY_REGISTRAR_ECDSA = 1 19 | const CURVE_TYPE_KEY_REGISTRAR_BN254 = 2 20 | 21 | const EIGEN_CONTRACT_ADDRESS = "0x3B78576F7D6837500bA3De27A60c7f594934027E" 22 | 23 | const ST_ETH_TOKEN_ADDRESS = "0x00c71b0fCadE911B2feeE9912DE4Fe19eB04ca56" 24 | const B_EIGEN_TOKEN_ADDRESS = "0x275cCf9Be51f4a6C94aBa6114cdf2a4c45B9cb27" 25 | const STRATEGY_TOKEN_FUNDING_AMOUNT_BY_LARGE_HOLDER_IN_ETH = 1000 26 | 27 | const DEFAULT_L1_FORK_URL = "https://rpc.sepolia.ethpandaops.io" 28 | const DEFAULT_L2_FORK_URL = "https://base-sepolia.gateway.tenderly.co" 29 | 30 | const L1_CONTAINER_NAME_PREFIX = "devkit-devnet-l1-" 31 | const L2_CONTAINER_NAME_PREFIX = "devkit-devnet-l2-" 32 | 33 | const L1_CONTAINER_TYPE = "l1" 34 | const L2_CONTAINER_TYPE = "l2" 35 | 36 | const DEFAULT_L1_ANVIL_CHAINID = 31337 37 | const DEFAULT_L2_ANVIL_CHAINID = 31338 38 | 39 | const DEFAULT_L1_ANVIL_RPCURL = "http://localhost:8545" 40 | const DEFAULT_L2_ANVIL_RPCURL = "http://localhost:9545" 41 | -------------------------------------------------------------------------------- /pkg/common/output/prompt.go: -------------------------------------------------------------------------------- 1 | package output 2 | 3 | import "github.com/AlecAivazis/survey/v2" 4 | 5 | // Confirm prompts the user to confirm an action with a yes/no question. 6 | func Confirm(prompt string) (bool, error) { 7 | result := false 8 | c := &survey.Confirm{ 9 | Message: prompt, 10 | } 11 | err := survey.AskOne(c, &result) 12 | return result, err 13 | } 14 | 15 | // InputHiddenString prompts the user to input a string. The input is hidden from the user. 16 | // The validator is used to validate the input. The help text is displayed to the user when they ask for help. 17 | // There is no default value. 18 | func InputHiddenString(prompt, help string, validator func(string) error) (string, error) { 19 | var result string 20 | i := &survey.Password{ 21 | Message: prompt, 22 | Help: help, 23 | } 24 | 25 | err := survey.AskOne(i, &result, survey.WithValidator(func(ans interface{}) error { 26 | if err := validator(ans.(string)); err != nil { 27 | return err 28 | } 29 | return nil 30 | })) 31 | return result, err 32 | } 33 | 34 | // InputString prompts the user to input a string. The input is visible to the user. 35 | // The validator is used to validate the input. The help text is displayed to the user when they ask for help. 36 | // If defaultValue is not empty, it will be used as the default value. 37 | func InputString(prompt, help, defaultValue string, validator func(string) error) (string, error) { 38 | var result string 39 | i := &survey.Input{ 40 | Message: prompt, 41 | Help: help, 42 | Default: defaultValue, 43 | } 44 | 45 | var opts []survey.AskOpt 46 | if validator != nil { 47 | opts = append(opts, survey.WithValidator(func(ans interface{}) error { 48 | if err := validator(ans.(string)); err != nil { 49 | return err 50 | } 51 | return nil 52 | })) 53 | } 54 | 55 | err := survey.AskOne(i, &result, opts...) 56 | return result, err 57 | } 58 | -------------------------------------------------------------------------------- /config/contexts/migrations/v0.0.3-v0.0.4.go: -------------------------------------------------------------------------------- 1 | package contextMigrations 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/Layr-Labs/devkit-cli/config" 7 | "github.com/Layr-Labs/devkit-cli/pkg/common" 8 | "github.com/Layr-Labs/devkit-cli/pkg/migration" 9 | 10 | "gopkg.in/yaml.v3" 11 | ) 12 | 13 | func Migration_0_0_3_to_0_0_4(user, old, new *yaml.Node) (*yaml.Node, error) { 14 | log, _ := common.GetLogger(true) // We don't have context for logger here. So using verbose logs as default for migrations. 15 | // Extract eigenlayer section from new default 16 | eigenlayerNode := migration.ResolveNode(new, []string{"context", "eigenlayer"}) 17 | 18 | // Check if context exists in user config, create if not 19 | contextNode := migration.ResolveNode(user, []string{"context"}) 20 | if contextNode == nil || contextNode.Kind != yaml.MappingNode { 21 | // Something is wrong with user config, just return it unmodified 22 | return user, nil 23 | } 24 | 25 | // Add eigenlayer section to user config 26 | if eigenlayerNode != nil { 27 | // Add the key with comment first 28 | migration.EnsureKeyWithComment(user, []string{"context", "eigenlayer"}, "Core EigenLayer contract addresses") 29 | 30 | // Pull users eigenlayer key node 31 | keyNode := migration.ResolveNode(user, []string{"context", "eigenlayer"}) 32 | 33 | // Replace the key-value pairs in the context eigenlayer mapping 34 | *keyNode = *migration.CloneNode(eigenlayerNode) 35 | } 36 | 37 | // Write Zeus config to project root if it doesn't exist already 38 | zeusConfigDst := common.ZeusConfig 39 | if _, err := os.Stat(zeusConfigDst); os.IsNotExist(err) { 40 | _ = os.WriteFile(zeusConfigDst, []byte(config.ZeusConfig), 0644) 41 | } 42 | 43 | log.Info("Copied .zeus config to project root") 44 | // bump version node 45 | if v := migration.ResolveNode(user, []string{"version"}); v != nil { 46 | v.Value = "0.0.4" 47 | } 48 | return user, nil 49 | } 50 | -------------------------------------------------------------------------------- /config/configs/registry.go: -------------------------------------------------------------------------------- 1 | package configs 2 | 3 | import ( 4 | _ "embed" 5 | "errors" 6 | "fmt" 7 | "path/filepath" 8 | 9 | configMigrations "github.com/Layr-Labs/devkit-cli/config/configs/migrations" 10 | "github.com/Layr-Labs/devkit-cli/pkg/common/iface" 11 | "github.com/Layr-Labs/devkit-cli/pkg/migration" 12 | ) 13 | 14 | // Set the latest version 15 | const LatestVersion = "0.0.3" 16 | 17 | // -- 18 | // Versioned configs 19 | // -- 20 | 21 | //go:embed v0.0.1.yaml 22 | var v0_0_1_default []byte 23 | 24 | //go:embed v0.0.2.yaml 25 | var v0_0_2_default []byte 26 | 27 | //go:embed v0.0.3.yaml 28 | var v0_0_3_default []byte 29 | 30 | // Map of context name -> content 31 | var ConfigYamls = map[string][]byte{ 32 | "0.0.1": v0_0_1_default, 33 | "0.0.2": v0_0_2_default, 34 | "0.0.3": v0_0_3_default, 35 | } 36 | 37 | // Map of sequential migrations 38 | var MigrationChain = []migration.MigrationStep{ 39 | { 40 | From: "0.0.1", 41 | To: "0.0.2", 42 | Apply: configMigrations.Migration_0_0_1_to_0_0_2, 43 | OldYAML: v0_0_1_default, 44 | NewYAML: v0_0_2_default, 45 | }, 46 | { 47 | From: "0.0.2", 48 | To: "0.0.3", 49 | Apply: configMigrations.Migration_0_0_2_to_0_0_3, 50 | OldYAML: v0_0_2_default, 51 | NewYAML: v0_0_3_default, 52 | }, 53 | } 54 | 55 | func MigrateConfig(logger iface.Logger) (int, error) { 56 | // Set path for context yamls 57 | configDir := filepath.Join("config") 58 | configPath := filepath.Join(configDir, "config.yaml") 59 | 60 | // Migrate the config 61 | err := migration.MigrateYaml(logger, configPath, LatestVersion, MigrationChain) 62 | // Check for already upto date and ignore 63 | alreadyUptoDate := errors.Is(err, migration.ErrAlreadyUpToDate) 64 | 65 | // For any other error, migration has failed 66 | if err != nil && !alreadyUptoDate { 67 | return 0, fmt.Errorf("failed to migrate: %v", err) 68 | } 69 | 70 | // If config was migrated 71 | if !alreadyUptoDate { 72 | logger.Info("Migrated %s\n", configPath) 73 | 74 | return 1, nil 75 | } 76 | 77 | return 0, nil 78 | } 79 | -------------------------------------------------------------------------------- /config/contexts/migrations/v0.0.2-v0.0.3.go: -------------------------------------------------------------------------------- 1 | package contextMigrations 2 | 3 | import ( 4 | "github.com/Layr-Labs/devkit-cli/pkg/migration" 5 | 6 | "gopkg.in/yaml.v3" 7 | ) 8 | 9 | func Migration_0_0_2_to_0_0_3(user, old, new *yaml.Node) (*yaml.Node, error) { 10 | engine := migration.PatchEngine{ 11 | Old: old, 12 | New: new, 13 | User: user, 14 | Rules: []migration.PatchRule{ 15 | { 16 | Path: []string{"context", "chains", "l1", "fork"}, 17 | Condition: migration.Always{}, 18 | Transform: func(_ *yaml.Node) *yaml.Node { 19 | // build the key node and scalar node 20 | key := &yaml.Node{Kind: yaml.ScalarNode, Value: "block_time"} 21 | val := &yaml.Node{Kind: yaml.ScalarNode, Value: migration.ResolveNode(new, []string{"context", "chains", "l1", "fork", "block_time"}).Value} 22 | // clone the existing fork mapping, then append our new pair 23 | forkMap := migration.CloneNode(migration.ResolveNode(user, []string{"context", "chains", "l1", "fork"})) 24 | forkMap.Content = append(forkMap.Content, key, val) 25 | return forkMap 26 | }, 27 | }, 28 | { 29 | Path: []string{"context", "chains", "l2", "fork"}, 30 | Condition: migration.Always{}, 31 | Transform: func(_ *yaml.Node) *yaml.Node { 32 | // build the key node and scalar node 33 | key := &yaml.Node{Kind: yaml.ScalarNode, Value: "block_time"} 34 | val := &yaml.Node{Kind: yaml.ScalarNode, Value: migration.ResolveNode(new, []string{"context", "chains", "l2", "fork", "block_time"}).Value} 35 | // clone the existing fork mapping, then append our new pair 36 | forkMap := migration.CloneNode(migration.ResolveNode(user, []string{"context", "chains", "l2", "fork"})) 37 | forkMap.Content = append(forkMap.Content, key, val) 38 | return forkMap 39 | }, 40 | }, 41 | }, 42 | } 43 | err := engine.Apply() 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | // bump version node 49 | if v := migration.ResolveNode(user, []string{"version"}); v != nil { 50 | v.Value = "0.0.3" 51 | } 52 | return user, nil 53 | } 54 | -------------------------------------------------------------------------------- /pkg/telemetry/metric.go: -------------------------------------------------------------------------------- 1 | package telemetry 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "time" 7 | ) 8 | 9 | // MetricsContext holds all metrics collected during command execution 10 | type MetricsContext struct { 11 | StartTime time.Time `json:"start_time"` 12 | Metrics []Metric `json:"metrics"` 13 | Properties map[string]string `json:"properties"` 14 | } 15 | 16 | // Metric represents a single metric with its value and dimensions 17 | type Metric struct { 18 | Value float64 `json:"value"` 19 | Name string `json:"name"` 20 | Dimensions map[string]string `json:"dimensions"` 21 | } 22 | 23 | // metricsContextKey is used to store the metrics context 24 | type metricsContextKey struct{} 25 | 26 | // WithMetricsContext returns a new context with the metrics context 27 | func WithMetricsContext(ctx context.Context, metrics *MetricsContext) context.Context { 28 | return context.WithValue(ctx, metricsContextKey{}, metrics) 29 | } 30 | 31 | // MetricsFromContext retrieves the metrics context 32 | func MetricsFromContext(ctx context.Context) (*MetricsContext, error) { 33 | metrics, ok := ctx.Value(metricsContextKey{}).(*MetricsContext) 34 | if !ok { 35 | return &MetricsContext{}, errors.New("no metrics context") 36 | } 37 | return metrics, nil 38 | } 39 | 40 | // NewMetricsContext creates a new metrics context 41 | func NewMetricsContext() *MetricsContext { 42 | return &MetricsContext{ 43 | StartTime: time.Now(), 44 | Metrics: make([]Metric, 0), 45 | Properties: make(map[string]string), 46 | } 47 | } 48 | 49 | // AddMetric adds a new metric to the context without dimensions 50 | func (m *MetricsContext) AddMetric(name string, value float64) { 51 | m.AddMetricWithDimensions(name, value, make(map[string]string)) 52 | } 53 | 54 | // AddMetricWithDimensions adds a new metric to the context with dimensions 55 | func (m *MetricsContext) AddMetricWithDimensions(name string, value float64, dimensions map[string]string) { 56 | m.Metrics = append(m.Metrics, Metric{ 57 | Name: name, 58 | Value: value, 59 | Dimensions: dimensions, 60 | }) 61 | } 62 | -------------------------------------------------------------------------------- /config/contexts/migrations/v0.0.1-v0.0.2.go: -------------------------------------------------------------------------------- 1 | package contextMigrations 2 | 3 | import ( 4 | "github.com/Layr-Labs/devkit-cli/pkg/migration" 5 | 6 | "gopkg.in/yaml.v3" 7 | ) 8 | 9 | func Migration_0_0_1_to_0_0_2(user, old, new *yaml.Node) (*yaml.Node, error) { 10 | engine := migration.PatchEngine{ 11 | Old: old, 12 | New: new, 13 | User: user, 14 | Rules: []migration.PatchRule{ 15 | {Path: []string{"context", "chains", "l1", "fork", "url"}, Condition: migration.IfUnchanged{}}, 16 | {Path: []string{"context", "chains", "l2", "fork", "url"}, Condition: migration.IfUnchanged{}}, 17 | {Path: []string{"context", "app_private_key"}, Condition: migration.IfUnchanged{}}, 18 | {Path: []string{"context", "operators", "0", "address"}, Condition: migration.IfUnchanged{}}, 19 | {Path: []string{"context", "operators", "0", "ecdsa_key"}, Condition: migration.IfUnchanged{}}, 20 | {Path: []string{"context", "operators", "1", "address"}, Condition: migration.IfUnchanged{}}, 21 | {Path: []string{"context", "operators", "1", "ecdsa_key"}, Condition: migration.IfUnchanged{}}, 22 | {Path: []string{"context", "operators", "2", "address"}, Condition: migration.IfUnchanged{}}, 23 | {Path: []string{"context", "operators", "2", "ecdsa_key"}, Condition: migration.IfUnchanged{}}, 24 | {Path: []string{"context", "operators", "3", "address"}, Condition: migration.IfUnchanged{}}, 25 | {Path: []string{"context", "operators", "3", "ecdsa_key"}, Condition: migration.IfUnchanged{}}, 26 | {Path: []string{"context", "operators", "4", "address"}, Condition: migration.IfUnchanged{}}, 27 | {Path: []string{"context", "operators", "4", "ecdsa_key"}, Condition: migration.IfUnchanged{}}, 28 | {Path: []string{"context", "avs", "address"}, Condition: migration.IfUnchanged{}}, 29 | {Path: []string{"context", "avs", "avs_private_key"}, Condition: migration.IfUnchanged{}}, 30 | }, 31 | } 32 | err := engine.Apply() 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | // bump version node 38 | if v := migration.ResolveNode(user, []string{"version"}); v != nil { 39 | v.Value = "0.0.2" 40 | } 41 | return user, nil 42 | } 43 | -------------------------------------------------------------------------------- /pkg/commands/release.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "github.com/Layr-Labs/devkit-cli/pkg/common" 5 | "github.com/urfave/cli/v2" 6 | ) 7 | 8 | // OperatorSetRelease represents the data for each operator set 9 | type OperatorSetRelease struct { 10 | Digest string `json:"digest"` 11 | Registry string `json:"registry"` 12 | RuntimeSpec string `json:"runtimeSpec,omitempty"` // YAML content of the runtime spec 13 | } 14 | 15 | // ReleaseCommand defines the "release" command 16 | var ReleaseCommand = &cli.Command{ 17 | Name: "release", 18 | Usage: "Manage AVS releases and artifacts", 19 | Subcommands: []*cli.Command{ 20 | { 21 | Name: "publish", 22 | Usage: "Publish a new AVS release", 23 | Flags: append(common.GlobalFlags, []cli.Flag{ 24 | &cli.StringFlag{ 25 | Name: "context", 26 | Usage: "Select the context to use in this command (devnet, testnet or mainnet)", 27 | }, 28 | &cli.Int64Flag{ 29 | Name: "upgrade-by-time", 30 | Usage: "Unix timestamp by which the upgrade must be completed", 31 | Required: true, 32 | }, 33 | &cli.StringFlag{ 34 | Name: "registry", 35 | Usage: "Registry to use for the release. If not provided, will use registry from context", 36 | }, 37 | }...), 38 | Action: publishReleaseAction, 39 | }, 40 | { 41 | Name: "uri", 42 | Usage: "Set release metadata URI for an operator set", 43 | Flags: append(common.GlobalFlags, []cli.Flag{ 44 | &cli.StringFlag{ 45 | Name: "context", 46 | Usage: "Select the context to use in this command (devnet, testnet or mainnet)", 47 | }, 48 | &cli.StringFlag{ 49 | Name: "metadata-uri", 50 | Usage: "Metadata URI to set for the release", 51 | Required: true, 52 | }, 53 | &cli.UintFlag{ 54 | Name: "operator-set-id", 55 | Usage: "Operator set ID", 56 | Required: true, 57 | }, 58 | &cli.StringFlag{ 59 | Name: "avs-address", 60 | Usage: "AVS address (if not provided, will use from context)", 61 | }, 62 | }...), 63 | Action: setReleaseMetadataURIAction, 64 | }, 65 | }, 66 | } 67 | -------------------------------------------------------------------------------- /.github/workflows/devnet.yml: -------------------------------------------------------------------------------- 1 | name: Devnet Smoke Test 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [ '**' ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | env: 13 | FOUNDRY_PROFILE: ci 14 | L1_FORK_URL: ${{ secrets.L1_FORK_URL }} 15 | L2_FORK_URL: ${{ secrets.L2_FORK_URL }} 16 | 17 | jobs: 18 | devnet-test: 19 | if: github.event.pull_request.head.repo.full_name == github.repository 20 | strategy: 21 | fail-fast: true 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Checkout repo 25 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 26 | with: 27 | submodules: recursive 28 | 29 | - name: Set up Go 30 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 31 | with: 32 | go-version: '1.24' 33 | 34 | - name: Install Foundry 35 | uses: foundry-rs/foundry-toolchain@82dee4ba654bd2146511f85f0d013af94670c4de # v1.4.0 36 | with: 37 | version: stable 38 | 39 | - name: Install devkit CLI 40 | run: make install 41 | 42 | - name: Add ~/bin to PATH 43 | run: echo "$HOME/bin" >> $GITHUB_PATH 44 | 45 | - name: Create AVS project 46 | run: devkit avs create my-avs 47 | 48 | - name: Start devnet 49 | run: | 50 | cd ./my-avs/ 51 | devkit avs devnet start & 52 | sleep 10 # wait for devnet to fully start 53 | 54 | - name: Check block number with cast (with retry) 55 | run: | 56 | for i in {1..10}; do 57 | bn=$(cast block-number --rpc-url http://localhost:8545 || echo "error") 58 | if [ "$bn" != "error" ]; then 59 | echo "Current block number: $bn" 60 | exit 0 61 | fi 62 | echo "Anvil not ready yet, retrying in 2s..." 63 | sleep 2 64 | done 65 | echo "Devnet didn't start properly after waiting" 66 | exit 1 67 | 68 | - name: Stop devnet 69 | run: | 70 | cd ./my-avs/ 71 | devkit avs devnet stop 72 | -------------------------------------------------------------------------------- /pkg/common/logger/basic_logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "strings" 7 | ) 8 | 9 | type BasicLogger struct { 10 | verbose bool 11 | } 12 | 13 | func NewLogger(verbose bool) *BasicLogger { 14 | return &BasicLogger{ 15 | verbose: verbose, 16 | } 17 | } 18 | 19 | func (l *BasicLogger) Title(msg string, args ...any) { 20 | // format the message once 21 | formatted := fmt.Sprintf("\n"+msg+"\n", args...) 22 | 23 | // split into lines 24 | lines := strings.Split(formatted, "\n") 25 | 26 | // print the lines with log 27 | for _, line := range lines { 28 | log.Printf("%s", line) 29 | } 30 | } 31 | 32 | func (l *BasicLogger) Info(msg string, args ...any) { 33 | // format the message once 34 | formatted := fmt.Sprintf(msg, args...) 35 | 36 | // split into lines 37 | lines := strings.Split(strings.TrimSuffix(formatted, "\n"), "\n") 38 | 39 | // print the lines with log 40 | for _, line := range lines { 41 | log.Printf("%s", line) 42 | } 43 | } 44 | 45 | func (l *BasicLogger) Warn(msg string, args ...any) { 46 | // format the message once 47 | formatted := fmt.Sprintf(msg, args...) 48 | 49 | // split into lines 50 | lines := strings.Split(strings.TrimSuffix(formatted, "\n"), "\n") 51 | 52 | // print the lines with log 53 | for _, line := range lines { 54 | log.Printf("Warning: %s", line) 55 | } 56 | } 57 | 58 | func (l *BasicLogger) Error(msg string, args ...any) { 59 | // format the message once 60 | formatted := fmt.Sprintf(msg, args...) 61 | 62 | // split into lines 63 | lines := strings.Split(strings.TrimSuffix(formatted, "\n"), "\n") 64 | 65 | // print the lines with log 66 | for _, line := range lines { 67 | log.Printf("Error: %s", line) 68 | } 69 | } 70 | 71 | func (l *BasicLogger) Debug(msg string, args ...any) { 72 | // skip debug when !verbose 73 | if !l.verbose { 74 | return 75 | } 76 | 77 | // format the message once 78 | formatted := fmt.Sprintf(msg, args...) 79 | 80 | // split into lines 81 | lines := strings.Split(strings.TrimSuffix(formatted, "\n"), "\n") 82 | 83 | // print the lines with log 84 | for _, line := range lines { 85 | log.Printf("Debug: %s", line) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /pkg/common/progress/log_progress.go: -------------------------------------------------------------------------------- 1 | package progress 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | 7 | "github.com/Layr-Labs/devkit-cli/pkg/common/iface" 8 | ) 9 | 10 | type LogProgressTracker struct { 11 | mu sync.Mutex 12 | logger iface.Logger 13 | progress map[string]*iface.ProgressInfo 14 | order []string 15 | maxTracked int 16 | } 17 | 18 | func NewLogProgressTracker(max int, logger iface.Logger) *LogProgressTracker { 19 | return &LogProgressTracker{ 20 | logger: logger, 21 | progress: make(map[string]*iface.ProgressInfo), 22 | order: make([]string, 0, max), 23 | maxTracked: max, 24 | } 25 | } 26 | 27 | // ProgressRows returns all progress entries, in the order they completed. 28 | // It is safe to call from multiple goroutines. 29 | func (s *LogProgressTracker) ProgressRows() []iface.ProgressRow { 30 | s.mu.Lock() 31 | defer s.mu.Unlock() 32 | 33 | rows := make([]iface.ProgressRow, 0, len(s.order)) 34 | for _, id := range s.order { 35 | info := s.progress[id] 36 | rows = append(rows, iface.ProgressRow{ 37 | Module: id, 38 | Pct: info.Percentage, 39 | Label: info.DisplayText, 40 | }) 41 | 42 | } 43 | return rows 44 | } 45 | 46 | func (s *LogProgressTracker) Set(id string, pct int, label string) { 47 | s.mu.Lock() 48 | defer s.mu.Unlock() 49 | 50 | if info, exists := s.progress[id]; exists { 51 | // end early if this has already reached 100% 52 | if info.Percentage >= pct || info.Percentage == 100 { 53 | return 54 | } 55 | info.Percentage = pct 56 | info.DisplayText = label 57 | } else { 58 | if len(s.progress) >= s.maxTracked { 59 | return 60 | } 61 | s.progress[id] = &iface.ProgressInfo{ 62 | Percentage: pct, 63 | DisplayText: label, 64 | } 65 | s.order = append(s.order, id) 66 | } 67 | // print to logger on first 100% progress report 68 | info := s.progress[id] 69 | if info.Percentage == 100 { 70 | s.logger.Info(fmt.Sprintf("Progress: %s - %d%%", info.DisplayText, info.Percentage)) 71 | } 72 | } 73 | 74 | func (s *LogProgressTracker) Render() { 75 | // no-op - only print on 100% at Set 76 | } 77 | 78 | func (s *LogProgressTracker) Clear() { 79 | s.mu.Lock() 80 | defer s.mu.Unlock() 81 | 82 | s.progress = make(map[string]*iface.ProgressInfo) 83 | s.order = s.order[:0] 84 | } 85 | -------------------------------------------------------------------------------- /pkg/common/context.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "os/signal" 8 | "path/filepath" 9 | "runtime" 10 | "syscall" 11 | 12 | "github.com/google/uuid" 13 | "github.com/urfave/cli/v2" 14 | ) 15 | 16 | // Embedded devkit version from release 17 | var embeddedDevkitReleaseVersion = "Development" 18 | 19 | // WithShutdown creates a new context that will be cancelled on SIGTERM/SIGINT 20 | func WithShutdown(ctx context.Context) context.Context { 21 | ctx, cancel := context.WithCancel(ctx) 22 | sigChan := make(chan os.Signal, 1) 23 | signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT) 24 | 25 | go func() { 26 | <-sigChan 27 | signal.Stop(sigChan) 28 | cancel() 29 | _, _ = fmt.Fprintln(os.Stderr, "caught interrupt, shutting down gracefully.") 30 | }() 31 | 32 | return ctx 33 | } 34 | 35 | type appEnvironmentContextKey struct{} 36 | 37 | type AppEnvironment struct { 38 | CLIVersion string 39 | OS string 40 | Arch string 41 | ProjectUUID string 42 | UserUUID string 43 | } 44 | 45 | func NewAppEnvironment(os, arch, projectUuid, userUuid string) *AppEnvironment { 46 | return &AppEnvironment{ 47 | CLIVersion: embeddedDevkitReleaseVersion, 48 | OS: os, 49 | Arch: arch, 50 | ProjectUUID: projectUuid, 51 | UserUUID: userUuid, 52 | } 53 | } 54 | 55 | func WithAppEnvironment(ctx *cli.Context) { 56 | withAppEnvironmentFromLocation(ctx, filepath.Join("config", "config.yaml")) 57 | } 58 | 59 | func withAppEnvironmentFromLocation(ctx *cli.Context, location string) { 60 | user := getUserUUIDFromGlobalConfig() 61 | if user == "" { 62 | user = uuid.New().String() 63 | } 64 | 65 | id := getProjectUUIDFromLocation(location) 66 | if id == "" { 67 | id = uuid.New().String() 68 | } 69 | ctx.Context = withAppEnvironment(ctx.Context, NewAppEnvironment( 70 | runtime.GOOS, 71 | runtime.GOARCH, 72 | id, 73 | user, 74 | )) 75 | } 76 | 77 | func withAppEnvironment(ctx context.Context, appEnvironment *AppEnvironment) context.Context { 78 | return context.WithValue(ctx, appEnvironmentContextKey{}, appEnvironment) 79 | } 80 | 81 | func AppEnvironmentFromContext(ctx context.Context) (*AppEnvironment, bool) { 82 | env, ok := ctx.Value(appEnvironmentContextKey{}).(*AppEnvironment) 83 | return env, ok 84 | } 85 | -------------------------------------------------------------------------------- /pkg/commands/transporter_test.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/robfig/cron/v3" 9 | "github.com/stretchr/testify/require" 10 | "github.com/urfave/cli/v2" 11 | ) 12 | 13 | func TestScheduleTransport_InvalidCron(t *testing.T) { 14 | app := cli.NewApp() 15 | cCtx := cli.NewContext(app, nil, nil) 16 | cCtx.Context = context.Background() 17 | 18 | // attempt to schedule with invalid cron expression 19 | err := ScheduleTransport(cCtx, "invalid-cron") 20 | require.Error(t, err) 21 | require.Contains(t, err.Error(), "invalid cron expression") 22 | } 23 | 24 | func TestScheduleTransportWithParserAndFunc_Executes(t *testing.T) { 25 | executed := make(chan struct{}, 1) 26 | mockFunc := func() { 27 | executed <- struct{}{} 28 | } 29 | 30 | ctx, cancel := context.WithCancel(context.Background()) 31 | defer cancel() 32 | 33 | app := cli.NewApp() 34 | cCtx := cli.NewContext(app, nil, nil) 35 | cCtx.Context = ctx 36 | 37 | // pass in second aware parser 38 | cronExpr := "*/1 * * * * *" 39 | parser := cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow) 40 | go func() { 41 | err := ScheduleTransportWithParserAndFunc(cCtx, cronExpr, parser, mockFunc) 42 | require.NoError(t, err) 43 | }() 44 | 45 | select { 46 | case <-executed: 47 | // success 48 | case <-time.After(2 * time.Second): 49 | t.Fatal("transportFunc did not execute as expected") 50 | } 51 | } 52 | 53 | func TestScheduleTransportWithParserAndFunc_ContextCancellationStopsScheduler(t *testing.T) { 54 | executed := make(chan struct{}, 1) 55 | mockFunc := func() { 56 | executed <- struct{}{} 57 | } 58 | 59 | ctx, cancel := context.WithCancel(context.Background()) 60 | app := cli.NewApp() 61 | cCtx := cli.NewContext(app, nil, nil) 62 | cCtx.Context = ctx 63 | 64 | // pass in minute aware parser 65 | cronExpr := "*/1 * * * *" 66 | parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow) 67 | go func() { 68 | _ = ScheduleTransportWithParserAndFunc(cCtx, cronExpr, parser, mockFunc) 69 | }() 70 | 71 | // cancel early to test shutdown 72 | time.Sleep(500 * time.Millisecond) 73 | cancel() 74 | 75 | // wait and confirm function was not called 76 | select { 77 | case <-executed: 78 | t.Fatal("transportFunc should not have executed after early cancel") 79 | case <-time.After(2 * time.Second): 80 | // success 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /pkg/commands/template/info_test.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "flag" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/Layr-Labs/devkit-cli/pkg/common" 10 | "github.com/Layr-Labs/devkit-cli/pkg/testutils" 11 | "github.com/urfave/cli/v2" 12 | ) 13 | 14 | func TestInfoCommand(t *testing.T) { 15 | // Create a temporary directory for testing 16 | testProjectsDir := filepath.Join("../../..", "test-projects", "template-info-test") 17 | defer os.RemoveAll(testProjectsDir) 18 | 19 | // Create config directory and config.yaml 20 | configDir := filepath.Join(testProjectsDir, "config") 21 | err := os.MkdirAll(configDir, 0755) 22 | if err != nil { 23 | t.Fatalf("Failed to create config directory: %v", err) 24 | } 25 | 26 | // Create config with template information 27 | configContent := `config: 28 | project: 29 | name: template-info-test 30 | templateBaseUrl: https://github.com/Layr-Labs/hourglass-avs-template 31 | templateVersion: v0.0.3 32 | ` 33 | configPath := filepath.Join(configDir, common.BaseConfig) 34 | err = os.WriteFile(configPath, []byte(configContent), 0644) 35 | if err != nil { 36 | t.Fatalf("Failed to write config file: %v", err) 37 | } 38 | 39 | // Create test context with no-op logger 40 | infoCmdWithLogger, _ := testutils.WithTestConfigAndNoopLoggerAndAccess(InfoCommand) 41 | app := &cli.App{ 42 | Name: "test-app", 43 | Commands: []*cli.Command{ 44 | infoCmdWithLogger, 45 | }, 46 | } 47 | 48 | // Change to the test directory 49 | origDir, err := os.Getwd() 50 | if err != nil { 51 | t.Fatalf("Failed to get current directory: %v", err) 52 | } 53 | //nolint:errcheck 54 | defer os.Chdir(origDir) 55 | 56 | err = os.Chdir(testProjectsDir) 57 | if err != nil { 58 | t.Fatalf("Failed to change to test directory: %v", err) 59 | } 60 | 61 | // Test info command 62 | t.Run("Info command", func(t *testing.T) { 63 | // Create a flag set and context with no-op logger 64 | set := flag.NewFlagSet("test", 0) 65 | ctx := cli.NewContext(app, set, nil) 66 | 67 | // Execute the Before hook to set up the logger context 68 | if infoCmdWithLogger.Before != nil { 69 | err := infoCmdWithLogger.Before(ctx) 70 | if err != nil { 71 | t.Fatalf("Before hook failed: %v", err) 72 | } 73 | } 74 | 75 | // Run the info command 76 | err := infoCmdWithLogger.Action(ctx) 77 | if err != nil { 78 | t.Errorf("InfoCommand action returned error: %v", err) 79 | } 80 | }) 81 | } 82 | -------------------------------------------------------------------------------- /install-devkit.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | # DevKit version 6 | DEVKIT_VERSION=$(curl -fsSL https://raw.githubusercontent.com/Layr-Labs/devkit-cli/main/VERSION) 7 | DEVKIT_BASE_URL="https://s3.amazonaws.com/eigenlayer-devkit-releases" 8 | 9 | # Detect platform 10 | OS=$(uname -s | tr '[:upper:]' '[:lower:]') 11 | ARCH=$(uname -m) 12 | 13 | case $OS in 14 | darwin) OS="darwin" ;; 15 | linux) OS="linux" ;; 16 | *) echo "Error: Unsupported OS: $OS"; exit 1 ;; 17 | esac 18 | 19 | case $ARCH in 20 | x86_64|amd64) ARCH="amd64" ;; 21 | arm64|aarch64) ARCH="arm64" ;; 22 | *) echo "Error: Unsupported architecture: $ARCH"; exit 1 ;; 23 | esac 24 | 25 | PLATFORM="${OS}-${ARCH}" 26 | 27 | # Prompt for installation directory 28 | if [[ -t 0 ]]; then 29 | # Interactive terminal available 30 | echo "Where would you like to install DevKit?" 31 | echo "1) $HOME/bin (recommended)" 32 | echo "2) /usr/local/bin (system-wide, requires sudo)" 33 | echo "3) Custom path" 34 | read -p "Enter choice (1-3) [1]: " choice 35 | else 36 | # Non-interactive (piped), use default 37 | echo "Installing to $HOME/bin (default for non-interactive install)" 38 | choice=1 39 | fi 40 | 41 | case ${choice:-1} in 42 | 1) INSTALL_DIR="$HOME/bin" ;; 43 | 2) INSTALL_DIR="/usr/local/bin" ;; 44 | 3) 45 | read -p "Enter custom path: " INSTALL_DIR 46 | if [[ -z "$INSTALL_DIR" ]]; then 47 | echo "Error: No path provided" 48 | exit 1 49 | fi 50 | ;; 51 | *) echo "Invalid choice"; exit 1 ;; 52 | esac 53 | 54 | # Create directory if it doesn't exist 55 | if [[ "$INSTALL_DIR" == "/usr/local/bin" ]]; then 56 | sudo mkdir -p "$INSTALL_DIR" 57 | else 58 | mkdir -p "$INSTALL_DIR" 59 | fi 60 | 61 | # Download and install 62 | DEVKIT_URL="${DEVKIT_BASE_URL}/${DEVKIT_VERSION}/devkit-${PLATFORM}-${DEVKIT_VERSION}.tar.gz" 63 | echo "Downloading DevKit ${DEVKIT_VERSION} for ${PLATFORM}..." 64 | 65 | if [[ "$INSTALL_DIR" == "/usr/local/bin" ]]; then 66 | curl -sL "$DEVKIT_URL" | sudo tar -x -C "$INSTALL_DIR" -f - 67 | else 68 | curl -sL "$DEVKIT_URL" | tar -x -C "$INSTALL_DIR" -f - 69 | fi 70 | 71 | echo "✅ DevKit installed to $INSTALL_DIR/devkit" 72 | 73 | # Add to PATH if needed 74 | if [[ "$INSTALL_DIR" == "$HOME/bin" ]] && [[ ":$PATH:" != *":$HOME/bin:"* ]]; then 75 | echo "💡 Add $HOME/bin to your PATH:" 76 | echo " echo 'export PATH=\"\$HOME/bin:\$PATH\"' >> ~/.$(basename $SHELL)rc" 77 | fi 78 | 79 | echo "🚀 Verify installation: $INSTALL_DIR/devkit --help" -------------------------------------------------------------------------------- /cmd/devkit/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "os" 8 | 9 | "github.com/Layr-Labs/devkit-cli/pkg/commands" 10 | "github.com/Layr-Labs/devkit-cli/pkg/commands/keystore" 11 | "github.com/Layr-Labs/devkit-cli/pkg/commands/version" 12 | "github.com/Layr-Labs/devkit-cli/pkg/common" 13 | "github.com/Layr-Labs/devkit-cli/pkg/hooks" 14 | "github.com/urfave/cli/v2" 15 | ) 16 | 17 | func main() { 18 | ctx := common.WithShutdown(context.Background()) 19 | 20 | app := &cli.App{ 21 | EnableBashCompletion: true, 22 | Name: "devkit", 23 | Usage: "EigenLayer Development Kit", 24 | Flags: common.GlobalFlags, 25 | Before: func(cCtx *cli.Context) error { 26 | err := hooks.LoadEnvFile(cCtx) 27 | if err != nil { 28 | return err 29 | } 30 | common.WithAppEnvironment(cCtx) 31 | 32 | // Parse verbose flags from raw argv to capture from subcommand flags 33 | verbose := common.PeelBoolFromFlags(os.Args[1:], "--verbose", "-v") 34 | // Set verbose directly if it appears in subcommand flags 35 | if verbose { 36 | err := cCtx.Set("verbose", "true") 37 | if err != nil { 38 | return fmt.Errorf("failed to set verbose flag globally: %w", err) 39 | } 40 | } 41 | 42 | // Get logger based on CLI context (handles verbosity internally) 43 | logger, tracker := common.GetLoggerFromCLIContext(cCtx) 44 | 45 | // Store logger and tracker in the context 46 | cCtx.Context = common.WithLogger(cCtx.Context, logger) 47 | cCtx.Context = common.WithProgressTracker(cCtx.Context, tracker) 48 | 49 | // Handle first-run telemetry prompt (only for non-telemetry commands) 50 | if cCtx.Command.Name != "telemetry" && cCtx.Command.Name != "help" && cCtx.Command.Name != "version" { 51 | if err := hooks.WithFirstRunTelemetryPrompt(cCtx); err != nil { 52 | // Log error but don't fail the command 53 | logger.Debug("First-run telemetry prompt failed: %v", err) 54 | } 55 | } 56 | 57 | return hooks.WithCommandMetricsContext(cCtx) 58 | }, 59 | Commands: []*cli.Command{ 60 | commands.AVSCommand, 61 | keystore.KeystoreCommand, 62 | version.VersionCommand, 63 | commands.UpgradeCommand, 64 | commands.TelemetryCommand, 65 | }, 66 | UseShortOptionHandling: true, 67 | } 68 | 69 | actionChain := hooks.NewActionChain() 70 | actionChain.Use(hooks.WithMetricEmission) 71 | 72 | hooks.ApplyMiddleware(app.Commands, actionChain) 73 | 74 | if err := app.RunContext(ctx, os.Args); err != nil { 75 | log.Fatal(err) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /pkg/template/git_reporter.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | "strings" 7 | 8 | "github.com/Layr-Labs/devkit-cli/pkg/common/logger" 9 | ) 10 | 11 | type SubmoduleReport struct { 12 | Name string 13 | Dest string 14 | URL string 15 | } 16 | 17 | // cloneReporter implements Reporter and knows how to render submodules + progress for clones 18 | type cloneReporter struct { 19 | logger logger.ProgressLogger 20 | repoName string 21 | parent string 22 | final string 23 | discovered []SubmoduleReport 24 | metrics GitMetrics 25 | } 26 | 27 | func NewCloneReporter(repoURL string, lg logger.ProgressLogger, m GitMetrics) Reporter { 28 | return &cloneReporter{ 29 | repoName: filepath.Base(strings.TrimSuffix(repoURL, ".git")), 30 | logger: lg, 31 | metrics: m, 32 | } 33 | } 34 | 35 | func (r *cloneReporter) Report(e CloneEvent) { 36 | switch e.Type { 37 | case EventSubmoduleDiscovered: 38 | if r.parent != e.Parent { 39 | r.discovered = r.discovered[:0] 40 | r.parent = e.Parent 41 | } 42 | r.discovered = append(r.discovered, SubmoduleReport{ 43 | Name: e.Name, 44 | Dest: e.Parent + e.Name, 45 | URL: e.URL, 46 | }) 47 | 48 | case EventSubmoduleCloneStart: 49 | if len(r.discovered) > 0 { 50 | header := r.repoName 51 | if e.Parent != "" && r.parent != "." { 52 | header = strings.TrimSuffix(r.parent, "/") 53 | } 54 | // Clear prev progress before starting next set 55 | r.logger.ClearProgress() 56 | // Print submodule discoveries 57 | r.logger.Info("Discovered submodules for %s", header) 58 | for _, d := range r.discovered { 59 | // Log all details of the discovery 60 | r.logger.Info(" - %s → %s (%s)\n", d.Name, d.Dest, d.URL) 61 | // Set progress to report all at 0 at start of cloning layer 62 | r.logger.SetProgress(d.Name, 0, d.Name) 63 | } 64 | // Spacing line 65 | r.logger.Info("") 66 | // Clear discoveries after printing 67 | r.discovered = nil 68 | } 69 | 70 | case EventProgress: 71 | mod := e.Module 72 | desc := e.Module 73 | if mod == "" || mod == "." || mod == r.repoName { 74 | mod = r.repoName 75 | desc = fmt.Sprintf("%s (Cloning from ref: %s)", r.repoName, e.Ref) 76 | } 77 | r.logger.SetProgress(mod, e.Progress, desc) 78 | r.logger.PrintProgress() 79 | r.final = mod 80 | 81 | case EventCloneComplete: 82 | if r.metrics != nil { 83 | r.metrics.CloneFinished(r.repoName, nil) 84 | } 85 | r.logger.SetProgress(r.final, 100, r.final) 86 | r.logger.PrintProgress() 87 | r.logger.ClearProgress() 88 | 89 | case EventCloneFailed: 90 | r.logger.ClearProgress() 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /pkg/commands/upgrade_test.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "archive/tar" 5 | "bytes" 6 | "compress/gzip" 7 | "net/http" 8 | "net/http/httptest" 9 | "os" 10 | "path/filepath" 11 | "testing" 12 | 13 | "github.com/Layr-Labs/devkit-cli/pkg/common/logger" 14 | "github.com/stretchr/testify/assert" 15 | ) 16 | 17 | func TestUpgrade_PerformUpgrade(t *testing.T) { 18 | // Create a fake tar.gz containing a single dummy binary file 19 | var buf bytes.Buffer 20 | gz := gzip.NewWriter(&buf) 21 | tw := tar.NewWriter(gz) 22 | 23 | content := []byte("#!/bin/sh\necho devkit upgraded\n") 24 | hdr := &tar.Header{ 25 | Name: "devkit", 26 | Mode: 0755, 27 | Size: int64(len(content)), 28 | } 29 | err := tw.WriteHeader(hdr) 30 | assert.NoError(t, err) 31 | _, err = tw.Write(content) 32 | assert.NoError(t, err) 33 | tw.Close() 34 | gz.Close() 35 | 36 | // Start a test server that returns the tarball 37 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 38 | _, _ = w.Write(buf.Bytes()) 39 | })) 40 | defer ts.Close() 41 | 42 | // Patch the URL builder temporarily (for testing) 43 | oldURLBuilder := buildDownloadURL 44 | buildDownloadURL = func(version, arch, distro string) string { 45 | return ts.URL // fake URL instead of real S3 46 | } 47 | defer func() { buildDownloadURL = oldURLBuilder }() 48 | 49 | tmpDir := t.TempDir() 50 | log := logger.NewNoopLogger() 51 | 52 | err = PerformUpgrade("v0.0.1", tmpDir, log) 53 | assert.NoError(t, err) 54 | 55 | files, err := os.ReadDir(tmpDir) 56 | assert.NoError(t, err) 57 | assert.Len(t, files, 1) 58 | assert.Equal(t, "devkit", files[0].Name()) 59 | 60 | path := filepath.Join(tmpDir, "devkit") 61 | data, err := os.ReadFile(path) 62 | assert.NoError(t, err) 63 | assert.Contains(t, string(data), "echo devkit upgraded") 64 | } 65 | 66 | func TestUpgrade_GetLatestVersionFromGitHub(t *testing.T) { 67 | // Fake GitHub API server 68 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 69 | assert.Equal(t, "/repos/Layr-Labs/devkit-cli/releases/latest", r.URL.Path) 70 | _, _ = w.Write([]byte(`{"tag_name": "v9.9.9", "target_commitish": "aaaaaaa"}`)) 71 | })) 72 | defer ts.Close() 73 | 74 | // Patch URL to use mock server 75 | original := githubReleasesURL 76 | githubReleasesURL = func(version string) string { 77 | return ts.URL + "/repos/Layr-Labs/devkit-cli/releases/latest" 78 | } 79 | defer func() { githubReleasesURL = original }() 80 | 81 | version, commit, err := GetLatestVersionFromGitHub("latest") 82 | assert.NoError(t, err) 83 | assert.Equal(t, "v9.9.9", version) 84 | assert.Equal(t, "aaaaaaa", commit) 85 | } 86 | -------------------------------------------------------------------------------- /pkg/commands/run.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | 7 | "github.com/Layr-Labs/devkit-cli/pkg/common" 8 | "github.com/Layr-Labs/devkit-cli/pkg/common/devnet" 9 | 10 | "github.com/urfave/cli/v2" 11 | ) 12 | 13 | // RunCommand defines the "run" command 14 | var RunCommand = &cli.Command{ 15 | Name: "run", 16 | Usage: "Start offchain AVS components", 17 | Flags: append([]cli.Flag{ 18 | &cli.StringFlag{ 19 | Name: "context", 20 | Usage: "Select the context to use in this command (devnet, testnet or mainnet)", 21 | }, 22 | }, common.GlobalFlags...), 23 | Action: func(cCtx *cli.Context) error { 24 | // Invoke and return AVSRun 25 | return AVSRun(cCtx) 26 | }, 27 | } 28 | 29 | func AVSRun(cCtx *cli.Context) error { 30 | // Get logger 31 | logger := common.LoggerFromContext(cCtx) 32 | 33 | // Check for flagged contextName 34 | contextName := cCtx.String("context") 35 | 36 | // Set path for context yaml 37 | var err error 38 | var contextJSON []byte 39 | if contextName == "" { 40 | contextJSON, contextName, err = common.LoadDefaultRawConfigWithContext() 41 | } else { 42 | contextJSON, contextName, err = common.LoadRawConfigWithContext(contextName) 43 | } 44 | if err != nil { 45 | return fmt.Errorf("failed to load context: %w", err) 46 | } 47 | 48 | // Prevent runs when context is not devnet 49 | if contextName != devnet.DEVNET_CONTEXT { 50 | return fmt.Errorf("run failed: `devkit avs run` only available on devnet - please run `devkit avs run --context devnet`") 51 | } 52 | 53 | // Print task if verbose 54 | logger.Debug("Starting offchain AVS components...") 55 | 56 | // Load the config fetch templateLanguage 57 | cfg, _, err := common.LoadConfigWithContextConfig(contextName) 58 | if err != nil { 59 | return err 60 | } 61 | 62 | // Pull template language from config 63 | language := cfg.Config.Project.TemplateLanguage 64 | if language == "" { 65 | language = "go" 66 | } 67 | 68 | // Log the type of project being ran 69 | logger.Info("Running %s AVS project", language) 70 | 71 | // Run the script from root of project dir 72 | // (@TODO (GD): this should always be the root of the project, but we need to do this everywhere (ie reading ctx/config etc)) 73 | const dir = "" 74 | 75 | // Set path for .devkit scripts 76 | scriptPath := filepath.Join(".devkit", "scripts", "run") 77 | 78 | // Run init on the template init script 79 | if _, err := common.CallTemplateScript(cCtx.Context, logger, dir, scriptPath, common.ExpectNonJSONResponse, contextJSON, []byte(language)); err != nil { 80 | return fmt.Errorf("run failed: %w", err) 81 | } 82 | 83 | logger.Info("Offchain AVS components started successfully!") 84 | 85 | return nil 86 | } 87 | -------------------------------------------------------------------------------- /pkg/common/scripts_caller.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "os" 10 | "os/exec" 11 | "syscall" 12 | 13 | "github.com/Layr-Labs/devkit-cli/pkg/common/iface" 14 | ) 15 | 16 | type ResponseExpectation int 17 | 18 | const ( 19 | ExpectNonJSONResponse ResponseExpectation = iota 20 | ExpectJSONResponse 21 | ) 22 | 23 | func CallTemplateScript(cmdCtx context.Context, logger iface.Logger, dir string, scriptPath string, expect ResponseExpectation, params ...[]byte) (map[string]interface{}, error) { 24 | // Convert byte params to strings 25 | stringParams := make([]string, len(params)) 26 | for i, b := range params { 27 | stringParams[i] = string(b) 28 | } 29 | 30 | // Prepare the command 31 | var stdout bytes.Buffer 32 | cmd := exec.CommandContext(cmdCtx, scriptPath, stringParams...) 33 | cmd.Dir = dir 34 | cmd.Stdout = &stdout 35 | cmd.Stderr = os.Stderr 36 | 37 | // Run the command in its own group 38 | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} 39 | 40 | // When context is canceled, forward SIGINT (but only if the process is running) 41 | go func() { 42 | <-cmdCtx.Done() 43 | if cmd.Process != nil { 44 | _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGINT) 45 | } 46 | }() 47 | 48 | // Exec the command 49 | if err := cmd.Run(); err != nil { 50 | // if its an ExitError, check if it was killed by a signal 51 | var exitErr *exec.ExitError 52 | if errors.As(err, &exitErr) { 53 | if ws, ok := exitErr.ProcessState.Sys().(syscall.WaitStatus); ok && ws.Signaled() { 54 | // killed by signal -> treat as cancellation 55 | return nil, cmdCtx.Err() 56 | } 57 | // nonzero exit code 58 | return nil, fmt.Errorf("script %s exited with code %d", scriptPath, exitErr.ExitCode()) 59 | } 60 | return nil, fmt.Errorf("failed to run script %s: %w", scriptPath, err) 61 | } 62 | 63 | // Clean and validate stdout 64 | raw := bytes.TrimSpace(stdout.Bytes()) 65 | 66 | // Return the result as JSON if expected 67 | if expect == ExpectJSONResponse { 68 | // End early for empty response 69 | if len(raw) == 0 { 70 | logger.Warn("Empty output from %s; returning empty result", scriptPath) 71 | return map[string]interface{}{}, nil 72 | } 73 | 74 | // Unmarshal response and return unless err 75 | var result map[string]interface{} 76 | if err := json.Unmarshal(raw, &result); err != nil { 77 | logger.Warn("Invalid or non-JSON script output: %s; returning empty result: %v", string(raw), err) 78 | return map[string]interface{}{}, nil 79 | } 80 | return result, nil 81 | } 82 | 83 | // Log the raw stdout 84 | if len(raw) > 0 { 85 | logger.Info("%s", string(raw)) 86 | } 87 | 88 | return nil, nil 89 | } 90 | -------------------------------------------------------------------------------- /pkg/migration/migrator_test.go: -------------------------------------------------------------------------------- 1 | package migration 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "gopkg.in/yaml.v3" 8 | ) 9 | 10 | // helper to parse YAML into *yaml.Node 11 | func testNode(t *testing.T, input string) *yaml.Node { 12 | var node yaml.Node 13 | if err := yaml.Unmarshal([]byte(input), &node); err != nil { 14 | t.Fatalf("unmarshal failed: %v", err) 15 | } 16 | // unwrap DocumentNode 17 | if node.Kind == yaml.DocumentNode && len(node.Content) > 0 { 18 | return node.Content[0] 19 | } 20 | return &node 21 | } 22 | 23 | func TestResolveNode(t *testing.T) { 24 | src := ` 25 | version: v1 26 | nested: 27 | key: value 28 | list: 29 | - a 30 | - b 31 | ` 32 | node := testNode(t, src) 33 | 34 | // scalar 35 | vn := ResolveNode(node, []string{"version"}) 36 | if vn == nil || vn.Value != "v1" { 37 | t.Error("ResolveNode version failed") 38 | } 39 | 40 | // nested 41 | kn := ResolveNode(node, []string{"nested", "key"}) 42 | if kn == nil || kn.Value != "value" { 43 | t.Error("ResolveNode nested.key failed") 44 | } 45 | 46 | // list 47 | ln := ResolveNode(node, []string{"list", "1"}) 48 | if ln == nil || ln.Value != "b" { 49 | t.Error("ResolveNode list[1] failed") 50 | } 51 | } 52 | 53 | func TestCloneNode(t *testing.T) { 54 | src := `key: orig` 55 | n := testNode(t, src) 56 | clone := CloneNode(n) 57 | 58 | // modify clone 59 | clone.Content[1].Value = "new" 60 | 61 | orig := testNode(t, src) 62 | ov := ResolveNode(orig, []string{"key"}) 63 | if ov == nil || ov.Value != "orig" { 64 | t.Error("CloneNode did not deep copy") 65 | } 66 | } 67 | 68 | func TestPatchEngine_Apply(t *testing.T) { 69 | yamlOld := ` 70 | version: v1 71 | param: old 72 | ` 73 | yamlNew := ` 74 | version: v1 75 | param: new 76 | ` 77 | yamlUser := ` 78 | version: v1 79 | param: old 80 | ` 81 | 82 | oldDef := testNode(t, yamlOld) 83 | newDef := testNode(t, yamlNew) 84 | user := testNode(t, yamlUser) 85 | 86 | engine := PatchEngine{ 87 | Old: oldDef, 88 | New: newDef, 89 | User: user, 90 | Rules: []PatchRule{{ 91 | Path: []string{"param"}, 92 | Condition: IfUnchanged{}, 93 | }}, 94 | } 95 | if err := engine.Apply(); err != nil { 96 | t.Fatalf("Apply failed: %v", err) 97 | } 98 | on := ResolveNode(user, []string{"param"}) 99 | if on == nil || on.Value != "new" { 100 | t.Errorf("Expected param=new, got %v", on.Value) 101 | } 102 | } 103 | 104 | func TestMigrateNode_AlreadyUpToDate(t *testing.T) { 105 | yamlUser := `version: v1` 106 | node := testNode(t, yamlUser) 107 | // empty chain 108 | _, err := MigrateNode(node, "v1", "v1", nil) 109 | if !errors.Is(err, ErrAlreadyUpToDate) { 110 | t.Error("Expected ErrAlreadyUpToDate") 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /pkg/commands/keystore/read_keystore.go: -------------------------------------------------------------------------------- 1 | package keystore 2 | 3 | import ( 4 | "encoding/hex" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/Layr-Labs/crypto-libs/pkg/bn254" 10 | blskeystore "github.com/Layr-Labs/crypto-libs/pkg/keystore" 11 | "github.com/Layr-Labs/devkit-cli/pkg/common" 12 | ethkeystore "github.com/ethereum/go-ethereum/accounts/keystore" 13 | "github.com/urfave/cli/v2" 14 | "log" 15 | ) 16 | 17 | var ReadCommand = &cli.Command{ 18 | Name: "read", 19 | Usage: "Print the private key from a given keystore file, password", 20 | Flags: append([]cli.Flag{ 21 | &cli.StringFlag{ 22 | Name: "path", 23 | Usage: "Path to the keystore JSON", 24 | Required: true, 25 | }, 26 | &cli.StringFlag{ 27 | Name: "password", 28 | Usage: "Password to decrypt the keystore file", 29 | Required: true, 30 | }, 31 | }, common.GlobalFlags...), 32 | Action: func(cCtx *cli.Context) error { 33 | path := cCtx.String("path") 34 | password := cCtx.String("password") 35 | 36 | // Determine keystore type by checking the file content 37 | fileContent, err := os.ReadFile(path) 38 | if err != nil { 39 | return fmt.Errorf("failed to read keystore file: %w", err) 40 | } 41 | 42 | // Check if it's an ECDSA keystore (has "address" field) 43 | var jsonData map[string]interface{} 44 | if err := json.Unmarshal(fileContent, &jsonData); err != nil { 45 | return fmt.Errorf("failed to parse keystore JSON: %w", err) 46 | } 47 | 48 | if _, hasAddress := jsonData["address"]; hasAddress { 49 | // ECDSA keystore 50 | key, err := ethkeystore.DecryptKey(fileContent, password) 51 | if err != nil { 52 | return fmt.Errorf("failed to decrypt ECDSA keystore: %w", err) 53 | } 54 | 55 | privateKeyHex := hex.EncodeToString(key.PrivateKey.D.Bytes()) 56 | log.Println("✅ ECDSA Keystore decrypted successfully") 57 | log.Println("") 58 | log.Println("🔑 Save this ECDSA private key in a secure location:") 59 | log.Printf(" 0x%s\n", privateKeyHex) 60 | log.Println("") 61 | } else if _, hasPubkey := jsonData["pubkey"]; hasPubkey { 62 | // BLS keystore 63 | scheme := bn254.NewScheme() 64 | keystoreData, err := blskeystore.LoadKeystoreFile(path) 65 | if err != nil { 66 | return fmt.Errorf("failed to load BLS keystore file: %w", err) 67 | } 68 | 69 | privateKeyData, err := keystoreData.GetPrivateKey(password, scheme) 70 | if err != nil { 71 | return fmt.Errorf("failed to extract BLS private key: %w", err) 72 | } 73 | log.Println("✅ BLS Keystore decrypted successfully") 74 | log.Println("") 75 | log.Println("🔑 Save this BLS private key in a secure location:") 76 | log.Printf(" %s\n", privateKeyData.Bytes()) 77 | log.Println("") 78 | } else { 79 | return fmt.Errorf("unknown keystore format") 80 | } 81 | 82 | return nil 83 | }, 84 | } 85 | -------------------------------------------------------------------------------- /config/contexts/v0.0.2.yaml: -------------------------------------------------------------------------------- 1 | # Devnet context to be used for local deployments against Anvil chain 2 | version: 0.0.2 3 | context: 4 | # Name of the context 5 | name: "devnet" 6 | # Chains available to this context 7 | chains: 8 | l1: 9 | chain_id: 31337 10 | rpc_url: "http://localhost:8545" 11 | fork: 12 | block: 22475020 13 | url: "" 14 | l2: 15 | chain_id: 31337 16 | rpc_url: "http://localhost:8545" 17 | fork: 18 | block: 22475020 19 | url: "" 20 | # All key material (BLS and ECDSA) within this file should be used for local testing ONLY 21 | # ECDSA keys used are from Anvil's private key set 22 | # BLS keystores are deterministically pre-generated and embedded. These are NOT derived from a secure seed 23 | # Available private keys for deploying 24 | deployer_private_key: "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" # Anvil Private Key 0 25 | app_private_key: "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a" # Anvil Private Key 2 26 | # List of Operators and their private keys / stake details 27 | operators: 28 | - address: "0x90F79bf6EB2c4f870365E785982E1f101E93b906" 29 | ecdsa_key: "0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6" # Anvil Private Key 3 30 | bls_keystore_path: "keystores/operator1.keystore.json" 31 | bls_keystore_password: "testpass" 32 | stake: "1000ETH" 33 | - address: "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65" 34 | ecdsa_key: "0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a" # Anvil Private Key 4 35 | bls_keystore_path: "keystores/operator2.keystore.json" 36 | bls_keystore_password: "testpass" 37 | stake: "1000ETH" 38 | - address: "0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc" 39 | ecdsa_key: "0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba" # Anvil Private Key 5 40 | bls_keystore_path: "keystores/operator3.keystore.json" 41 | bls_keystore_password: "testpass" 42 | stake: "1000ETH" 43 | - address: "0x976EA74026E726554dB657fA54763abd0C3a0aa9" 44 | ecdsa_key: "0x92db14e403b83dfe3df233f83dfa3a0d7096f21ca9b0d6d6b8d88b2b4ec1564e" # Anvil Private Key 6 45 | bls_keystore_path: "keystores/operator4.keystore.json" 46 | bls_keystore_password: "testpass" 47 | stake: "1000ETH" 48 | - address: "0x14dC79964da2C08b23698B3D3cc7Ca32193d9955" 49 | ecdsa_key: "0x4bbbf85ce3377467afe5d46f804f221813b2bb87f24d81f60f1fcdbf7cbf4356" # Anvil Private Key 7 50 | bls_keystore_path: "keystores/operator5.keystore.json" 51 | bls_keystore_password: "testpass" 52 | stake: "1000ETH" 53 | # AVS configuration 54 | avs: 55 | address: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" 56 | avs_private_key: "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" # Anvil Private Key 1 57 | metadata_url: "https://my-org.com/avs/metadata.json" 58 | registrar_address: "0x0123456789abcdef0123456789ABCDEF01234567" 59 | -------------------------------------------------------------------------------- /test/integration/migration/avs_context_0_1_0_to_0_1_1_test.go: -------------------------------------------------------------------------------- 1 | package migration_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/Layr-Labs/devkit-cli/config/contexts" 7 | "github.com/Layr-Labs/devkit-cli/pkg/migration" 8 | ) 9 | 10 | func TestMigration_0_1_0_to_0_1_1(t *testing.T) { 11 | // Test YAML with old block heights 12 | oldYAML := ` 13 | version: 0.1.0 14 | context: 15 | name: "devnet" 16 | chains: 17 | l1: 18 | chain_id: 31337 19 | rpc_url: "http://localhost:8545" 20 | fork: 21 | block: 9085290 22 | url: "" 23 | block_time: 3 24 | l2: 25 | chain_id: 31338 26 | rpc_url: "http://localhost:9545" 27 | fork: 28 | block: 30327360 29 | url: "" 30 | block_time: 3 31 | ` 32 | 33 | userNode := testNode(t, oldYAML) 34 | 35 | // locate the 0.1.0 -> 0.1.1 step from the chain 36 | var step migration.MigrationStep 37 | for _, s := range contexts.MigrationChain { 38 | if s.From == "0.1.0" && s.To == "0.1.1" { 39 | step = s 40 | break 41 | } 42 | } 43 | if step.Apply == nil { 44 | t.Fatalf("migration step 0.1.0 -> 0.1.1 not found") 45 | } 46 | 47 | migrated, err := migration.MigrateNode(userNode, "0.1.0", "0.1.1", []migration.MigrationStep{step}) 48 | if err != nil { 49 | t.Fatalf("Migration failed: %v", err) 50 | } 51 | 52 | t.Run("version bumped", func(t *testing.T) { 53 | v := migration.ResolveNode(migrated, []string{"version"}) 54 | if v == nil || v.Value != "0.1.1" { 55 | t.Errorf("expected version 0.1.1, got %v", v) 56 | } 57 | }) 58 | 59 | t.Run("L1 block height updated", func(t *testing.T) { 60 | blockNode := migration.ResolveNode(migrated, []string{"context", "chains", "l1", "fork", "block"}) 61 | if blockNode == nil || blockNode.Value != "9259079" { 62 | t.Errorf("expected L1 block 9259079, got %v", blockNode) 63 | } 64 | }) 65 | 66 | t.Run("L2 block height updated", func(t *testing.T) { 67 | blockNode := migration.ResolveNode(migrated, []string{"context", "chains", "l2", "fork", "block"}) 68 | if blockNode == nil || blockNode.Value != "31408197" { 69 | t.Errorf("expected L2 block 31408197, got %v", blockNode) 70 | } 71 | }) 72 | 73 | t.Run("other fields preserved", func(t *testing.T) { 74 | // Check that other fields are not modified 75 | nameNode := migration.ResolveNode(migrated, []string{"context", "name"}) 76 | if nameNode == nil || nameNode.Value != "devnet" { 77 | t.Errorf("expected name to be preserved as 'devnet', got %v", nameNode) 78 | } 79 | 80 | l1ChainIdNode := migration.ResolveNode(migrated, []string{"context", "chains", "l1", "chain_id"}) 81 | if l1ChainIdNode == nil || l1ChainIdNode.Value != "31337" { 82 | t.Errorf("expected L1 chain_id to be preserved as 31337, got %v", l1ChainIdNode) 83 | } 84 | 85 | l2ChainIdNode := migration.ResolveNode(migrated, []string{"context", "chains", "l2", "chain_id"}) 86 | if l2ChainIdNode == nil || l2ChainIdNode.Value != "31338" { 87 | t.Errorf("expected L2 chain_id to be preserved as 31338, got %v", l2ChainIdNode) 88 | } 89 | }) 90 | } 91 | -------------------------------------------------------------------------------- /config/contexts/v0.0.1.yaml: -------------------------------------------------------------------------------- 1 | # Devnet context to be used for local deployments against Anvil chain 2 | version: 0.0.1 3 | context: 4 | # Name of the context 5 | name: "devnet" 6 | # Chains available to this context 7 | chains: 8 | l1: 9 | chain_id: 31337 10 | rpc_url: "http://localhost:8545" 11 | fork: 12 | block: 22475020 13 | url: "https://eth.llamarpc.com" 14 | l2: 15 | chain_id: 31337 16 | rpc_url: "http://localhost:8545" 17 | fork: 18 | block: 22475020 19 | url: "https://eth.llamarpc.com" 20 | # All key material (BLS and ECDSA) within this file should be used for local testing ONLY 21 | # ECDSA keys used are from Anvil's private key set 22 | # BLS keystores are deterministically pre-generated and embedded. These are NOT derived from a secure seed 23 | # Available private keys for deploying 24 | deployer_private_key: "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" # Anvil Private Key 0 25 | app_private_key: "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" # Anvil Private Key 0 26 | # List of Operators and their private keys / stake details 27 | operators: 28 | - address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" 29 | ecdsa_key: "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" # Anvil Private Key 0 30 | bls_keystore_path: "keystores/operator1.keystore.json" 31 | bls_keystore_password: "testpass" 32 | stake: "1000ETH" 33 | - address: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" 34 | ecdsa_key: "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" # Anvil Private Key 1 35 | bls_keystore_path: "keystores/operator2.keystore.json" 36 | bls_keystore_password: "testpass" 37 | stake: "1000ETH" 38 | - address: "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC" 39 | ecdsa_key: "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a" # Anvil Private Key 2 40 | bls_keystore_path: "keystores/operator3.keystore.json" 41 | bls_keystore_password: "testpass" 42 | stake: "1000ETH" 43 | - address: "0x90F79bf6EB2c4f870365E785982E1f101E93b906" 44 | ecdsa_key: "0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6" # Anvil Private Key 3 45 | bls_keystore_path: "keystores/operator4.keystore.json" 46 | bls_keystore_password: "testpass" 47 | stake: "1000ETH" 48 | - address: "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65" 49 | ecdsa_key: "0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a" # Anvil Private Key 4 50 | bls_keystore_path: "keystores/operator5.keystore.json" 51 | bls_keystore_password: "testpass" 52 | stake: "1000ETH" 53 | # AVS configuration 54 | avs: 55 | address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" 56 | avs_private_key: "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" # Anvil Private Key 0 57 | metadata_url: "https://my-org.com/avs/metadata.json" 58 | registrar_address: "0x0123456789abcdef0123456789ABCDEF01234567" 59 | -------------------------------------------------------------------------------- /pkg/common/progress/tty_progress.go: -------------------------------------------------------------------------------- 1 | package progress 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/Layr-Labs/devkit-cli/pkg/common/iface" 7 | 8 | "fmt" 9 | "os" 10 | "strings" 11 | "sync" 12 | ) 13 | 14 | type TTYProgressTracker struct { 15 | mu sync.Mutex 16 | progress map[string]*iface.ProgressInfo 17 | order []string 18 | maxTracked int 19 | linesDrawn int 20 | target *os.File 21 | } 22 | 23 | func NewTTYProgressTracker(max int, target *os.File) *TTYProgressTracker { 24 | return &TTYProgressTracker{ 25 | progress: make(map[string]*iface.ProgressInfo), 26 | order: make([]string, 0, max), 27 | maxTracked: max, 28 | target: target, 29 | } 30 | } 31 | 32 | // ProgressRows returns all progress entries, in the order they completed. 33 | // It is safe to call from multiple goroutines. 34 | func (s *TTYProgressTracker) ProgressRows() []iface.ProgressRow { 35 | s.mu.Lock() 36 | defer s.mu.Unlock() 37 | 38 | rows := make([]iface.ProgressRow, 0, len(s.order)) 39 | for _, id := range s.order { 40 | info := s.progress[id] 41 | rows = append(rows, iface.ProgressRow{ 42 | Module: id, 43 | Pct: info.Percentage, 44 | Label: info.DisplayText, 45 | }) 46 | } 47 | return rows 48 | } 49 | 50 | func (t *TTYProgressTracker) Set(id string, pct int, label string) { 51 | t.mu.Lock() 52 | defer t.mu.Unlock() 53 | 54 | ts := time.Now().Format("2006/01/02 15:04:05") 55 | 56 | if info, exists := t.progress[id]; exists { 57 | if info.Percentage >= pct { 58 | return 59 | } 60 | info.Percentage = pct 61 | info.DisplayText = label 62 | info.Timestamp = ts 63 | } else { 64 | if len(t.progress) >= t.maxTracked { 65 | return 66 | } 67 | t.progress[id] = &iface.ProgressInfo{ 68 | Percentage: pct, 69 | DisplayText: label, 70 | Timestamp: ts, 71 | } 72 | t.order = append(t.order, id) 73 | } 74 | } 75 | 76 | func (t *TTYProgressTracker) Render() { 77 | t.mu.Lock() 78 | defer t.mu.Unlock() 79 | 80 | if t.linesDrawn > 0 { 81 | fmt.Fprintf(t.target, "\033[%dA", t.linesDrawn) 82 | } 83 | t.linesDrawn = 0 84 | 85 | for _, id := range t.order { 86 | info := t.progress[id] 87 | bar := buildBar(info.Percentage) 88 | fmt.Fprintf(t.target, "\r\033[K%s %s %3d%% %s\n", info.Timestamp, bar, info.Percentage, info.DisplayText) 89 | t.linesDrawn++ 90 | } 91 | } 92 | 93 | func (t *TTYProgressTracker) Clear() { 94 | t.mu.Lock() 95 | defer t.mu.Unlock() 96 | 97 | t.progress = make(map[string]*iface.ProgressInfo) 98 | t.order = t.order[:0] 99 | t.linesDrawn = 0 100 | 101 | // print timestamp line on clear 102 | ts := time.Now().Format("2006/01/02 15:04:05") 103 | fmt.Fprintf(t.target, "%s\n", ts) 104 | } 105 | 106 | func buildBar(pct int) string { 107 | total := 20 108 | if pct < 0 { 109 | pct = 0 110 | } 111 | if pct > 100 { 112 | pct = 100 113 | } 114 | filled := pct * total / 100 115 | return fmt.Sprintf("[%s%s]", strings.Repeat("=", filled), strings.Repeat(" ", total-filled)) 116 | } 117 | -------------------------------------------------------------------------------- /config/contexts/v0.0.3.yaml: -------------------------------------------------------------------------------- 1 | # Devnet context to be used for local deployments against Anvil chain 2 | version: 0.0.3 3 | context: 4 | # Name of the context 5 | name: "devnet" 6 | # Chains available to this context 7 | chains: 8 | l1: 9 | chain_id: 31337 10 | rpc_url: "http://localhost:8545" 11 | fork: 12 | block: 22475020 13 | url: "" 14 | block_time: 3 15 | l2: 16 | chain_id: 31337 17 | rpc_url: "http://localhost:8545" 18 | fork: 19 | block: 22475020 20 | url: "" 21 | block_time: 3 22 | # All key material (BLS and ECDSA) within this file should be used for local testing ONLY 23 | # ECDSA keys used are from Anvil's private key set 24 | # BLS keystores are deterministically pre-generated and embedded. These are NOT derived from a secure seed 25 | # Available private keys for deploying 26 | deployer_private_key: "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" # Anvil Private Key 0 27 | app_private_key: "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a" # Anvil Private Key 2 28 | # List of Operators and their private keys / stake details 29 | operators: 30 | - address: "0x90F79bf6EB2c4f870365E785982E1f101E93b906" 31 | ecdsa_key: "0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6" # Anvil Private Key 3 32 | bls_keystore_path: "keystores/operator1.keystore.json" 33 | bls_keystore_password: "testpass" 34 | stake: "1000ETH" 35 | - address: "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65" 36 | ecdsa_key: "0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a" # Anvil Private Key 4 37 | bls_keystore_path: "keystores/operator2.keystore.json" 38 | bls_keystore_password: "testpass" 39 | stake: "1000ETH" 40 | - address: "0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc" 41 | ecdsa_key: "0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba" # Anvil Private Key 5 42 | bls_keystore_path: "keystores/operator3.keystore.json" 43 | bls_keystore_password: "testpass" 44 | stake: "1000ETH" 45 | - address: "0x976EA74026E726554dB657fA54763abd0C3a0aa9" 46 | ecdsa_key: "0x92db14e403b83dfe3df233f83dfa3a0d7096f21ca9b0d6d6b8d88b2b4ec1564e" # Anvil Private Key 6 47 | bls_keystore_path: "keystores/operator4.keystore.json" 48 | bls_keystore_password: "testpass" 49 | stake: "1000ETH" 50 | - address: "0x14dC79964da2C08b23698B3D3cc7Ca32193d9955" 51 | ecdsa_key: "0x4bbbf85ce3377467afe5d46f804f221813b2bb87f24d81f60f1fcdbf7cbf4356" # Anvil Private Key 7 52 | bls_keystore_path: "keystores/operator5.keystore.json" 53 | bls_keystore_password: "testpass" 54 | stake: "1000ETH" 55 | # AVS configuration 56 | avs: 57 | address: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" 58 | avs_private_key: "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" # Anvil Private Key 1 59 | metadata_url: "https://my-org.com/avs/metadata.json" 60 | registrar_address: "0x0123456789abcdef0123456789ABCDEF01234567" 61 | -------------------------------------------------------------------------------- /pkg/commands/context/context_selection.go: -------------------------------------------------------------------------------- 1 | package context 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | 9 | tea "github.com/charmbracelet/bubbletea" 10 | ) 11 | 12 | // RunSelection is a variable alias for runSelection, so it can be stubbed in tests. 13 | var RunSelection = runSelection 14 | 15 | type model struct { 16 | Label string 17 | Choices []string 18 | cursor int 19 | selected int 20 | } 21 | 22 | func (m model) Init() tea.Cmd { return nil } 23 | 24 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 25 | switch msg := msg.(type) { 26 | case tea.KeyMsg: 27 | switch msg.String() { 28 | case "up", "k": 29 | if m.cursor > 0 { 30 | m.cursor-- 31 | } 32 | return m, nil 33 | case "down", "j": 34 | if m.cursor < len(m.Choices)-1 { 35 | m.cursor++ 36 | } 37 | return m, nil 38 | case "enter", " ": 39 | m.selected = m.cursor 40 | return m, tea.Quit 41 | case "ctrl+c", "q": 42 | return m, tea.Quit 43 | } 44 | } 45 | return m, nil 46 | } 47 | 48 | func (m model) View() string { 49 | var b strings.Builder 50 | b.WriteString(m.Label + "\n") 51 | b.WriteString("Use ↑/↓ to navigate, press space or enter to select\n\n") 52 | for i, choice := range m.Choices { 53 | cursor := " " 54 | if m.cursor == i { 55 | cursor = ">" 56 | } 57 | check := " " 58 | if m.selected == i { 59 | check = "x" 60 | } 61 | fmt.Fprintf(&b, "%s [%s] %s\n", cursor, check, choice) 62 | } 63 | return b.String() 64 | } 65 | 66 | func runSelection(label string, opts []string) (string, error) { 67 | m := NewModel(label, opts) 68 | p := tea.NewProgram(m) 69 | finalModel, err := p.Run() 70 | if err != nil { 71 | return "", err 72 | } 73 | selIdx := finalModel.(model).selected 74 | if selIdx < 0 || selIdx >= len(opts) { 75 | return "", fmt.Errorf("no selection made") 76 | } 77 | return opts[selIdx], nil 78 | } 79 | 80 | // NewModel creates a new bubbletea model with a label and options 81 | func NewModel(label string, choices []string) model { 82 | return model{ 83 | Label: label, 84 | Choices: choices, 85 | selected: -1, 86 | } 87 | } 88 | 89 | // ListContexts reads YAML contexts and returns user's selection 90 | func ListContexts(contextDir string, isList bool) ([]string, error) { 91 | entries, err := os.ReadDir(contextDir) 92 | if err != nil { 93 | return nil, fmt.Errorf("reading contexts dir: %w", err) 94 | } 95 | 96 | var names []string 97 | for _, e := range entries { 98 | if e.IsDir() { 99 | continue 100 | } 101 | name := e.Name() 102 | ext := filepath.Ext(name) 103 | if ext != ".yaml" && ext != ".yml" { 104 | continue 105 | } 106 | names = append(names, strings.TrimSuffix(name, ext)) 107 | } 108 | 109 | var operation = "set" 110 | if isList { 111 | operation = "list" 112 | } 113 | sel, err := RunSelection(fmt.Sprintf("Which context would you like to %s?", operation), names) 114 | if err != nil { 115 | return nil, err 116 | } 117 | return []string{sel}, nil 118 | } 119 | -------------------------------------------------------------------------------- /pkg/common/scripts_caller_test.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "os" 7 | "path/filepath" 8 | "reflect" 9 | "testing" 10 | 11 | "github.com/Layr-Labs/devkit-cli/pkg/common/logger" 12 | ) 13 | 14 | func TestCallTemplateScript(t *testing.T) { 15 | logger := logger.NewNoopLogger() 16 | // JSON response case 17 | scriptJSON := `#!/bin/bash 18 | input=$1 19 | echo '{"status": "ok", "received": '"$input"'}'` 20 | 21 | tmpDir := t.TempDir() 22 | jsonScriptPath := filepath.Join(tmpDir, "json_echo.sh") 23 | if err := os.WriteFile(jsonScriptPath, []byte(scriptJSON), 0755); err != nil { 24 | t.Fatalf("failed to write JSON test script: %v", err) 25 | } 26 | 27 | // Parse the provided params 28 | inputJSON, err := json.Marshal(map[string]interface{}{"context": map[string]interface{}{"foo": "bar"}}) 29 | if err != nil { 30 | t.Fatalf("marshal context: %v", err) 31 | } 32 | 33 | // Run the json_echo script 34 | out, err := CallTemplateScript(context.Background(), logger, "", jsonScriptPath, ExpectJSONResponse, inputJSON) 35 | if err != nil { 36 | t.Fatalf("CallTemplateScript (JSON) failed: %v", err) 37 | } 38 | 39 | // Assert known structure 40 | if out["status"] != "ok" { 41 | t.Errorf("expected status ok, got %v", out["status"]) 42 | } 43 | 44 | received, ok := out["received"].(map[string]interface{}) 45 | if !ok { 46 | t.Fatalf("expected map under 'received'") 47 | } 48 | 49 | expected := map[string]interface{}{"foo": "bar"} 50 | if !reflect.DeepEqual(received["context"], expected) { 51 | t.Errorf("expected context %v, got %v", expected, received["context"]) 52 | } 53 | 54 | // Non-JSON response case 55 | scriptText := `#!/bin/bash 56 | echo "This is plain text output"` 57 | 58 | textScriptPath := filepath.Join(tmpDir, "text_echo.sh") 59 | if err := os.WriteFile(textScriptPath, []byte(scriptText), 0755); err != nil { 60 | t.Fatalf("failed to write text test script: %v", err) 61 | } 62 | 63 | // Run the text_echo script 64 | out, err = CallTemplateScript(context.Background(), logger, "", textScriptPath, ExpectNonJSONResponse) 65 | if err != nil { 66 | t.Fatalf("CallTemplateScript (non-JSON) failed: %v", err) 67 | } 68 | if out != nil { 69 | t.Errorf("expected nil output for non-JSON response, got: %v", out) 70 | } 71 | 72 | // Empty response case 73 | empty := `#!/bin/bash 74 | exit 0` 75 | 76 | emptyPath := filepath.Join(tmpDir, "empty.sh") 77 | if err := os.WriteFile(emptyPath, []byte(empty), 0755); err != nil { 78 | t.Fatalf("failed to write empty test script: %v", err) 79 | } 80 | 81 | // Run the empty script expecting JSON (this should generate a warning) 82 | out, err = CallTemplateScript(context.Background(), logger, "", emptyPath, ExpectJSONResponse) 83 | if err != nil { 84 | t.Fatalf("CallTemplateScript (empty JSON) failed: %v", err) 85 | } 86 | if len(out) != 0 { 87 | t.Errorf("expected empty map for empty JSON response, got: %v", out) 88 | } 89 | 90 | // Check logger buffer for warning instead of capturing stdout 91 | if !logger.Contains("returning empty result") { 92 | t.Errorf("expected warning 'returning empty result' in logger buffer, but not found") 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /pkg/common/dockerutils.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os/exec" 7 | "runtime" 8 | "time" 9 | 10 | "github.com/docker/docker/client" 11 | "github.com/urfave/cli/v2" 12 | ) 13 | 14 | // EnsureDockerIsRunning checks if Docker is running and attempts to launch Docker Desktop if not. 15 | func EnsureDockerIsRunning(cCtx *cli.Context) error { 16 | logger := LoggerFromContext(cCtx) 17 | dockerPingTimeout := 2 * time.Second 18 | if !isDockerInstalled() { 19 | return fmt.Errorf("docker is not installed. Please install Docker Desktop from https://www.docker.com/products/docker-desktop") 20 | } 21 | 22 | if err := isDockerRunning(cCtx.Context, dockerPingTimeout); err == nil { 23 | return nil 24 | } 25 | 26 | logger.Info(" Docker is installed but not running. Attempting to start Docker Desktop...") 27 | 28 | switch runtime.GOOS { 29 | case "darwin": 30 | err := exec.CommandContext(cCtx.Context, "open", "-a", "Docker").Start() 31 | if err != nil { 32 | return fmt.Errorf("failed to launch Docker Desktop: %w", err) 33 | } 34 | case "windows": 35 | err := exec.CommandContext(cCtx.Context, "powershell", "Start-Process", "Docker Desktop").Start() 36 | if err != nil { 37 | return fmt.Errorf("failed to launch Docker Desktop: %w", err) 38 | } 39 | case "linux": 40 | if isCI() { 41 | // In CI, don't attempt to auto-start Docker. Assume it's pre-installed and running. 42 | return nil 43 | } else { 44 | 45 | err := exec.CommandContext(cCtx.Context, "systemctl", "start", "docker").Start() 46 | if err != nil { 47 | return fmt.Errorf("failed to launch Docker Desktop: %w", err) 48 | } 49 | } 50 | default: 51 | return fmt.Errorf("unsupported OS for automatic Docker launch! please start Docker manually") 52 | } 53 | 54 | logger.Info("⏳ Waiting for Docker to start") 55 | ticker := time.NewTicker(DockerOpenRetryIntervalMilliseconds * time.Millisecond) 56 | defer ticker.Stop() 57 | 58 | start := time.Now() 59 | timeout := time.After(DockerOpenTimeoutSeconds * time.Second) 60 | var lastErr error 61 | 62 | for { 63 | select { 64 | case <-cCtx.Done(): 65 | return cCtx.Err() 66 | case <-timeout: 67 | return fmt.Errorf("timed out waiting for Docker to start after %s: error: %v", 68 | time.Since(start).Round(time.Millisecond), lastErr) 69 | case <-ticker.C: 70 | if err := isDockerRunning(cCtx.Context, dockerPingTimeout); err == nil { 71 | logger.Info("\n✅ Docker is now running.") 72 | return nil 73 | } else { 74 | lastErr = err 75 | } 76 | fmt.Print(".") 77 | } 78 | } 79 | } 80 | 81 | func isDockerRunning(ctx context.Context, pingTimeout time.Duration) error { 82 | client, err := client.NewClientWithOpts(client.FromEnv) 83 | if err != nil { 84 | return err 85 | } 86 | defer client.Close() 87 | 88 | pingCtx, cancel := context.WithTimeout(ctx, pingTimeout) 89 | defer cancel() 90 | 91 | _, err = client.Ping(pingCtx) 92 | return err 93 | } 94 | 95 | // Check if docker is installed 96 | func isDockerInstalled() bool { 97 | _, err := exec.LookPath("docker") 98 | return err == nil 99 | } 100 | -------------------------------------------------------------------------------- /config/contexts/v0.0.4.yaml: -------------------------------------------------------------------------------- 1 | # Devnet context to be used for local deployments against Anvil chain 2 | version: 0.0.4 3 | context: 4 | # Name of the context 5 | name: "devnet" 6 | # Chains available to this context 7 | chains: 8 | l1: 9 | chain_id: 31337 10 | rpc_url: "http://localhost:8545" 11 | fork: 12 | block: 22475020 13 | url: "" 14 | block_time: 3 15 | l2: 16 | chain_id: 31337 17 | rpc_url: "http://localhost:8545" 18 | fork: 19 | block: 22475020 20 | url: "" 21 | block_time: 3 22 | # All key material (BLS and ECDSA) within this file should be used for local testing ONLY 23 | # ECDSA keys used are from Anvil's private key set 24 | # BLS keystores are deterministically pre-generated and embedded. These are NOT derived from a secure seed 25 | # Available private keys for deploying 26 | deployer_private_key: "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" # Anvil Private Key 0 27 | app_private_key: "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a" # Anvil Private Key 2 28 | # List of Operators and their private keys / stake details 29 | operators: 30 | - address: "0x90F79bf6EB2c4f870365E785982E1f101E93b906" 31 | ecdsa_key: "0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6" # Anvil Private Key 3 32 | bls_keystore_path: "keystores/operator1.keystore.json" 33 | bls_keystore_password: "testpass" 34 | stake: "1000ETH" 35 | - address: "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65" 36 | ecdsa_key: "0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a" # Anvil Private Key 4 37 | bls_keystore_path: "keystores/operator2.keystore.json" 38 | bls_keystore_password: "testpass" 39 | stake: "1000ETH" 40 | - address: "0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc" 41 | ecdsa_key: "0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba" # Anvil Private Key 5 42 | bls_keystore_path: "keystores/operator3.keystore.json" 43 | bls_keystore_password: "testpass" 44 | stake: "1000ETH" 45 | - address: "0x976EA74026E726554dB657fA54763abd0C3a0aa9" 46 | ecdsa_key: "0x92db14e403b83dfe3df233f83dfa3a0d7096f21ca9b0d6d6b8d88b2b4ec1564e" # Anvil Private Key 6 47 | bls_keystore_path: "keystores/operator4.keystore.json" 48 | bls_keystore_password: "testpass" 49 | stake: "1000ETH" 50 | - address: "0x14dC79964da2C08b23698B3D3cc7Ca32193d9955" 51 | ecdsa_key: "0x4bbbf85ce3377467afe5d46f804f221813b2bb87f24d81f60f1fcdbf7cbf4356" # Anvil Private Key 7 52 | bls_keystore_path: "keystores/operator5.keystore.json" 53 | bls_keystore_password: "testpass" 54 | stake: "1000ETH" 55 | # AVS configuration 56 | avs: 57 | address: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" 58 | avs_private_key: "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" # Anvil Private Key 1 59 | metadata_url: "https://my-org.com/avs/metadata.json" 60 | registrar_address: "0x0123456789abcdef0123456789ABCDEF01234567" 61 | # Core EigenLayer contract addresses 62 | eigenlayer: 63 | allocation_manager: "0x948a420b8CC1d6BFd0B6087C2E7c344a2CD0bc39" 64 | delegation_manager: "0x39053D51B77DC0d36036Fc1fCc8Cb819df8Ef37A" -------------------------------------------------------------------------------- /pkg/telemetry/posthog.go: -------------------------------------------------------------------------------- 1 | package telemetry 2 | 3 | import ( 4 | "context" 5 | "os" 6 | 7 | "github.com/Layr-Labs/devkit-cli/pkg/common" 8 | 9 | "github.com/posthog/posthog-go" 10 | "gopkg.in/yaml.v3" 11 | ) 12 | 13 | // PostHogClient implements the Client interface using PostHog 14 | type PostHogClient struct { 15 | namespace string 16 | client posthog.Client 17 | appEnvironment *common.AppEnvironment 18 | } 19 | 20 | // NewPostHogClient creates a new PostHog client 21 | func NewPostHogClient(environment *common.AppEnvironment, namespace string) (*PostHogClient, error) { 22 | apiKey := getPostHogAPIKey() 23 | if apiKey == "" { 24 | // No API key available, return noop client without error 25 | return nil, nil 26 | } 27 | client, err := posthog.NewWithConfig(apiKey, posthog.Config{Endpoint: getPostHogEndpoint()}) 28 | if err != nil { 29 | return nil, err 30 | } 31 | return &PostHogClient{ 32 | namespace: namespace, 33 | client: client, 34 | appEnvironment: environment, 35 | }, nil 36 | } 37 | 38 | // AddMetric implements the Client interface 39 | func (c *PostHogClient) AddMetric(_ context.Context, metric Metric) error { 40 | if c == nil || c.client == nil { 41 | return nil 42 | } 43 | 44 | // Create properties map starting with base properties 45 | props := make(map[string]interface{}) 46 | // Add metric value 47 | props["name"] = metric.Name 48 | props["value"] = metric.Value 49 | 50 | // Add metric dimensions 51 | for k, v := range metric.Dimensions { 52 | props[k] = v 53 | } 54 | 55 | // Never return errors from telemetry operations 56 | err := c.client.Enqueue(posthog.Capture{ 57 | DistinctId: c.appEnvironment.ProjectUUID, 58 | Event: c.namespace, 59 | Properties: props, 60 | }) 61 | return err 62 | } 63 | 64 | // Close implements the Client interface 65 | func (c *PostHogClient) Close() error { 66 | if c == nil || c.client == nil { 67 | return nil 68 | } 69 | // Ignore any errors from Close operations 70 | _ = c.client.Close() 71 | return nil 72 | } 73 | 74 | func getPostHogAPIKey() string { 75 | // Priority order: 76 | // 1. Environment variable 77 | // 2. Project config file 78 | // 3. Embedded key (set at build time) 79 | // Check environment variable first 80 | if key := os.Getenv("DEVKIT_POSTHOG_KEY"); key != "" { 81 | return key 82 | } 83 | 84 | // Check project config file next (config/config.yaml) 85 | data, err := os.ReadFile("config/config.yaml") 86 | if err == nil { 87 | // Parse the full config structure to extract the key 88 | var config struct { 89 | Config struct { 90 | Project struct { 91 | PostHogAPIKey string `yaml:"posthog_api_key"` 92 | } `yaml:"project"` 93 | } `yaml:"config"` 94 | } 95 | if yaml.Unmarshal(data, &config) == nil && config.Config.Project.PostHogAPIKey != "" { 96 | return config.Config.Project.PostHogAPIKey 97 | } 98 | } 99 | 100 | // return embedded key if no overrides provided 101 | return embeddedTelemetryApiKey 102 | } 103 | 104 | func getPostHogEndpoint() string { 105 | if endpoint := os.Getenv("DEVKIT_POSTHOG_ENDPOINT"); endpoint != "" { 106 | return endpoint 107 | } 108 | return "https://us.i.posthog.com" 109 | } 110 | -------------------------------------------------------------------------------- /config/contexts/v0.0.5.yaml: -------------------------------------------------------------------------------- 1 | # Devnet context to be used for local deployments against Anvil chain 2 | version: 0.0.5 3 | context: 4 | # Name of the context 5 | name: "devnet" 6 | # Chains available to this context 7 | chains: 8 | l1: 9 | chain_id: 31337 10 | rpc_url: "http://localhost:8545" 11 | fork: 12 | block: 22475020 13 | url: "" 14 | block_time: 3 15 | l2: 16 | chain_id: 31337 17 | rpc_url: "http://localhost:8545" 18 | fork: 19 | block: 22475020 20 | url: "" 21 | block_time: 3 22 | # All key material (BLS and ECDSA) within this file should be used for local testing ONLY 23 | # ECDSA keys used are from Anvil's private key set 24 | # BLS keystores are deterministically pre-generated and embedded. These are NOT derived from a secure seed 25 | # Available private keys for deploying 26 | deployer_private_key: "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" # Anvil Private Key 0 27 | app_private_key: "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a" # Anvil Private Key 2 28 | # List of Operators and their private keys / stake details 29 | operators: 30 | - address: "0x90F79bf6EB2c4f870365E785982E1f101E93b906" 31 | ecdsa_key: "0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6" # Anvil Private Key 3 32 | bls_keystore_path: "keystores/operator1.keystore.json" 33 | bls_keystore_password: "testpass" 34 | stake: "1000ETH" 35 | - address: "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65" 36 | ecdsa_key: "0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a" # Anvil Private Key 4 37 | bls_keystore_path: "keystores/operator2.keystore.json" 38 | bls_keystore_password: "testpass" 39 | stake: "1000ETH" 40 | - address: "0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc" 41 | ecdsa_key: "0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba" # Anvil Private Key 5 42 | bls_keystore_path: "keystores/operator3.keystore.json" 43 | bls_keystore_password: "testpass" 44 | stake: "1000ETH" 45 | - address: "0x976EA74026E726554dB657fA54763abd0C3a0aa9" 46 | ecdsa_key: "0x92db14e403b83dfe3df233f83dfa3a0d7096f21ca9b0d6d6b8d88b2b4ec1564e" # Anvil Private Key 6 47 | bls_keystore_path: "keystores/operator4.keystore.json" 48 | bls_keystore_password: "testpass" 49 | stake: "1000ETH" 50 | - address: "0x14dC79964da2C08b23698B3D3cc7Ca32193d9955" 51 | ecdsa_key: "0x4bbbf85ce3377467afe5d46f804f221813b2bb87f24d81f60f1fcdbf7cbf4356" # Anvil Private Key 7 52 | bls_keystore_path: "keystores/operator5.keystore.json" 53 | bls_keystore_password: "testpass" 54 | stake: "1000ETH" 55 | # AVS configuration 56 | avs: 57 | address: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" 58 | avs_private_key: "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" # Anvil Private Key 1 59 | metadata_url: "https://my-org.com/avs/metadata.json" 60 | registrar_address: "0x0123456789abcdef0123456789ABCDEF01234567" 61 | # Core EigenLayer contract addresses 62 | eigenlayer: 63 | allocation_manager: "0x948a420b8CC1d6BFd0B6087C2E7c344a2CD0bc39" 64 | delegation_manager: "0x39053D51B77DC0d36036Fc1fCc8Cb819df8Ef37A" 65 | # Contracts deployed on `devnet start` 66 | deployed_contracts: [] 67 | # Operator Sets registered on `devnet start` 68 | operator_sets: [] 69 | # Operators registered on `devnet start` 70 | operator_registrations: [] -------------------------------------------------------------------------------- /pkg/commands/test_test.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | "time" 10 | 11 | "github.com/Layr-Labs/devkit-cli/pkg/common/logger" 12 | "github.com/Layr-Labs/devkit-cli/pkg/testutils" 13 | 14 | "github.com/stretchr/testify/assert" 15 | "github.com/urfave/cli/v2" 16 | ) 17 | 18 | func setupTestApp(t *testing.T) (tmpDir string, restoreWD func(), app *cli.App, noopLogger *logger.NoopLogger) { 19 | tmpDir, err := testutils.CreateTempAVSProject(t) 20 | assert.NoError(t, err) 21 | 22 | // Create the test script 23 | scriptsDir := filepath.Join(tmpDir, ".devkit", "scripts") 24 | testScript := `#!/bin/bash 25 | echo "Running tests..." 26 | exit 0` 27 | err = os.WriteFile(filepath.Join(scriptsDir, "test"), []byte(testScript), 0755) 28 | assert.NoError(t, err) 29 | 30 | oldWD, err := os.Getwd() 31 | assert.NoError(t, err) 32 | assert.NoError(t, os.Chdir(tmpDir)) 33 | 34 | restore := func() { 35 | _ = os.Chdir(oldWD) 36 | os.RemoveAll(tmpDir) 37 | } 38 | 39 | cmdWithLogger, logger := testutils.WithTestConfigAndNoopLoggerAndAccess(TestCommand) 40 | app = &cli.App{ 41 | Name: "test", 42 | Commands: []*cli.Command{cmdWithLogger}, 43 | } 44 | 45 | return tmpDir, restore, app, logger 46 | } 47 | 48 | func TestTestCommand_ExecutesSuccessfully(t *testing.T) { 49 | _, restore, app, l := setupTestApp(t) 50 | defer restore() 51 | 52 | err := app.Run([]string{"app", "test", "--verbose"}) 53 | assert.NoError(t, err) 54 | 55 | // Check that the expected message was logged 56 | assert.True(t, l.Contains("AVS tests completed successfully"), 57 | "Expected 'AVS tests completed successfully' to be logged") 58 | } 59 | 60 | func TestTestCommand_MissingDevnetYAML(t *testing.T) { 61 | tmpDir, restore, app, _ := setupTestApp(t) 62 | defer restore() 63 | 64 | os.Remove(filepath.Join(tmpDir, "config", "contexts", "devnet.yaml")) 65 | 66 | err := app.Run([]string{"app", "test"}) 67 | assert.Error(t, err) 68 | assert.Contains(t, err.Error(), "failed to load context") 69 | } 70 | 71 | func TestTestCommand_MissingScript(t *testing.T) { 72 | tmpDir, restore, app, _ := setupTestApp(t) 73 | defer restore() 74 | 75 | os.Remove(filepath.Join(tmpDir, ".devkit", "scripts", "test")) 76 | 77 | err := app.Run([]string{"app", "test"}) 78 | assert.Error(t, err) 79 | assert.Contains(t, err.Error(), "no such file or directory") 80 | } 81 | 82 | func TestTestCommand_ScriptReturnsNonZero(t *testing.T) { 83 | tmpDir, restore, app, _ := setupTestApp(t) 84 | defer restore() 85 | 86 | scriptPath := filepath.Join(tmpDir, ".devkit", "scripts", "test") 87 | failScript := "#!/bin/bash\nexit 1" 88 | err := os.WriteFile(scriptPath, []byte(failScript), 0755) 89 | assert.NoError(t, err) 90 | 91 | err = app.Run([]string{"app", "test"}) 92 | assert.Error(t, err) 93 | assert.Contains(t, err.Error(), "test failed") 94 | } 95 | 96 | func TestTestCommand_Cancelled(t *testing.T) { 97 | _, restore, app, _ := setupTestApp(t) 98 | defer restore() 99 | 100 | ctx, cancel := context.WithCancel(context.Background()) 101 | result := make(chan error) 102 | go func() { 103 | result <- app.RunContext(ctx, []string{"app", "test"}) 104 | }() 105 | cancel() 106 | 107 | select { 108 | case err := <-result: 109 | if err != nil && errors.Is(err, context.Canceled) { 110 | t.Log("Test exited cleanly after context cancellation") 111 | } else { 112 | t.Errorf("Unexpected exit result: %v", err) 113 | } 114 | case <-time.After(1 * time.Second): 115 | t.Error("Test command did not exit after context cancellation") 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /pkg/common/utils_test.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "math/big" 5 | "reflect" 6 | "testing" 7 | 8 | "github.com/urfave/cli/v2" 9 | ) 10 | 11 | func TestIsVerboseEnabled(t *testing.T) { 12 | app := &cli.App{ 13 | Flags: []cli.Flag{ 14 | &cli.BoolFlag{Name: "verbose"}, 15 | }, 16 | Action: func(cCtx *cli.Context) error { 17 | cfg := &ConfigWithContextConfig{} 18 | if !IsVerboseEnabled(cCtx, cfg) { 19 | t.Errorf("expected true when verbose flag is set") 20 | } 21 | return nil 22 | }, 23 | } 24 | 25 | err := app.Run([]string{"test", "--verbose"}) 26 | if err != nil { 27 | t.Fatalf("cli run failed: %v", err) 28 | } 29 | } 30 | 31 | func TestGetLogger_ReturnsLoggerAndTracker(t *testing.T) { 32 | log, tracker := GetLogger(false) 33 | 34 | logType := reflect.TypeOf(log).String() 35 | trackerType := reflect.TypeOf(tracker).String() 36 | 37 | if !isValidLogger(logType) { 38 | t.Errorf("unexpected logger type: %s", logType) 39 | } 40 | if !isValidTracker(trackerType) { 41 | t.Errorf("unexpected tracker type: %s", trackerType) 42 | } 43 | } 44 | 45 | func isValidLogger(typ string) bool { 46 | return typ == "*logger.Logger" || typ == "*logger.ZapLogger" 47 | } 48 | 49 | func isValidTracker(typ string) bool { 50 | return typ == "*progress.TTYProgressTracker" || typ == "*progress.LogProgressTracker" 51 | } 52 | 53 | func TestParseETHAmount(t *testing.T) { 54 | tests := []struct { 55 | name string 56 | input string 57 | expected string // Expected result in wei as string 58 | wantErr bool 59 | }{ 60 | { 61 | name: "Simple ETH amount", 62 | input: "5ETH", 63 | expected: "5000000000000000000", // 5 * 10^18 64 | wantErr: false, 65 | }, 66 | { 67 | name: "Decimal ETH amount", 68 | input: "1.5ETH", 69 | expected: "1500000000000000000", // 1.5 * 10^18 70 | wantErr: false, 71 | }, 72 | { 73 | name: "Case insensitive ETH", 74 | input: "10eth", 75 | expected: "10000000000000000000", // 10 * 10^18 76 | wantErr: false, 77 | }, 78 | { 79 | name: "ETH with spaces", 80 | input: " 2.5 ETH ", 81 | expected: "2500000000000000000", // 2.5 * 10^18 82 | wantErr: false, 83 | }, 84 | { 85 | name: "Wei amount (no ETH suffix)", 86 | input: "1000000000000000000", 87 | expected: "1000000000000000000", // 1 * 10^18 88 | wantErr: false, 89 | }, 90 | { 91 | name: "Zero ETH", 92 | input: "0ETH", 93 | expected: "0", 94 | wantErr: false, 95 | }, 96 | { 97 | name: "Empty string", 98 | input: "", 99 | expected: "", 100 | wantErr: true, 101 | }, 102 | { 103 | name: "Invalid ETH amount", 104 | input: "invalidETH", 105 | expected: "", 106 | wantErr: true, 107 | }, 108 | { 109 | name: "Invalid wei amount", 110 | input: "invalid123", 111 | expected: "", 112 | wantErr: true, 113 | }, 114 | } 115 | 116 | for _, tt := range tests { 117 | t.Run(tt.name, func(t *testing.T) { 118 | result, err := ParseETHAmount(tt.input) 119 | 120 | if tt.wantErr { 121 | if err == nil { 122 | t.Errorf("ParseETHAmount() expected error for input '%s', but got none", tt.input) 123 | } 124 | return 125 | } 126 | 127 | if err != nil { 128 | t.Errorf("ParseETHAmount() unexpected error for input '%s': %v", tt.input, err) 129 | return 130 | } 131 | 132 | expected := new(big.Int) 133 | expected.SetString(tt.expected, 10) 134 | 135 | if result.Cmp(expected) != 0 { 136 | t.Errorf("ParseETHAmount() for input '%s' = %s, expected %s", tt.input, result.String(), expected.String()) 137 | } 138 | }) 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /pkg/commands/call.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/Layr-Labs/devkit-cli/pkg/common" 10 | "github.com/Layr-Labs/devkit-cli/pkg/common/devnet" 11 | 12 | "github.com/urfave/cli/v2" 13 | ) 14 | 15 | // CallCommand defines the "call" command 16 | var CallCommand = &cli.Command{ 17 | Name: "call", 18 | Usage: "Submits tasks to the local devnet, triggers off-chain execution, and aggregates results", 19 | Flags: append([]cli.Flag{ 20 | &cli.StringFlag{ 21 | Name: "context", 22 | Usage: "Select the context to use in this command (devnet, testnet or mainnet)", 23 | }, 24 | }, common.GlobalFlags...), 25 | Action: func(cCtx *cli.Context) error { 26 | // Get logger 27 | logger := common.LoggerFromContext(cCtx) 28 | 29 | // Check for flagged contextName 30 | contextName := cCtx.String("context") 31 | 32 | // Set path for context yaml 33 | var err error 34 | var contextJSON []byte 35 | if contextName == "" { 36 | contextJSON, contextName, err = common.LoadDefaultRawContext() 37 | } else { 38 | contextJSON, contextName, err = common.LoadRawContext(contextName) 39 | } 40 | if err != nil { 41 | return fmt.Errorf("failed to load context: %w", err) 42 | } 43 | 44 | // Prevent runs when context is not devnet 45 | if contextName != devnet.DEVNET_CONTEXT { 46 | cmdParams := reconstructCommandParams(cCtx.Args().Slice()) 47 | 48 | return fmt.Errorf( 49 | "call failed: `devkit avs call` only available on devnet - please run `devkit avs call --context devnet %s`", 50 | cmdParams, 51 | ) 52 | } 53 | 54 | // Print task if verbose 55 | logger.Debug("Testing AVS tasks...") 56 | 57 | // Check that args are provided 58 | parts := cCtx.Args().Slice() 59 | if len(parts) == 0 { 60 | return fmt.Errorf("no parameters supplied") 61 | } 62 | 63 | // Run scriptPath from cwd 64 | const dir = "" 65 | 66 | // Set path for .devkit scripts 67 | scriptPath := filepath.Join(".devkit", "scripts", "call") 68 | 69 | // Parse the params from the provided args 70 | paramsMap, err := parseParams(strings.Join(parts, " ")) 71 | if err != nil { 72 | return err 73 | } 74 | paramsJSON, err := json.Marshal(paramsMap) 75 | if err != nil { 76 | return err 77 | } 78 | 79 | // Run init on the template init script 80 | if _, err := common.CallTemplateScript(cCtx.Context, logger, dir, scriptPath, common.ExpectNonJSONResponse, contextJSON, paramsJSON); err != nil { 81 | return fmt.Errorf("call failed: %w", err) 82 | } 83 | 84 | logger.Info("Task execution completed successfully") 85 | return nil 86 | }, 87 | } 88 | 89 | func parseParams(input string) (map[string]string, error) { 90 | result := make(map[string]string) 91 | pairs := strings.Fields(input) 92 | 93 | for _, pair := range pairs { 94 | kv := strings.SplitN(pair, "=", 2) 95 | if len(kv) != 2 { 96 | return nil, fmt.Errorf("invalid param: %s", pair) 97 | } 98 | key := kv[0] 99 | val := strings.Trim(kv[1], `"'`) 100 | result[key] = val 101 | } 102 | 103 | return result, nil 104 | } 105 | 106 | func reconstructQuotes(val string) string { 107 | if strings.Contains(val, `"`) { 108 | return "'" + val + "'" 109 | } 110 | return `"` + val + `"` 111 | } 112 | 113 | func reconstructCommandParams(argv []string) string { 114 | var out []string 115 | for _, arg := range argv { 116 | parts := strings.SplitN(arg, "=", 2) 117 | if len(parts) == 2 { 118 | k, v := parts[0], parts[1] 119 | out = append(out, fmt.Sprintf("%s=%s", k, reconstructQuotes(v))) 120 | } else { 121 | out = append(out, arg) 122 | } 123 | } 124 | return strings.Join(out, " ") 125 | } 126 | -------------------------------------------------------------------------------- /pkg/commands/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | "strings" 7 | 8 | "github.com/Layr-Labs/devkit-cli/pkg/common" 9 | "gopkg.in/yaml.v3" 10 | 11 | "github.com/urfave/cli/v2" 12 | ) 13 | 14 | var Command = &cli.Command{ 15 | Name: "config", 16 | Usage: "Views or manages project-specific configuration (stored in config directory)", 17 | Flags: append([]cli.Flag{ 18 | &cli.BoolFlag{ 19 | Name: "list", 20 | Usage: "Display all current project configuration settings", 21 | }, 22 | &cli.BoolFlag{ 23 | Name: "edit", 24 | Usage: "Open config file in a text editor for manual editing", 25 | }, 26 | &cli.StringSliceFlag{ 27 | Name: "set", 28 | Usage: "Set a value into the current projects configuration settings (--set project.name=value)", 29 | }, 30 | }, common.GlobalFlags...), 31 | Action: func(cCtx *cli.Context) error { 32 | logger := common.LoggerFromContext(cCtx) 33 | 34 | // Identify the top level config .yaml 35 | cfgPath := filepath.Join("config", common.BaseConfig) 36 | 37 | // Open editor for the project level config 38 | if cCtx.Bool("edit") { 39 | logger.Info("Opening config file for editing...") 40 | return EditConfig(cCtx, cfgPath, Config, "") 41 | } 42 | 43 | // Get the sets 44 | items := cCtx.StringSlice("set") 45 | 46 | // Set values using dot.delim to navigate keys 47 | if len(items) > 0 { 48 | // Slice any position args to the items list 49 | items = append(items, cCtx.Args().Slice()...) 50 | 51 | // Load the config yaml 52 | rootDoc, err := common.LoadYAML(cfgPath) 53 | if err != nil { 54 | return fmt.Errorf("read config YAML: %w", err) 55 | } 56 | root := rootDoc.Content[0] 57 | configNode := common.GetChildByKey(root, "config") 58 | if configNode == nil { 59 | configNode = &yaml.Node{Kind: yaml.MappingNode} 60 | root.Content = append(root.Content, 61 | &yaml.Node{Kind: yaml.ScalarNode, Value: "config"}, 62 | configNode, 63 | ) 64 | } 65 | for _, item := range items { 66 | // Split into "key.path.to.field" and "value" 67 | idx := strings.LastIndex(item, "=") 68 | if idx < 0 { 69 | return fmt.Errorf("invalid --set syntax %q (want key=val)", item) 70 | } 71 | pathStr := item[:idx] 72 | val := item[idx+1:] 73 | 74 | // Break the key path into segments 75 | path := strings.Split(pathStr, ".") 76 | 77 | // Set val at path 78 | configNode, err = common.WriteToPath(configNode, path, val) 79 | if err != nil { 80 | return fmt.Errorf("setting value %s failed: %w", item, err) 81 | } 82 | logger.Info("Set %s = %s", pathStr, val) 83 | } 84 | if err := common.WriteYAML(cfgPath, rootDoc); err != nil { 85 | return fmt.Errorf("write config YAML: %w", err) 86 | } 87 | return nil 88 | } 89 | 90 | // list by default, if no flags are provided 91 | projectSettings, err := common.LoadProjectSettings() 92 | 93 | if err != nil { 94 | return fmt.Errorf("failed to load project settings to get telemetry status: %v", err) 95 | } 96 | 97 | // Load config 98 | config, err := common.LoadBaseConfigYaml() 99 | if err != nil { 100 | return fmt.Errorf("failed to load config and context config: %w", err) 101 | } 102 | 103 | // Log top level details 104 | logger.Info("Displaying current configuration... \n\n") 105 | logger.Info("Telemetry enabled: %t \n", projectSettings.TelemetryEnabled) 106 | logger.Info("Project: %s\n", config.Config.Project.Name) 107 | logger.Info("Version: %s\n\n", config.Config.Project.Version) 108 | 109 | // err = listConfig(config, projectSetting) 110 | err = common.ListYaml(cfgPath, logger) 111 | if err != nil { 112 | return fmt.Errorf("failed to list config %w", err) 113 | } 114 | return nil 115 | }, 116 | } 117 | -------------------------------------------------------------------------------- /pkg/commands/template/template.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/Layr-Labs/devkit-cli/pkg/common" 9 | "github.com/Layr-Labs/devkit-cli/pkg/template" 10 | "github.com/urfave/cli/v2" 11 | "gopkg.in/yaml.v3" 12 | ) 13 | 14 | // GetTemplateInfo reads the template information from the project config 15 | // Returns projectName, templateBaseURL, templateVersion, templateLanguage, error 16 | func GetTemplateInfo() (string, string, string, string, error) { 17 | // Check for config file 18 | configPath := filepath.Join("config", common.BaseConfig) 19 | if _, err := os.Stat(configPath); os.IsNotExist(err) { 20 | return "", "", "", "", fmt.Errorf("config/config.yaml not found. Make sure you're in a devkit project directory") 21 | } 22 | 23 | // Read and parse config 24 | configData, err := os.ReadFile(configPath) 25 | if err != nil { 26 | return "", "", "", "", fmt.Errorf("failed to read config file: %w", err) 27 | } 28 | 29 | var configMap map[string]interface{} 30 | if err := yaml.Unmarshal(configData, &configMap); err != nil { 31 | return "", "", "", "", fmt.Errorf("failed to parse config file: %w", err) 32 | } 33 | 34 | // Extract values with defaults 35 | projectName := "" 36 | templateBaseURL := "" 37 | templateVersion := "unknown" 38 | templateLanguage := "go" 39 | 40 | // Navigate to config.project section and extract values 41 | if config, ok := configMap["config"].(map[string]interface{}); ok { 42 | if project, ok := config["project"].(map[string]interface{}); ok { 43 | projectName, _ = project["name"].(string) 44 | templateBaseURL, _ = project["templateBaseUrl"].(string) 45 | templateVersion = getStringOrDefault(project, "templateVersion", templateVersion) 46 | templateLanguage = getStringOrDefault(project, "templateLanguage", templateLanguage) 47 | } 48 | } 49 | 50 | // Use defaults if templateBaseURL is empty 51 | if templateBaseURL == "" { 52 | templateBaseURL = "https://github.com/Layr-Labs/hourglass-avs-template" 53 | 54 | // Try to get from template config (optional) 55 | if cfg, err := template.LoadConfig(); err == nil { 56 | if url, _, _ := template.GetTemplateURLs(cfg, "hourglass", "go"); url != "" { 57 | templateBaseURL = url 58 | } 59 | } 60 | } 61 | 62 | return projectName, templateBaseURL, templateVersion, templateLanguage, nil 63 | } 64 | 65 | // GetTemplateInfoDefault returns default template information without requiring a config file 66 | // Returns projectName, templateBaseURL, templateVersion, error 67 | func GetTemplateInfoDefault() (string, string, string, string, error) { 68 | // Default values 69 | projectName := "" 70 | templateBaseURL := "" 71 | templateVersion := "" 72 | templateLanguage := "go" 73 | 74 | // Try to load templates configuration 75 | templateConfig, err := template.LoadConfig() 76 | if err == nil { 77 | // Default to "hourglass" framework and "go" language 78 | defaultFramework := "hourglass" 79 | defaultLang := "go" 80 | 81 | // Look up the default template URL 82 | mainBaseURL, _, _ := template.GetTemplateURLs(templateConfig, defaultFramework, defaultLang) 83 | 84 | // Use the default values 85 | templateBaseURL = mainBaseURL 86 | } 87 | 88 | return projectName, templateBaseURL, templateVersion, templateLanguage, nil 89 | } 90 | 91 | // Helper function to get string value or return default 92 | func getStringOrDefault(m map[string]interface{}, key, defaultValue string) string { 93 | if val, ok := m[key].(string); ok { 94 | return val 95 | } 96 | return defaultValue 97 | } 98 | 99 | // Command defines the main "template" command for template operations 100 | var Command = &cli.Command{ 101 | Name: "template", 102 | Usage: "Manage project templates", 103 | Subcommands: []*cli.Command{ 104 | InfoCommand, 105 | UpgradeCommand, 106 | }, 107 | } 108 | -------------------------------------------------------------------------------- /pkg/common/telemetry_prompt_test.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/Layr-Labs/devkit-cli/pkg/common/logger" 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestTelemetryPromptWithOptions(t *testing.T) { 13 | logger := logger.NewNoopLogger() 14 | 15 | t.Run("EnableTelemetry enables telemetry", func(t *testing.T) { 16 | opts := TelemetryPromptOptions{ 17 | EnableTelemetry: true, 18 | } 19 | 20 | enabled, err := TelemetryPromptWithOptions(logger, opts) 21 | require.NoError(t, err) 22 | assert.True(t, enabled) 23 | }) 24 | 25 | t.Run("DisableTelemetry disables telemetry", func(t *testing.T) { 26 | opts := TelemetryPromptOptions{ 27 | DisableTelemetry: true, 28 | } 29 | 30 | enabled, err := TelemetryPromptWithOptions(logger, opts) 31 | require.NoError(t, err) 32 | assert.False(t, enabled) 33 | }) 34 | 35 | // t.Run("SkipPromptInCI disables telemetry in CI", func(t *testing.T) { 36 | // // Set CI environment variable 37 | // originalCI := os.Getenv("CI") 38 | // defer func() { 39 | // if originalCI != "" { 40 | // os.Setenv("CI", originalCI) 41 | // } else { 42 | // os.Unsetenv("CI") 43 | // } 44 | // }() 45 | // os.Setenv("CI", "true") 46 | 47 | // opts := TelemetryPromptOptions{ 48 | // SkipPromptInCI: true, 49 | // } 50 | 51 | // enabled, err := TelemetryPromptWithOptions(logger, opts) 52 | // require.NoError(t, err) 53 | // assert.False(t, enabled) 54 | // }) 55 | 56 | t.Run("EnableTelemetry takes precedence over CI detection", func(t *testing.T) { 57 | // Set CI environment variable 58 | originalCI := os.Getenv("CI") 59 | defer func() { 60 | if originalCI != "" { 61 | os.Setenv("CI", originalCI) 62 | } else { 63 | os.Unsetenv("CI") 64 | } 65 | }() 66 | os.Setenv("CI", "true") 67 | 68 | opts := TelemetryPromptOptions{ 69 | EnableTelemetry: true, 70 | SkipPromptInCI: true, 71 | } 72 | 73 | enabled, err := TelemetryPromptWithOptions(logger, opts) 74 | require.NoError(t, err) 75 | assert.True(t, enabled) 76 | }) 77 | 78 | t.Run("DisableTelemetry takes precedence over CI detection", func(t *testing.T) { 79 | // Set CI environment variable 80 | originalCI := os.Getenv("CI") 81 | defer func() { 82 | if originalCI != "" { 83 | os.Setenv("CI", originalCI) 84 | } else { 85 | os.Unsetenv("CI") 86 | } 87 | }() 88 | os.Setenv("CI", "true") 89 | 90 | opts := TelemetryPromptOptions{ 91 | DisableTelemetry: true, 92 | SkipPromptInCI: true, 93 | } 94 | 95 | enabled, err := TelemetryPromptWithOptions(logger, opts) 96 | require.NoError(t, err) 97 | assert.False(t, enabled) 98 | }) 99 | } 100 | 101 | func TestIsStdinAvailable(t *testing.T) { 102 | // This test is environment-dependent and mainly for verification 103 | // In a real terminal, stdin should be available 104 | // In CI or non-interactive environments, it may not be 105 | available := isStdinAvailable() 106 | t.Logf("stdin available: %v", available) 107 | 108 | // We can't make strong assertions here since it depends on the test environment 109 | // but we can verify the function doesn't panic 110 | assert.IsType(t, true, available) 111 | } 112 | 113 | func TestHandleFirstRunTelemetryPromptWithOptions(t *testing.T) { 114 | logger := logger.NewNoopLogger() 115 | 116 | t.Run("Non-first run returns existing preference", func(t *testing.T) { 117 | // This test would need to set up a mock environment 118 | // For now, just verify the function signature works 119 | opts := TelemetryPromptOptions{} 120 | enabled, isFirstRun, err := HandleFirstRunTelemetryPromptWithOptions(logger, opts) 121 | 122 | // The actual behavior depends on the global config state 123 | // We're just verifying the function doesn't panic 124 | assert.NoError(t, err) 125 | assert.IsType(t, true, enabled) 126 | assert.IsType(t, true, isFirstRun) 127 | }) 128 | } 129 | -------------------------------------------------------------------------------- /pkg/commands/basic_e2e_test.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "os" 5 | "os/exec" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/Layr-Labs/devkit-cli/config/configs" 10 | "github.com/Layr-Labs/devkit-cli/pkg/hooks" 11 | 12 | "github.com/stretchr/testify/assert" 13 | "github.com/urfave/cli/v2" 14 | ) 15 | 16 | // TODO: Enhance this test to cover other commands and more complex scenarios 17 | 18 | func TestBasicE2E(t *testing.T) { 19 | // Create a temporary project directory 20 | tmpDir, err := os.MkdirTemp("", "e2e-test") 21 | if err != nil { 22 | t.Fatalf("Failed to create temp dir: %v", err) 23 | } 24 | defer os.RemoveAll(tmpDir) 25 | 26 | // Save current directory 27 | currentDir, err := os.Getwd() 28 | if err != nil { 29 | t.Fatalf("Failed to get current dir: %v", err) 30 | } 31 | defer func() { 32 | if err := os.Chdir(currentDir); err != nil { 33 | t.Logf("Warning: failed to restore directory: %v", err) 34 | } 35 | }() 36 | 37 | // Setup test project 38 | projectDir := filepath.Join(tmpDir, "test-avs") 39 | setupBasicProject(t, projectDir) 40 | 41 | // Change to the project directory 42 | if err := os.Chdir(projectDir); err != nil { 43 | t.Fatalf("Failed to change to project dir: %v", err) 44 | } 45 | 46 | // Test env loading 47 | testEnvLoading(t, projectDir) 48 | } 49 | 50 | func setupBasicProject(t *testing.T, dir string) { 51 | // Create project directory and required files 52 | if err := os.MkdirAll(dir, 0755); err != nil { 53 | t.Fatalf("Failed to create project dir: %v", err) 54 | } 55 | 56 | // Create config directory 57 | configDir := filepath.Join(dir, "config") 58 | err := os.MkdirAll(configDir, 0755) 59 | assert.NoError(t, err) 60 | 61 | // Create config.yaml (needed to identify project root) 62 | eigenContent := configs.ConfigYamls[configs.LatestVersion] 63 | if err := os.WriteFile(filepath.Join(configDir, "config.yaml"), []byte(eigenContent), 0644); err != nil { 64 | t.Fatalf("Failed to write config.yaml: %v", err) 65 | } 66 | 67 | // Create .env file 68 | envContent := `DEVKIT_TEST_ENV=test_value 69 | ` 70 | if err := os.WriteFile(filepath.Join(dir, ".env"), []byte(envContent), 0644); err != nil { 71 | t.Fatalf("Failed to write .env: %v", err) 72 | } 73 | 74 | // Create build script 75 | scriptsDir := filepath.Join(dir, ".devkit", "scripts") 76 | if err := os.MkdirAll(scriptsDir, 0755); err != nil { 77 | t.Fatal(err) 78 | } 79 | buildScript := `#!/bin/bash 80 | echo -e "Mock build executed ${DEVKIT_TEST_ENV}"` 81 | if err := os.WriteFile(filepath.Join(scriptsDir, "build"), []byte(buildScript), 0755); err != nil { 82 | t.Fatal(err) 83 | } 84 | } 85 | 86 | func testEnvLoading(t *testing.T, dir string) { 87 | // Backup and unset the original env var 88 | original := os.Getenv("DEVKIT_TEST_ENV") 89 | defer os.Setenv("DEVKIT_TEST_ENV", original) 90 | 91 | // Clear env var 92 | os.Unsetenv("DEVKIT_TEST_ENV") 93 | 94 | // 1. Simulate CLI context and run the Before hook 95 | app := cli.NewApp() 96 | cmd := &cli.Command{ 97 | Name: "build", 98 | Before: func(ctx *cli.Context) error { 99 | return hooks.LoadEnvFile(ctx) 100 | }, 101 | Action: func(ctx *cli.Context) error { 102 | // Verify that the env var is now set 103 | if val := os.Getenv("DEVKIT_TEST_ENV"); val != "test_value" { 104 | t.Errorf("Expected DEVKIT_TEST_ENV=test_value, got: %q", val) 105 | } 106 | return nil 107 | }, 108 | } 109 | app.Commands = []*cli.Command{cmd} 110 | 111 | err := app.Run([]string{"cmd", "build"}) 112 | if err != nil { 113 | t.Fatalf("CLI command failed: %v", err) 114 | } 115 | 116 | // Ref the scripts dir 117 | scriptsDir := filepath.Join(dir, ".devkit", "scripts") 118 | 119 | // 2. Run `bash -c ./build` and verify output 120 | cmdOut := exec.Command("bash", "-c", filepath.Join(scriptsDir, "build")) 121 | out, err := cmdOut.CombinedOutput() 122 | if err != nil { 123 | t.Fatalf("Failed to run 'make build': %v\nOutput:\n%s", err, out) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /pkg/commands/devnet.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "github.com/Layr-Labs/devkit-cli/pkg/common" 5 | "github.com/urfave/cli/v2" 6 | ) 7 | 8 | // DevnetCommand defines the "devnet" command 9 | var DevnetCommand = &cli.Command{ 10 | Name: "devnet", 11 | Usage: "Manage local AVS development network (Docker-based)", 12 | Subcommands: []*cli.Command{ 13 | { 14 | Name: "start", 15 | Usage: "Starts Docker containers and deploys local contracts", 16 | Flags: append([]cli.Flag{ 17 | &cli.StringFlag{ 18 | Name: "context", 19 | Usage: "Select the context to use in this command (devnet, testnet or mainnet)", 20 | }, 21 | &cli.BoolFlag{ 22 | Name: "reset", 23 | Usage: "Wipe and restart the devnet from scratch", 24 | }, 25 | &cli.StringFlag{ 26 | Name: "fork", 27 | Usage: "Fork from a specific chain (e.g. Base, OP)", 28 | }, 29 | &cli.BoolFlag{ 30 | Name: "headless", 31 | Usage: "Run without showing logs or interactive TUI", 32 | }, 33 | &cli.IntFlag{ 34 | Name: "l1-port", 35 | Usage: "Specify a custom port for local devnet L1", 36 | Value: 8545, 37 | }, 38 | &cli.IntFlag{ 39 | Name: "l2-port", 40 | Usage: "Specify a custom port for local devnet L2", 41 | Value: 9545, 42 | }, 43 | &cli.BoolFlag{ 44 | Name: "skip-avs-run", 45 | Usage: "Skip starting offchain AVS components", 46 | Value: false, 47 | }, 48 | &cli.BoolFlag{ 49 | Name: "skip-transporter", 50 | Usage: "Skip starting/submitting Stake Root via transporter", 51 | Value: false, 52 | }, 53 | &cli.BoolFlag{ 54 | Name: "skip-deploy-contracts", 55 | Usage: "Skip deploying contracts and only start local devnet", 56 | Value: false, 57 | }, 58 | &cli.BoolFlag{ 59 | Name: "skip-setup", 60 | Usage: "Skip AVS setup steps (metadata update, registrar setup, etc.) after contract deployment", 61 | Value: false, 62 | }, 63 | &cli.BoolFlag{ 64 | Name: "use-zeus", 65 | Usage: "Use Zeus CLI to fetch l1(sepolia) and l2(base sepolia) core addresses", 66 | Value: false, 67 | }, 68 | &cli.BoolFlag{ 69 | Name: "persist", 70 | Usage: "Persist devnet containers unless stop is used explicitly", 71 | Value: false, 72 | }, 73 | }, common.GlobalFlags...), 74 | Action: StartDevnetAction, 75 | }, 76 | { 77 | Name: "deploy-contracts", 78 | Usage: "Deploy all L1/L2 and AVS contracts to devnet", 79 | Flags: []cli.Flag{ 80 | &cli.StringFlag{ 81 | Name: "context", 82 | Usage: "Select the context to use in this command (devnet, testnet or mainnet)", 83 | }, 84 | }, 85 | Action: DeployL1ContractsAction, 86 | }, 87 | { 88 | Name: "stop", 89 | Usage: "Stops and removes all containers and resources", 90 | Flags: []cli.Flag{ 91 | &cli.StringFlag{ 92 | Name: "context", 93 | Usage: "Select the context to use in this command (devnet, testnet or mainnet)", 94 | }, 95 | &cli.BoolFlag{ 96 | Name: "all", 97 | Usage: "Stop all running devnet containers", 98 | Value: true, 99 | }, 100 | &cli.StringFlag{ 101 | Name: "project.name", 102 | Usage: "Stop containers associated with the given project name", 103 | }, 104 | &cli.IntFlag{ 105 | Name: "l1-port", 106 | Usage: "Stop only the L1 container running on the specified port", 107 | }, 108 | &cli.IntFlag{ 109 | Name: "l2-port", 110 | Usage: "Stop only the L2 container running on the specified port", 111 | }, 112 | }, 113 | Action: StopDevnetAction, 114 | }, 115 | { 116 | Name: "list", 117 | Usage: "Lists all running devkit devnet containers with their ports", 118 | Action: ListDevnetContainersAction, 119 | }, 120 | // TODO: Surface the following actions as separate commands: 121 | // - update-avs-metadata: Updates the AVS metadata URI on the devnet 122 | // - set-avs-registrar: Sets the AVS registrar address on the devnet 123 | // - create-avs-operator-sets: Creates AVS operator sets on the devnet 124 | // - register-operators-from-config: Registers operators defined in config to Eigenlayer and the AVS on the devnet 125 | }, 126 | } 127 | -------------------------------------------------------------------------------- /pkg/commands/template/template_test.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/Layr-Labs/devkit-cli/pkg/common" 9 | ) 10 | 11 | func TestGetTemplateInfo(t *testing.T) { 12 | // Create a temporary directory for testing 13 | testDir := filepath.Join(os.TempDir(), "devkit-test-template") 14 | defer os.RemoveAll(testDir) 15 | 16 | // Create config directory and config.yaml 17 | configDir := filepath.Join(testDir, "config") 18 | err := os.MkdirAll(configDir, 0755) 19 | if err != nil { 20 | t.Fatalf("Failed to create config directory: %v", err) 21 | } 22 | 23 | // Change to the test directory 24 | origDir, err := os.Getwd() 25 | if err != nil { 26 | t.Fatalf("Failed to get current directory: %v", err) 27 | } 28 | //nolint:errcheck 29 | defer os.Chdir(origDir) 30 | 31 | err = os.Chdir(testDir) 32 | if err != nil { 33 | t.Fatalf("Failed to change to test directory: %v", err) 34 | } 35 | 36 | // Test with template information 37 | t.Run("With template information in config", func(t *testing.T) { 38 | // Test with template information 39 | configContent := `config: 40 | project: 41 | name: test-project 42 | templateBaseUrl: https://github.com/Layr-Labs/hourglass-avs-template 43 | templateVersion: v0.0.3 44 | templateLanguage: go 45 | ` 46 | configPath := filepath.Join(configDir, common.BaseConfig) 47 | err = os.WriteFile(configPath, []byte(configContent), 0644) 48 | if err != nil { 49 | t.Fatalf("Failed to write config file: %v", err) 50 | } 51 | 52 | projectName, templateURL, templateVersion, templateLanguage, err := GetTemplateInfo() 53 | if err != nil { 54 | t.Fatalf("GetTemplateInfo failed: %v", err) 55 | } 56 | 57 | if projectName != "test-project" { 58 | t.Errorf("Expected project name 'test-project', got '%s'", projectName) 59 | } 60 | if templateURL != "https://github.com/Layr-Labs/hourglass-avs-template" { 61 | t.Errorf("Expected template URL 'https://github.com/Layr-Labs/hourglass-avs-template', got '%s'", templateURL) 62 | } 63 | if templateVersion != "v0.0.3" { 64 | t.Errorf("Expected template version 'v0.0.3', got '%s'", templateVersion) 65 | } 66 | if templateLanguage != "go" { 67 | t.Errorf("Expected template language 'go', got '%s'", templateLanguage) 68 | } 69 | }) 70 | 71 | // Test with no template info in config and falling back to hardcoded values 72 | t.Run("Without template information falling back to hardcoded values", func(t *testing.T) { 73 | // Update config content to remove template info 74 | configContent := `config: 75 | project: 76 | name: test-project 77 | ` 78 | configPath := filepath.Join(configDir, common.BaseConfig) 79 | err = os.WriteFile(configPath, []byte(configContent), 0644) 80 | if err != nil { 81 | t.Fatalf("Failed to write config file: %v", err) 82 | } 83 | 84 | projectName, templateURL, templateVersion, templateLanguage, err := GetTemplateInfo() 85 | if err != nil { 86 | t.Fatalf("GetTemplateInfo failed: %v", err) 87 | } 88 | 89 | if projectName != "test-project" { 90 | t.Errorf("Expected project name 'test-project', got '%s'", projectName) 91 | } 92 | 93 | // With the real implementation, we can't fully mock pkgtemplate.LoadConfig as it's not a variable, 94 | // so we'll check that we at least get a fallback value 95 | if templateURL == "" { 96 | t.Errorf("Expected a fallback template URL, got empty string") 97 | } 98 | // Most likely the hardcoded value from GetTemplateInfo() 99 | if templateVersion != "unknown" && templateVersion == "" { 100 | t.Errorf("Expected template version to be populated, got '%s'", templateVersion) 101 | } 102 | if templateLanguage == "" { 103 | t.Errorf("Expected template language to be populated, got '%s'", templateLanguage) 104 | } 105 | }) 106 | 107 | // Test with missing config file 108 | t.Run("No config file", func(t *testing.T) { 109 | // Remove config file 110 | err = os.Remove(filepath.Join(configDir, common.BaseConfig)) 111 | if err != nil { 112 | t.Fatalf("Failed to remove config file: %v", err) 113 | } 114 | 115 | _, _, _, _, err := GetTemplateInfo() 116 | if err == nil { 117 | t.Errorf("Expected error for missing config file, got nil") 118 | } 119 | }) 120 | } 121 | -------------------------------------------------------------------------------- /pkg/commands/run_test.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | "time" 10 | 11 | "github.com/Layr-Labs/devkit-cli/pkg/common/logger" 12 | "github.com/Layr-Labs/devkit-cli/pkg/testutils" 13 | 14 | "github.com/stretchr/testify/assert" 15 | "github.com/urfave/cli/v2" 16 | ) 17 | 18 | func setupRunApp(t *testing.T) (tmpDir string, restoreWD func(), app *cli.App, noopLogger *logger.NoopLogger) { 19 | tmpDir, err := testutils.CreateTempAVSProject(t) 20 | assert.NoError(t, err) 21 | 22 | oldWD, err := os.Getwd() 23 | assert.NoError(t, err) 24 | assert.NoError(t, os.Chdir(tmpDir)) 25 | 26 | restore := func() { 27 | _ = os.Chdir(oldWD) 28 | os.RemoveAll(tmpDir) 29 | } 30 | 31 | cmdWithLogger, logger := testutils.WithTestConfigAndNoopLoggerAndAccess(RunCommand) 32 | app = &cli.App{ 33 | Name: "run", 34 | Commands: []*cli.Command{cmdWithLogger}, 35 | } 36 | 37 | return tmpDir, restore, app, logger 38 | } 39 | 40 | func TestRunCommand_ExecutesSuccessfully(t *testing.T) { 41 | _, restore, app, logger := setupRunApp(t) 42 | defer restore() 43 | 44 | err := app.Run([]string{"app", "run", "--verbose"}) 45 | assert.NoError(t, err) 46 | 47 | // Check that the expected message was logged 48 | assert.True(t, logger.Contains("Offchain AVS components started successfully"), 49 | "Expected 'Offchain AVS components started successfully' to be logged") 50 | } 51 | 52 | func TestRunCommand_MissingDevnetYAML(t *testing.T) { 53 | tmpDir, restore, app, _ := setupRunApp(t) 54 | defer restore() 55 | 56 | os.Remove(filepath.Join(tmpDir, "config", "contexts", "devnet.yaml")) 57 | 58 | err := app.Run([]string{"app", "run"}) 59 | assert.Error(t, err) 60 | assert.Contains(t, err.Error(), "failed to load context") 61 | } 62 | 63 | func TestRunCommand_MalformedYAML(t *testing.T) { 64 | tmpDir, restore, app, _ := setupRunApp(t) 65 | defer restore() 66 | 67 | yamlPath := filepath.Join(tmpDir, "config", "contexts", "devnet.yaml") 68 | err := os.WriteFile(yamlPath, []byte(":\n - bad"), 0644) 69 | assert.NoError(t, err) 70 | 71 | err = app.Run([]string{"app", "run"}) 72 | assert.Error(t, err) 73 | assert.Contains(t, err.Error(), "failed to load context") 74 | } 75 | 76 | func TestRunCommand_MissingScript(t *testing.T) { 77 | tmpDir, restore, app, _ := setupRunApp(t) 78 | defer restore() 79 | 80 | os.Remove(filepath.Join(tmpDir, ".devkit", "scripts", "run")) 81 | 82 | err := app.Run([]string{"app", "run"}) 83 | assert.Error(t, err) 84 | assert.Contains(t, err.Error(), "no such file or directory") 85 | } 86 | 87 | func TestRunCommand_ScriptReturnsNonZero(t *testing.T) { 88 | tmpDir, restore, app, _ := setupRunApp(t) 89 | defer restore() 90 | 91 | scriptPath := filepath.Join(tmpDir, ".devkit", "scripts", "run") 92 | failScript := "#!/bin/bash\nexit 1" 93 | err := os.WriteFile(scriptPath, []byte(failScript), 0755) 94 | assert.NoError(t, err) 95 | 96 | err = app.Run([]string{"app", "run"}) 97 | assert.Error(t, err) 98 | assert.Contains(t, err.Error(), "run failed") 99 | } 100 | 101 | func TestRunCommand_ScriptOutputsInvalidJSON(t *testing.T) { 102 | tmpDir, restore, app, logger := setupRunApp(t) 103 | defer restore() 104 | 105 | scriptPath := filepath.Join(tmpDir, ".devkit", "scripts", "run") 106 | badOutput := "#!/bin/bash\necho 'not-json'\n" 107 | err := os.WriteFile(scriptPath, []byte(badOutput), 0755) 108 | assert.NoError(t, err) 109 | 110 | err = app.Run([]string{"app", "run"}) 111 | assert.NoError(t, err, "Run command should succeed with non-JSON output") 112 | 113 | // Check that the output was logged 114 | assert.True(t, logger.Contains("not-json"), "Expected 'not-json' to be logged as output") 115 | } 116 | 117 | func TestRunCommand_Cancelled(t *testing.T) { 118 | _, restore, app, _ := setupRunApp(t) 119 | defer restore() 120 | 121 | ctx, cancel := context.WithCancel(context.Background()) 122 | result := make(chan error) 123 | go func() { 124 | result <- app.RunContext(ctx, []string{"app", "run"}) 125 | }() 126 | cancel() 127 | 128 | select { 129 | case err := <-result: 130 | if err != nil && errors.Is(err, context.Canceled) { 131 | t.Log("Run exited cleanly after context cancellation") 132 | } else { 133 | t.Errorf("Unexpected exit result: %v", err) 134 | } 135 | case <-time.After(1 * time.Second): 136 | t.Error("Run command did not exit after context cancellation") 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: help build test fmt lint install clean test-telemetry 2 | 3 | APP_NAME=devkit 4 | 5 | VERSION_PKG=github.com/Layr-Labs/devkit-cli/internal/version 6 | TELEMETRY_PKG=github.com/Layr-Labs/devkit-cli/pkg/telemetry 7 | COMMON_PKG=github.com/Layr-Labs/devkit-cli/pkg/common 8 | 9 | LD_FLAGS=\ 10 | -X '$(VERSION_PKG).Version=$(shell cat VERSION)' \ 11 | -X '$(VERSION_PKG).Commit=$(shell git rev-parse --short HEAD)' \ 12 | -X '$(TELEMETRY_PKG).embeddedTelemetryApiKey=$${TELEMETRY_TOKEN}' \ 13 | -X '$(COMMON_PKG).embeddedDevkitReleaseVersion=$(shell cat VERSION)' 14 | 15 | GO_PACKAGES=./pkg/... ./cmd/... 16 | ALL_FLAGS= 17 | GO_FLAGS=-ldflags "$(LD_FLAGS)" 18 | GO=$(shell which go) 19 | BIN=./bin 20 | 21 | help: ## Show available commands 22 | @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 23 | 24 | build: ## Build the binary 25 | @go build $(GO_FLAGS) -o $(BIN)/$(APP_NAME) cmd/$(APP_NAME)/main.go 26 | 27 | tests: ## Run tests 28 | $(GO) test -v ./... -p 1 29 | 30 | tests-fast: ## Run fast tests (skip slow integration tests) 31 | $(GO) test -v ./... -p 1 -timeout 5m -short 32 | 33 | fmt: ## Format code 34 | @go fmt $(GO_PACKAGES) 35 | 36 | lint: ## Run linter 37 | @go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest 38 | @golangci-lint run $(GO_PACKAGES) 39 | 40 | install: build ## Install binary and completion scripts 41 | @mkdir -p ~/bin 42 | @cp $(BIN)/$(APP_NAME) ~/bin/ 43 | @if ! npm list -g @layr-labs/zeus@1.5.9 >/dev/null 2>&1; then \ 44 | echo "Installing @layr-labs/zeus@1.5.9..."; \ 45 | npm install -g @layr-labs/zeus@1.5.9; \ 46 | fi 47 | @mkdir -p ~/.local/share/bash-completion/completions 48 | @mkdir -p ~/.zsh/completions 49 | @cp autocomplete/bash_autocomplete ~/.local/share/bash-completion/completions/devkit 50 | @cp autocomplete/zsh_autocomplete ~/.zsh/completions/_devkit 51 | @if [ "$(shell echo $$SHELL)" = "/bin/zsh" ] || [ "$(shell echo $$SHELL)" = "/usr/bin/zsh" ]; then \ 52 | if ! grep -q "# DevKit CLI completions" ~/.zshrc 2>/dev/null; then \ 53 | echo "" >> ~/.zshrc; \ 54 | echo "# DevKit CLI completions" >> ~/.zshrc; \ 55 | echo "fpath=(~/.zsh/completions \$$fpath)" >> ~/.zshrc; \ 56 | echo "autoload -U compinit && compinit" >> ~/.zshrc; \ 57 | echo "PROG=devkit" >> ~/.zshrc; \ 58 | echo "source ~/.zsh/completions/_devkit" >> ~/.zshrc; \ 59 | echo "Restart your shell or Run: source ~/.zshrc to enable completions in current shell"; \ 60 | fi; \ 61 | elif [ "$(shell echo $$SHELL)" = "/bin/bash" ] || [ "$(shell echo $$SHELL)" = "/usr/bin/bash" ]; then \ 62 | if ! grep -q "# DevKit CLI completions" ~/.bashrc 2>/dev/null; then \ 63 | echo "" >> ~/.bashrc; \ 64 | echo "# DevKit CLI completions" >> ~/.bashrc; \ 65 | echo "PROG=devkit" >> ~/.bashrc; \ 66 | echo "source ~/.local/share/bash-completion/completions/devkit" >> ~/.bashrc; \ 67 | echo "Restart your shell or Run: source ~/.bashrc to enable completions in current shell"; \ 68 | fi; \ 69 | fi 70 | @echo "" 71 | 72 | install-completion: ## Install shell completion for current session 73 | @if [ "$(shell echo $$0)" = "zsh" ] || [ "$(shell echo $$SHELL)" = "/bin/zsh" ] || [ "$(shell echo $$SHELL)" = "/usr/bin/zsh" ]; then \ 74 | echo "Setting up Zsh completion for current session..."; \ 75 | echo "Run: PROG=devkit source $(PWD)/autocomplete/zsh_autocomplete"; \ 76 | else \ 77 | echo "Setting up Bash completion for current session..."; \ 78 | echo "Run: PROG=devkit source $(PWD)/autocomplete/bash_autocomplete"; \ 79 | fi 80 | 81 | clean: ## Remove binary 82 | @rm -f $(APP_NAME) ~/bin/$(APP_NAME) 83 | 84 | build/darwin-arm64: 85 | CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 $(ALL_FLAGS) $(GO) build $(GO_FLAGS) -o release/darwin-arm64/devkit cmd/$(APP_NAME)/main.go 86 | 87 | build/darwin-amd64: 88 | CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 $(ALL_FLAGS) $(GO) build $(GO_FLAGS) -o release/darwin-amd64/devkit cmd/$(APP_NAME)/main.go 89 | 90 | build/linux-arm64: 91 | CGO_ENABLED=0 GOOS=linux GOARCH=arm64 $(ALL_FLAGS) $(GO) build $(GO_FLAGS) -o release/linux-arm64/devkit cmd/$(APP_NAME)/main.go 92 | 93 | build/linux-amd64: 94 | CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(ALL_FLAGS) $(GO) build $(GO_FLAGS) -o release/linux-amd64/devkit cmd/$(APP_NAME)/main.go 95 | 96 | 97 | .PHONY: release 98 | release: 99 | $(MAKE) build/darwin-arm64 100 | $(MAKE) build/darwin-amd64 101 | $(MAKE) build/linux-arm64 102 | $(MAKE) build/linux-amd64 103 | -------------------------------------------------------------------------------- /pkg/template/git_reporter_test.go: -------------------------------------------------------------------------------- 1 | package template_test 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/Layr-Labs/devkit-cli/pkg/common/iface" 8 | "github.com/Layr-Labs/devkit-cli/pkg/common/logger" 9 | "github.com/Layr-Labs/devkit-cli/pkg/template" 10 | ) 11 | 12 | type mockTracker struct { 13 | // Percentage by module 14 | perc map[string]int 15 | label map[string]string 16 | clears int 17 | } 18 | 19 | func newMockTracker() *mockTracker { 20 | return &mockTracker{ 21 | perc: make(map[string]int), 22 | label: make(map[string]string), 23 | } 24 | } 25 | 26 | // Set is called by ProgressLogger.SetProgress 27 | func (f *mockTracker) Set(id string, pct int, displayText string) { 28 | // record only the max pct seen 29 | if old, ok := f.perc[id]; !ok || pct > old { 30 | f.perc[id] = pct 31 | f.label[id] = displayText 32 | } 33 | } 34 | 35 | // Render is no-op here 36 | func (f *mockTracker) Render() {} 37 | 38 | // Clear is called when progress is cleared 39 | func (f *mockTracker) Clear() { 40 | f.clears++ 41 | } 42 | 43 | // ProgressRows is a no-op here 44 | func (s *mockTracker) ProgressRows() []iface.ProgressRow { return make([]iface.ProgressRow, 0) } 45 | 46 | func TestCloneReporterEndToEnd(t *testing.T) { 47 | log := logger.NewNoopLogger() 48 | mock := newMockTracker() 49 | progLogger := *logger.NewProgressLogger(log, mock) 50 | 51 | // Create the reporter for a repo named "foo" 52 | rep := template.NewCloneReporter("https://example.com/foo.git", progLogger, nil) 53 | 54 | // Simulate events 55 | events := []template.CloneEvent{ 56 | {Type: template.EventProgress, Module: "foo", Progress: 100, Ref: "main"}, 57 | {Type: template.EventSubmoduleDiscovered, Parent: ".", Name: "modA", URL: "uA", Ref: "main"}, 58 | {Type: template.EventSubmoduleCloneStart, Parent: ".", Module: "modA", Ref: "main"}, 59 | {Type: template.EventProgress, Parent: ".", Module: "modA", Progress: 50, Ref: "main"}, 60 | {Type: template.EventProgress, Parent: ".", Module: "modA", Progress: 75, Ref: "main"}, 61 | {Type: template.EventCloneComplete}, 62 | } 63 | for _, ev := range events { 64 | rep.Report(ev) 65 | } 66 | 67 | // After completion, we expect: 68 | // - For repo root "foo": final percentage 100 69 | // - For modA: final percentage 100 70 | if pct, ok := mock.perc["modA"]; !ok || pct != 100 { 71 | t.Errorf("modA expected 100%%, got %d%%", pct) 72 | } 73 | if pct, ok := mock.perc["foo"]; !ok || pct != 100 { 74 | t.Errorf("foo expected 100%%, got %d%%", pct) 75 | } 76 | 77 | // We also expect that the displayText for foo contains the ref 78 | if lbl, ok := mock.label["foo"]; !ok || !strings.Contains(lbl, "Cloning from ref: main") { 79 | t.Errorf("foo label expected to mention ref, got %q", lbl) 80 | } 81 | 82 | // And that Clear was called at least once (at end) 83 | if mock.clears == 0 { 84 | t.Error("expected at least one Clear() call") 85 | } 86 | } 87 | 88 | func TestCloneReporterSubmoduleDiscoveryGrouping(t *testing.T) { 89 | log := logger.NewNoopLogger() 90 | mock := newMockTracker() 91 | progLogger := *logger.NewProgressLogger(log, mock) 92 | 93 | rep := template.NewCloneReporter("https://example.com/bar.git", progLogger, nil) 94 | 95 | // Two discoveries under same parent, then start 96 | rep.Report(template.CloneEvent{Type: template.EventSubmoduleDiscovered, Parent: "p1/", Name: "a", URL: "uA"}) 97 | rep.Report(template.CloneEvent{Type: template.EventSubmoduleDiscovered, Parent: "p1/", Name: "b", URL: "uB"}) 98 | // Now trigger the clone start: should flush both 99 | rep.Report(template.CloneEvent{Type: template.EventSubmoduleCloneStart, Parent: "p1/", Module: "a"}) 100 | 101 | // That flush should have called Clear() once 102 | if mock.clears != 1 { 103 | t.Errorf("expected Clear after submodule flush, got %d", mock.clears) 104 | } 105 | } 106 | 107 | func TestCloneReporterFallbackRootProgress(t *testing.T) { 108 | log := logger.NewNoopLogger() 109 | mock := newMockTracker() 110 | progLogger := *logger.NewProgressLogger(log, mock) 111 | 112 | rep := template.NewCloneReporter("https://example.com/baz.git", progLogger, nil) 113 | 114 | // Emit a Progress event with Module="" 115 | rep.Report(template.CloneEvent{Type: template.EventProgress, Module: "", Progress: 33, Ref: "dev"}) 116 | 117 | // We should have seen update to 33% 118 | if pct, ok := mock.perc["baz"]; !ok || pct != 33 { 119 | t.Errorf("baz expected 33%%, got %d%%", pct) 120 | } 121 | if lbl, ok := mock.label["baz"]; !ok || !strings.Contains(lbl, "Cloning from ref: dev") { 122 | t.Errorf("baz label should show ref, got %q", lbl) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /pkg/common/global_config.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "gopkg.in/yaml.v3" 9 | ) 10 | 11 | // GlobalConfig contains user-level configuration that persists across all devkit usage 12 | type GlobalConfig struct { 13 | // FirstRun tracks if this is the user's first time running devkit 14 | FirstRun bool `yaml:"first_run"` 15 | // TelemetryEnabled stores the user's global telemetry preference 16 | TelemetryEnabled *bool `yaml:"telemetry_enabled,omitempty"` 17 | // The users uuid to identify user across projects 18 | UserUUID string `yaml:"user_uuid"` 19 | } 20 | 21 | // GetGlobalConfigDir returns the XDG-compliant directory where global devkit config should be stored 22 | func GetGlobalConfigDir() (string, error) { 23 | // First check XDG_CONFIG_HOME 24 | configHome := os.Getenv("XDG_CONFIG_HOME") 25 | 26 | var baseDir string 27 | if configHome != "" && filepath.IsAbs(configHome) { 28 | baseDir = configHome 29 | } else { 30 | // Fall back to ~/.config 31 | homeDir, err := os.UserHomeDir() 32 | if err != nil { 33 | return "", fmt.Errorf("unable to determine home directory: %w", err) 34 | } 35 | baseDir = filepath.Join(homeDir, ".config") 36 | } 37 | 38 | return filepath.Join(baseDir, "devkit"), nil 39 | } 40 | 41 | // GetGlobalConfigPath returns the full path to the global config file 42 | func GetGlobalConfigPath() (string, error) { 43 | configDir, err := GetGlobalConfigDir() 44 | if err != nil { 45 | return "", err 46 | } 47 | return filepath.Join(configDir, GlobalConfigFile), nil 48 | } 49 | 50 | // LoadGlobalConfig loads the global configuration, creating defaults if needed 51 | func LoadGlobalConfig() (*GlobalConfig, error) { 52 | configPath, err := GetGlobalConfigPath() 53 | if err != nil { 54 | // If we can't determine config path (e.g., no home directory), 55 | // return first-run defaults so the CLI doesn't fail completely 56 | return &GlobalConfig{ 57 | FirstRun: true, 58 | }, nil 59 | } 60 | 61 | // If file doesn't exist, return defaults for first run 62 | if _, err := os.Stat(configPath); os.IsNotExist(err) { 63 | return &GlobalConfig{ 64 | FirstRun: true, 65 | }, nil 66 | } 67 | 68 | data, err := os.ReadFile(configPath) 69 | if err != nil { 70 | return nil, fmt.Errorf("failed to read config file: %w", err) 71 | } 72 | 73 | var config GlobalConfig 74 | if err := yaml.Unmarshal(data, &config); err != nil { 75 | return nil, fmt.Errorf("failed to parse config file: %w", err) 76 | } 77 | 78 | return &config, nil 79 | } 80 | 81 | // SaveGlobalConfig saves the global configuration to disk 82 | func SaveGlobalConfig(config *GlobalConfig) error { 83 | configPath, err := GetGlobalConfigPath() 84 | if err != nil { 85 | return fmt.Errorf("cannot save global config (unable to determine config directory): %w", err) 86 | } 87 | 88 | // Ensure directory exists 89 | configDir := filepath.Dir(configPath) 90 | if err := os.MkdirAll(configDir, 0755); err != nil { 91 | return fmt.Errorf("failed to create config directory: %w", err) 92 | } 93 | 94 | data, err := yaml.Marshal(config) 95 | if err != nil { 96 | return fmt.Errorf("failed to marshal config: %w", err) 97 | } 98 | 99 | if err := os.WriteFile(configPath, data, 0644); err != nil { 100 | return fmt.Errorf("failed to write config file: %w", err) 101 | } 102 | 103 | return nil 104 | } 105 | 106 | // GetGlobalTelemetryPreference returns the global telemetry preference 107 | func GetGlobalTelemetryPreference() (*bool, error) { 108 | config, err := LoadGlobalConfig() 109 | if err != nil { 110 | return nil, err 111 | } 112 | return config.TelemetryEnabled, nil 113 | } 114 | 115 | // SetGlobalTelemetryPreference sets the global telemetry preference 116 | func SetGlobalTelemetryPreference(enabled bool) error { 117 | config, err := LoadGlobalConfig() 118 | if err != nil { 119 | return err 120 | } 121 | 122 | config.TelemetryEnabled = &enabled 123 | config.FirstRun = false // No longer first run after setting preference 124 | 125 | return SaveGlobalConfig(config) 126 | } 127 | 128 | // MarkFirstRunComplete marks that the first run has been completed 129 | func MarkFirstRunComplete() error { 130 | config, err := LoadGlobalConfig() 131 | if err != nil { 132 | return err 133 | } 134 | 135 | config.FirstRun = false 136 | 137 | return SaveGlobalConfig(config) 138 | } 139 | 140 | // IsFirstRun checks if this is the user's first time running devkit 141 | func IsFirstRun() (bool, error) { 142 | config, err := LoadGlobalConfig() 143 | if err != nil { 144 | return false, err 145 | } 146 | return config.FirstRun, nil 147 | } 148 | -------------------------------------------------------------------------------- /pkg/common/constants.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | // Project structure constants 4 | const ( 5 | // L1 and L2 config names 6 | L1 = "l1" 7 | L2 = "l2" 8 | 9 | // ContractsDir is the subdirectory name for contract components 10 | ContractsDir = "contracts" 11 | 12 | // Makefile is the name of the makefile used for root level operations 13 | Makefile = "Makefile" 14 | 15 | // ContractsMakefile is the name of the makefile used for contract level operations 16 | ContractsMakefile = "Makefile" 17 | 18 | // GlobalConfigFile is the name of the global YAML used to store global config details (eg, user_id) 19 | GlobalConfigFile = "config.yaml" 20 | 21 | // Filename for devkit project config 22 | BaseConfig = "config.yaml" 23 | 24 | // Filename for zeus config 25 | ZeusConfig = ".zeus" 26 | 27 | // Docker open timeout 28 | DockerOpenTimeoutSeconds = 10 29 | 30 | // Docker open retry interval in milliseconds 31 | DockerOpenRetryIntervalMilliseconds = 500 32 | 33 | // CrossChainRegistryOwnerAddress is the address of the owner of the cross chain registry 34 | CrossChainRegistryOwnerAddress = "0xb094Ba769b4976Dc37fC689A76675f31bc4923b0" 35 | 36 | // Curve type constants/enums for KeyRegistrar 37 | CURVE_TYPE_KEY_REGISTRAR_UNKNOWN = 0 38 | CURVE_TYPE_KEY_REGISTRAR_ECDSA = 1 39 | CURVE_TYPE_KEY_REGISTRAR_BN254 = 2 40 | 41 | // These are fallback EigenLayer deployment addresses when not specified in context (seploia) 42 | SEPOLIA_ALLOCATION_MANAGER_ADDRESS = "0x42583067658071247ec8CE0A516A58f682002d07" 43 | SEPOLIA_DELEGATION_MANAGER_ADDRESS = "0xD4A7E1Bd8015057293f0D0A557088c286942e84b" 44 | SEPOLIA_STRATEGY_MANAGER_ADDRESS = "0x2E3D6c0744b10eb0A4e6F679F71554a39Ec47a5D" 45 | SEPOLIA_KEY_REGISTRAR_ADDRESS = "0xA4dB30D08d8bbcA00D40600bee9F029984dB162a" 46 | SEPOLIA_CROSS_CHAIN_REGISTRY_ADDRESS = "0x287381B1570d9048c4B4C7EC94d21dDb8Aa1352a" 47 | SEPOLIA_EIGEN_CONTRACT_ADDRESS = "0x3B78576F7D6837500bA3De27A60c7f594934027E" 48 | SEPOLIA_RELEASE_MANAGER_ADDRESS = "0xd9Cb89F1993292dEC2F973934bC63B0f2A702776" 49 | SEPOLIA_L1_TASK_MAILBOX_ADDRESS = "0xB99CC53e8db7018f557606C2a5B066527bF96b26" 50 | SEPOLIA_L1_OPERATOR_TABLE_UPDATER_ADDRESS = "0xB02A15c6Bd0882b35e9936A9579f35FB26E11476" 51 | SEPOLIA_PERMISSION_CONTROLLER_ADDRESS = "0x44632dfBdCb6D3E21EF613B0ca8A6A0c618F5a37" 52 | 53 | // These are fallback EigenLayer Middleware deployment addresses when not specified in context 54 | SEPOLIA_BN254_TABLE_CALCULATOR_ADDRESS = "0xa19E3B00cf4aC46B5e6dc0Bbb0Fb0c86D0D65603" 55 | SEPOLIA_ECDSA_TABLE_CALCULATOR_ADDRESS = "0xaCB5DE6aa94a1908E6FA577C2ade65065333B450" 56 | 57 | // These are L2 fallback addresses 58 | SEPOLIA_L2_TASK_MAILBOX_ADDRESS = "0xB99CC53e8db7018f557606C2a5B066527bF96b26" 59 | SEPOLIA_L2_OPERATOR_TABLE_UPDATER_ADDRESS = "0xB02A15c6Bd0882b35e9936A9579f35FB26E11476" 60 | SEPOLIA_BN254_CERTIFICATE_VERIFIER_ADDRESS = "0xff58A373c18268F483C1F5cA03Cf885c0C43373a" 61 | SEPOLIA_ECDSA_CERTIFICATE_VERIFIER_ADDRESS = "0xb3Cd1A457dEa9A9A6F6406c6419B1c326670A96F" 62 | 63 | // These are fallback EigenLayer deployment addresses when not specified in context (mainnet) 64 | MAINNET_ALLOCATION_MANAGER_ADDRESS = "0x948a420b8CC1d6BFd0B6087C2E7c344a2CD0bc39" 65 | MAINNET_DELEGATION_MANAGER_ADDRESS = "0x39053D51B77DC0d36036Fc1fCc8Cb819df8Ef37A" 66 | MAINNET_STRATEGY_MANAGER_ADDRESS = "0x858646372CC42E1A627fcE94aa7A7033e7CF075A" 67 | MAINNET_KEY_REGISTRAR_ADDRESS = "0x54f4bC6bDEbe479173a2bbDc31dD7178408A57A4" 68 | MAINNET_CROSS_CHAIN_REGISTRY_ADDRESS = "0x9376A5863F2193cdE13e1aB7c678F22554E2Ea2b" 69 | MAINNET_EIGEN_CONTRACT_ADDRESS = "0x3B78576F7D6837500bA3De27A60c7f594934027E" 70 | MAINNET_RELEASE_MANAGER_ADDRESS = "0xeDA3CAd031c0cf367cF3f517Ee0DC98F9bA80C8F" 71 | MAINNET_L1_OPERATOR_TABLE_UPDATER_ADDRESS = "0x5557E1fE3068A1e823cE5Dcd052c6C352E2617B5" 72 | MAINNET_L1_TASK_MAILBOX_ADDRESS = "0x132b466d9d5723531F68797519DfED701aC2C749" 73 | MAINNET_PERMISSION_CONTROLLER_ADDRESS = "0x25E5F8B1E7aDf44518d35D5B2271f114e081f0E5" 74 | 75 | // These are fallback EigenLayer Middleware deployment addresses when not specified in context 76 | MAINNET_BN254_TABLE_CALCULATOR_ADDRESS = "0x55F4b21681977F412B318eCB204cB933bD1dF57c" 77 | MAINNET_ECDSA_TABLE_CALCULATOR_ADDRESS = "0xA933CB4cbD0C4C208305917f56e0C3f51ad713Fa" 78 | 79 | // These are L2 fallback addresses 80 | MAINNET_L2_TASK_MAILBOX_ADDRESS = "0x132b466d9d5723531F68797519DfED701aC2C749" 81 | MAINNET_L2_OPERATOR_TABLE_UPDATER_ADDRESS = "0x5557E1fE3068A1e823cE5Dcd052c6C352E2617B5" 82 | MAINNET_BN254_CERTIFICATE_VERIFIER_ADDRESS = "0x3F55654b2b2b86bB11bE2f72657f9C33bf88120A" 83 | MAINNET_ECDSA_CERTIFICATE_VERIFIER_ADDRESS = "0xd0930ee96D07de4F9d493c259232222e46B6EC25" 84 | ) 85 | -------------------------------------------------------------------------------- /pkg/commands/telemetry.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/Layr-Labs/devkit-cli/pkg/common" 7 | "github.com/Layr-Labs/devkit-cli/pkg/common/iface" 8 | "github.com/urfave/cli/v2" 9 | ) 10 | 11 | // TelemetryCommand allows users to manage telemetry settings 12 | var TelemetryCommand = &cli.Command{ 13 | Name: "telemetry", 14 | Usage: "Manage telemetry settings", 15 | Flags: []cli.Flag{ 16 | &cli.BoolFlag{ 17 | Name: "enable", 18 | Usage: "Enable telemetry collection", 19 | }, 20 | &cli.BoolFlag{ 21 | Name: "disable", 22 | Usage: "Disable telemetry collection", 23 | }, 24 | &cli.BoolFlag{ 25 | Name: "status", 26 | Usage: "Show current telemetry status", 27 | }, 28 | &cli.BoolFlag{ 29 | Name: "global", 30 | Usage: "Apply setting globally (affects all projects and global default)", 31 | }, 32 | }, 33 | Action: func(cCtx *cli.Context) error { 34 | logger := common.LoggerFromContext(cCtx) 35 | 36 | enable := cCtx.Bool("enable") 37 | disable := cCtx.Bool("disable") 38 | status := cCtx.Bool("status") 39 | global := cCtx.Bool("global") 40 | 41 | // Validate flags 42 | if (enable && disable) || (!enable && !disable && !status) { 43 | return fmt.Errorf("specify exactly one of --enable, --disable, or --status") 44 | } 45 | 46 | if status { 47 | return showTelemetryStatus(logger, global) 48 | } 49 | 50 | if enable { 51 | return enableTelemetry(logger, global) 52 | } 53 | 54 | if disable { 55 | return disableTelemetry(logger, global) 56 | } 57 | 58 | return nil 59 | }, 60 | } 61 | 62 | // displayGlobalTelemetryStatus shows the global telemetry preference status 63 | func displayGlobalTelemetryStatus(logger iface.Logger, prefix string) error { 64 | globalPreference, err := common.GetGlobalTelemetryPreference() 65 | if err != nil { 66 | return fmt.Errorf("failed to get global telemetry preference: %w", err) 67 | } 68 | 69 | if globalPreference == nil { 70 | logger.Info("%s: Not set (defaults to disabled)", prefix) 71 | } else if *globalPreference { 72 | logger.Info("%s: Enabled", prefix) 73 | } else { 74 | logger.Info("%s: Disabled", prefix) 75 | } 76 | return nil 77 | } 78 | 79 | func showTelemetryStatus(logger iface.Logger, global bool) error { 80 | if global { 81 | return displayGlobalTelemetryStatus(logger, "Global telemetry") 82 | } 83 | 84 | // Show effective status (project takes precedence over global) 85 | effectivePreference, err := common.GetEffectiveTelemetryPreference() 86 | if err != nil { 87 | // If not in a project, show global preference 88 | return displayGlobalTelemetryStatus(logger, "Telemetry") 89 | } 90 | 91 | // Check if we're in a project and if there's a project-specific setting 92 | projectSettings, projectErr := common.LoadProjectSettings() 93 | if projectErr == nil && projectSettings != nil { 94 | if effectivePreference { 95 | logger.Info("Telemetry: Enabled (project setting)") 96 | } else { 97 | logger.Info("Telemetry: Disabled (project setting)") 98 | } 99 | 100 | // Also show global setting for context 101 | return displayGlobalTelemetryStatus(logger, "Global default") 102 | } else { 103 | // Not in project, show global 104 | if effectivePreference { 105 | logger.Info("Telemetry: Enabled (global setting)") 106 | } else { 107 | logger.Info("Telemetry: Disabled (global setting)") 108 | } 109 | } 110 | 111 | return nil 112 | } 113 | 114 | func enableTelemetry(logger iface.Logger, global bool) error { 115 | if global { 116 | // Set global preference only 117 | if err := common.SetGlobalTelemetryPreference(true); err != nil { 118 | return fmt.Errorf("failed to enable global telemetry: %w", err) 119 | } 120 | 121 | logger.Info("✅ Global telemetry enabled") 122 | logger.Info("New projects will inherit this setting.") 123 | return nil 124 | } 125 | 126 | // Set project-specific preference 127 | if err := common.SetProjectTelemetry(true); err != nil { 128 | return fmt.Errorf("failed to enable project telemetry: %w", err) 129 | } 130 | 131 | logger.Info("✅ Telemetry enabled for this project") 132 | return nil 133 | } 134 | 135 | func disableTelemetry(logger iface.Logger, global bool) error { 136 | if global { 137 | // Set global preference only 138 | if err := common.SetGlobalTelemetryPreference(false); err != nil { 139 | return fmt.Errorf("failed to disable global telemetry: %w", err) 140 | } 141 | 142 | logger.Info("❌ Global telemetry disabled") 143 | logger.Info("New projects will inherit this setting.") 144 | return nil 145 | } 146 | 147 | // Set project-specific preference 148 | if err := common.SetProjectTelemetry(false); err != nil { 149 | return fmt.Errorf("failed to disable project telemetry: %w", err) 150 | } 151 | 152 | logger.Info("❌ Telemetry disabled for this project") 153 | return nil 154 | } 155 | --------------------------------------------------------------------------------