Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/eigenx/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ func main() {
Commands: []*cli.Command{
commands.AppCommand,
commands.AuthCommand,
commands.BillingCommand,
commands.EnvironmentCommand,
version.VersionCommand,
commands.UndelegateCommand,
Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ go 1.24.0
toolchain go1.24.5

require (
github.com/Layr-Labs/eigenx-contracts v0.0.0-20250929214419-f0d751f5c9bc
github.com/Layr-Labs/eigenx-contracts v0.0.0-20251024194654-82395e726316
github.com/Layr-Labs/eigenx-kms v0.0.0-20251101091130-b725a6aaa815
github.com/fatih/color v1.16.0
github.com/google/go-containerregistry v0.20.6
Expand Down Expand Up @@ -90,6 +90,7 @@ require (
github.com/mr-tron/base58 v1.2.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
Expand Down
6 changes: 4 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg6
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
github.com/Layr-Labs/eigenx-contracts v0.0.0-20250929214419-f0d751f5c9bc h1:sVrs4eGkhy7x6phdPdBaKaQxMapERXWXBRsIUhswZS8=
github.com/Layr-Labs/eigenx-contracts v0.0.0-20250929214419-f0d751f5c9bc/go.mod h1:fcLQZBQ4RpTXv3Kz4cO/raRxKrfWUC+r8hSB94pSKFc=
github.com/Layr-Labs/eigenx-contracts v0.0.0-20251024194654-82395e726316 h1:s4kSOoXaWfyYP0Tl5Qx8O7lriYWqZ0eXp2szbWznO2s=
github.com/Layr-Labs/eigenx-contracts v0.0.0-20251024194654-82395e726316/go.mod h1:fcLQZBQ4RpTXv3Kz4cO/raRxKrfWUC+r8hSB94pSKFc=
github.com/Layr-Labs/eigenx-kms v0.0.0-20250929214127-72a46e110b30 h1:JoqFUpny7myDIg8Wf2ymzTpkB+WMCJIMtl7S9DOpPtU=
github.com/Layr-Labs/eigenx-kms v0.0.0-20250929214127-72a46e110b30/go.mod h1:Wi6n3O6+6iJWHQBr0ax901sz+BQsCSc9lM+Bs5yrN6A=
github.com/Layr-Labs/eigenx-kms v0.0.0-20251021053316-00feddf36619 h1:2w1MyEjorYagro7BveY19RTKT5VrlU/gwetk/IPdGOo=
Expand Down Expand Up @@ -366,6 +366,8 @@ github.com/pion/transport/v2 v2.2.1 h1:7qYnCBlpgSJNYMbLCKuSY9KbQdBFoETvPNETv0y4N
github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g=
github.com/pion/transport/v3 v3.0.1 h1:gDTlPJwROfSfz6QfSi0ZmeCSkFcnWWiiR9ES0ouANiM=
github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
Expand Down
61 changes: 48 additions & 13 deletions pkg/commands/app/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,58 +36,63 @@ func deployAction(cCtx *cli.Context) error {
return err
}

// 2. Check if docker is running, else try to start it
// 2. Check quota availability
if err := checkQuotaAvailable(cCtx, preflightCtx); err != nil {
return err
}

// 3. Check if docker is running, else try to start it
err = common.EnsureDockerIsRunning(cCtx)
if err != nil {
return err
}

// 3. Check for Dockerfile before asking for image reference
// 4. Check for Dockerfile before asking for image reference
dockerfilePath, err := utils.GetDockerfileInteractive(cCtx)
if err != nil {
return fmt.Errorf("failed to get dockerfile path: %w", err)
}
buildFromDockerfile := dockerfilePath != ""

// 4. Get image reference (context-aware based on Dockerfile decision)
// 5. Get image reference (context-aware based on Dockerfile decision)
imageRef, err := utils.GetImageReferenceInteractive(cCtx, 0, buildFromDockerfile)
if err != nil {
return fmt.Errorf("failed to get image reference: %w", err)
}

// 5. Get app name upfront (before any expensive operations)
// 6. Get app name upfront (before any expensive operations)
environment := preflightCtx.EnvironmentConfig.Name
name, err := utils.GetOrPromptAppName(cCtx, environment, imageRef)
if err != nil {
return fmt.Errorf("failed to get app name: %w", err)
}

// 6. Get environment file configuration
// 7. Get environment file configuration
envFilePath, err := utils.GetEnvFileInteractive(cCtx)
if err != nil {
return fmt.Errorf("failed to get env file path: %w", err)
}

// 7. Get instance type selection (uses first from backend as default for new apps)
// 8. Get instance type selection (uses first from backend as default for new apps)
instanceType, err := utils.GetInstanceTypeInteractive(cCtx, "")
if err != nil {
return fmt.Errorf("failed to get instance: %w", err)
}

// 8. Get log settings from flags or interactive prompt
// 9. Get log settings from flags or interactive prompt
logRedirect, publicLogs, err := utils.GetLogSettingsInteractive(cCtx)
if err != nil {
return fmt.Errorf("failed to get log settings: %w", err)
}

// 9. Generate random salt
// 10. Generate random salt
salt := [32]byte{}
_, err = rand.Read(salt[:])
if err != nil {
return fmt.Errorf("failed to generate random salt: %w", err)
}

// 10. Get app ID
// 11. Get app ID
_, appController, err := utils.GetAppControllerBinding(cCtx)
if err != nil {
return fmt.Errorf("failed to get app controller binding: %w", err)
Expand All @@ -97,25 +102,55 @@ func deployAction(cCtx *cli.Context) error {
return fmt.Errorf("failed to get app id: %w", err)
}

// 11. Prepare the release (includes build/push if needed, with automatic retry on permission errors)
// 12. Prepare the release (includes build/push if needed, with automatic retry on permission errors)
release, imageRef, err := utils.PrepareReleaseFromContext(cCtx, preflightCtx.EnvironmentConfig, appIDToBeDeployed, dockerfilePath, imageRef, envFilePath, logRedirect, instanceType, 3)
if err != nil {
return err
}

// 12. Deploy the app
// 13. Deploy the app
appID, err := preflightCtx.Caller.DeployApp(cCtx.Context, salt, release, publicLogs, imageRef)
if err != nil {
return fmt.Errorf("failed to deploy app: %w", err)
}

// 13. Save the app name mapping
// 14. Save the app name mapping
if err := common.SetAppName(environment, appID.Hex(), name); err != nil {
logger.Warn("Failed to save app name: %s", err.Error())
} else {
logger.Info("App saved with name: %s", name)
}

// 14. Watch until deployment completes
// 15. Watch until deployment completes
return utils.WatchUntilTransitionComplete(cCtx, appID, common.AppStatusDeploying)
}

// checkQuotaAvailable verifies that the user has deployment quota available
// by checking their allowlist status on the contract
func checkQuotaAvailable(cCtx *cli.Context, preflightCtx *utils.PreflightContext) error {
ctx := cCtx.Context

// Check user's quota limit from contract
maxQuota, err := preflightCtx.Caller.GetMaxActiveAppsPerUser(ctx, preflightCtx.Caller.SelfAddress)
if err != nil {
return fmt.Errorf("failed to get quota limit: %w", err)
}

// If quota is 0, user needs to subscribe
if maxQuota == 0 {
return fmt.Errorf("no app quota available. Run 'eigenx billing subscribe' to enable app deployment")
}

// Check current active app count from contract
activeCount, err := preflightCtx.Caller.GetActiveAppCount(ctx, preflightCtx.Caller.SelfAddress)
if err != nil {
return fmt.Errorf("failed to get active app count: %w", err)
}

// Check if quota is reached
if activeCount >= maxQuota {
return fmt.Errorf("app quota reached for %s (%d/%d). Please contact the Eigen team at [email protected] for additional capacity", preflightCtx.EnvironmentConfig.Name, activeCount, maxQuota)
}

return nil
}
2 changes: 1 addition & 1 deletion pkg/commands/app/info.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ func logsAction(cCtx *cli.Context) error {
case common.AppStatusStopping:
logger.Info("%s is currently stopping. Logs may be limited.", formattedApp)
return nil
case common.AppStatusStopped, common.AppStatusTerminating, common.AppStatusTerminated:
case common.AppStatusStopped, common.AppStatusTerminating, common.AppStatusTerminated, common.AppStatusSuspended:
logger.Info("%s is %s. Logs are not available.", formattedApp, strings.ToLower(status))
return nil
case common.AppStatusFailed:
Expand Down
2 changes: 1 addition & 1 deletion pkg/commands/app/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ var StopCommand = &cli.Command{

var TerminateCommand = &cli.Command{
Name: "terminate",
Usage: "Terminate app (terminate GCP instance)",
Usage: "Terminate app (terminate GCP instance) permanently",
ArgsUsage: "[app-id|name]",
Flags: append(common.GlobalFlags, []cli.Flag{
common.EnvironmentFlag,
Expand Down
16 changes: 16 additions & 0 deletions pkg/commands/billing.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package commands

import (
"github.com/Layr-Labs/eigenx-cli/pkg/commands/billing"
"github.com/urfave/cli/v2"
)

var BillingCommand = &cli.Command{
Name: "billing",
Usage: "Manage billing and subscription",
Subcommands: []*cli.Command{
billing.SubscribeCommand,
billing.CancelCommand,
billing.StatusCommand,
},
}
139 changes: 139 additions & 0 deletions pkg/commands/billing/cancel.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package billing

import (
"context"
"fmt"

"github.com/Layr-Labs/eigenx-cli/pkg/commands/utils"
"github.com/Layr-Labs/eigenx-cli/pkg/common"
"github.com/Layr-Labs/eigenx-cli/pkg/common/output"
ethcommon "github.com/ethereum/go-ethereum/common"
"github.com/urfave/cli/v2"
)

var CancelCommand = &cli.Command{
Name: "cancel",
Usage: "Cancel subscription",
Flags: append(common.GlobalFlags, []cli.Flag{
common.EnvironmentFlag,
}...),
Action: func(cCtx *cli.Context) error {
ctx := cCtx.Context
logger := common.LoggerFromContext(cCtx)
environmentConfig, err := utils.GetEnvironmentConfig(cCtx)
if err != nil {
return fmt.Errorf("failed to get environment config: %w", err)
}
envName := environmentConfig.Name

// Get API client
apiClient, err := utils.NewUserApiClient(cCtx)
if err != nil {
return fmt.Errorf("failed to create API client: %w", err)
}

// Check current subscription status
subscription, err := apiClient.GetUserSubscription(cCtx)
if err != nil {
return fmt.Errorf("failed to check subscription status: %w", err)
}

if !isSubscriptionActive(subscription.Status) {
logger.Info("You don't have an active subscription on %s.", envName)
return nil
}

// Get contract caller for current environment
caller, err := utils.GetContractCaller(cCtx)
if err != nil {
return fmt.Errorf("failed to get contract caller: %w", err)
}

// Get developer address
developerAddr, err := utils.GetDeveloperAddress(cCtx)
if err != nil {
return fmt.Errorf("failed to get developer address: %w", err)
}

// Check active apps on current environment
activeAppCount, err := caller.GetActiveAppCount(ctx, developerAddr)
if err != nil {
return fmt.Errorf("failed to get active app count: %w", err)
}

// If apps exist, show warning and get confirmation
if activeAppCount > 0 {
logger.Info("You have %d active app(s) on %s that will be suspended.", activeAppCount, envName)
logger.Info("")

confirmed, err := output.Confirm("Continue?")
if err != nil {
return fmt.Errorf("failed to get confirmation: %w", err)
}

if !confirmed {
logger.Info("Cancellation aborted.")
return nil
}

// Get only active apps for this developer
activeApps, err := getActiveAppsByCreator(ctx, caller, developerAddr)
if err != nil {
return fmt.Errorf("failed to get active apps: %w", err)
}

if len(activeApps) > 0 {
logger.Info("Suspending apps...")

// Suspend only active apps
err = caller.Suspend(ctx, developerAddr, activeApps)
if err != nil {
return fmt.Errorf("failed to suspend apps: %w", err)
}

logger.Info("✓ Apps suspended")
}
} else {
// No active apps, just confirm cancellation
logger.Warn("Canceling your subscription on %s will prevent you from deploying new apps.", envName)
confirmed, err := output.Confirm("Are you sure you want to cancel your subscription?")
if err != nil {
return fmt.Errorf("failed to get confirmation: %w", err)
}

if !confirmed {
logger.Info("Cancellation aborted.")
return nil
}
}

// Cancel subscription via API
logger.Info("Canceling subscription...")
if err := apiClient.CancelSubscription(cCtx); err != nil {
return fmt.Errorf("failed to cancel subscription: %w", err)
}

logger.Info("\n✓ Subscription canceled successfully for %s.", envName)
return nil
},
}

// getActiveAppsByCreator retrieves only the active apps (STARTED/STOPPED) for a creator
func getActiveAppsByCreator(ctx context.Context, caller *common.ContractCaller, creator ethcommon.Address) ([]ethcommon.Address, error) {
// Get all apps for this creator on this network
allApps, appConfigs, err := caller.GetAppsByCreator(ctx, creator, 0, 1_000)
if err != nil {
return nil, fmt.Errorf("failed to get apps by creator: %w", err)
}

// Filter to only active apps (STARTED/STOPPED)
var activeApps []ethcommon.Address
for i, app := range allApps {
config := appConfigs[i]
status := common.AppStatus(config.Status)
if status == common.ContractAppStatusStarted || status == common.ContractAppStatusStopped {
activeApps = append(activeApps, app)
}
}
return activeApps, nil
}
Loading