diff --git a/pkg/commands/app/deploy.go b/pkg/commands/app/deploy.go index 955ca41..020147d 100644 --- a/pkg/commands/app/deploy.go +++ b/pkg/commands/app/deploy.go @@ -113,7 +113,7 @@ func deployAction(cCtx *cli.Context) error { // 13. Collect app profile while deployment is in progress (optional) environment := preflightCtx.EnvironmentConfig.Name - suggestedName, err := utils.ExtractAndFindAvailableName(environment, imageRef) + suggestedName, err := utils.ExtractAndFindAvailableName(cCtx, environment, imageRef) if err != nil { logger.Warn("Failed to extract suggested name: %s", err.Error()) suggestedName = "" @@ -138,6 +138,11 @@ func deployAction(cCtx *cli.Context) error { logger.Warn("Failed to upload profile: %s", err.Error()) } else { logger.Info("✓ Profile uploaded successfully") + + // Invalidate profile cache to ensure fresh data on next command + if err := common.InvalidateProfileCache(); err != nil { + logger.Debug("Failed to invalidate profile cache: %v", err) + } } } } diff --git a/pkg/commands/app/info.go b/pkg/commands/app/info.go index da0dec0..816424b 100644 --- a/pkg/commands/app/info.go +++ b/pkg/commands/app/info.go @@ -140,7 +140,7 @@ func listAction(cCtx *cli.Context) error { func infoAction(cCtx *cli.Context) error { // Get app address from args or interactive selection - appID, err := utils.GetAppIDInteractive(cCtx, 0, "view") + appID, err := utils.GetAppIDInteractive(cCtx, nil, 0, "view") if err != nil { return fmt.Errorf("failed to get app address: %w", err) } @@ -158,14 +158,14 @@ func logsAction(cCtx *cli.Context) error { fmt.Println() logger := common.LoggerFromContext(cCtx) - appID, err := utils.GetAppIDInteractive(cCtx, 0, "view logs for") + resolver, err := utils.NewAppResolver(cCtx) if err != nil { - return fmt.Errorf("failed to get app address: %w", err) + return fmt.Errorf("failed to create app resolver: %w", err) } - environmentConfig, err := utils.GetEnvironmentConfig(cCtx) + appID, err := utils.GetAppIDInteractive(cCtx, resolver, 0, "view logs for") if err != nil { - return fmt.Errorf("failed to get environment config: %w", err) + return fmt.Errorf("failed to get app address: %w", err) } userApiClient, err := utils.NewUserApiClient(cCtx) @@ -173,8 +173,7 @@ func logsAction(cCtx *cli.Context) error { return fmt.Errorf("failed to create API client: %w", err) } - profileName := utils.GetAppProfileName(cCtx, appID) - formattedApp := common.FormatAppDisplay(environmentConfig.Name, appID, profileName) + formattedApp := resolver.FormatAppDisplay(appID) logs, err := userApiClient.GetLogs(cCtx, appID) watchMode := cCtx.Bool(common.WatchFlag.Name) diff --git a/pkg/commands/app/lifecycle.go b/pkg/commands/app/lifecycle.go index c23b9dd..2d22168 100644 --- a/pkg/commands/app/lifecycle.go +++ b/pkg/commands/app/lifecycle.go @@ -56,13 +56,12 @@ func startAction(cCtx *cli.Context) error { } // Get app address from args or interactive selection - appID, err := utils.GetAppIDInteractive(cCtx, 0, "start") + appID, err := utils.GetAppIDInteractive(cCtx, preflightCtx.Resolver, 0, "start") if err != nil { return fmt.Errorf("failed to get app address: %w", err) } - profileName := utils.GetAppProfileName(cCtx, appID) - formattedApp := common.FormatAppDisplay(preflightCtx.EnvironmentConfig.Name, appID, profileName) + formattedApp := preflightCtx.Resolver.FormatAppDisplay(appID) // Call AppController.StartApp err = preflightCtx.Caller.StartApp(ctx, appID) @@ -90,13 +89,12 @@ func stopAction(cCtx *cli.Context) error { } // Get app address from args or interactive selection - appID, err := utils.GetAppIDInteractive(cCtx, 0, "stop") + appID, err := utils.GetAppIDInteractive(cCtx, preflightCtx.Resolver, 0, "stop") if err != nil { return fmt.Errorf("failed to get app address: %w", err) } - profileName := utils.GetAppProfileName(cCtx, appID) - formattedApp := common.FormatAppDisplay(preflightCtx.EnvironmentConfig.Name, appID, profileName) + formattedApp := preflightCtx.Resolver.FormatAppDisplay(appID) // Call AppController.StopApp err = preflightCtx.Caller.StopApp(ctx, appID) @@ -124,7 +122,7 @@ func terminateAction(cCtx *cli.Context) error { } // Get app address from args or interactive selection - appID, err := utils.GetAppIDInteractive(cCtx, 0, "terminate") + appID, err := utils.GetAppIDInteractive(cCtx, preflightCtx.Resolver, 0, "terminate") if err != nil { return fmt.Errorf("failed to get app address: %w", err) } @@ -139,8 +137,7 @@ func terminateAction(cCtx *cli.Context) error { return err } - profileName := utils.GetAppProfileName(cCtx, appID) - logger.Info("App %s terminated successfully", common.FormatAppDisplay(preflightCtx.EnvironmentConfig.Name, appID, profileName)) + logger.Info("App %s terminated successfully", preflightCtx.Resolver.FormatAppDisplay(appID)) return utils.GetAndPrintAppInfo(cCtx, appID, common.AppStatusTerminating) } diff --git a/pkg/commands/app/name.go b/pkg/commands/app/name.go deleted file mode 100644 index a50870c..0000000 --- a/pkg/commands/app/name.go +++ /dev/null @@ -1,72 +0,0 @@ -package app - -import ( - "fmt" - - "github.com/Layr-Labs/eigenx-cli/pkg/commands/utils" - "github.com/Layr-Labs/eigenx-cli/pkg/common" - "github.com/urfave/cli/v2" -) - -var NameCommand = &cli.Command{ - Name: "name", - Usage: "Set, change, or remove a friendly name for an app", - ArgsUsage: " [new-name]", - Flags: append(common.GlobalFlags, []cli.Flag{ - common.EnvironmentFlag, - common.RpcUrlFlag, - &cli.BoolFlag{ - Name: "delete", - Aliases: []string{"d"}, - Usage: "Delete the app name", - }, - }...), - Action: nameAction, -} - -func nameAction(cCtx *cli.Context) error { - argCount := cCtx.Args().Len() - if argCount < 1 { - return fmt.Errorf("please provide app ID or current name") - } - - appIDOrName := cCtx.Args().Get(0) - - // Handle delete flag or new name - var newName string - if cCtx.Bool("delete") { - if argCount > 1 { - return fmt.Errorf("cannot specify new name when using --delete flag") - } - newName = "" - } else { - if argCount < 2 { - return fmt.Errorf("please provide new name (or use --delete to remove name)") - } - newName = cCtx.Args().Get(1) - - // Validate the friendly name - if err := common.ValidateAppName(newName); err != nil { - return fmt.Errorf("invalid app name: %w", err) - } - } - - // Get environment config for context - environmentConfig, err := utils.GetEnvironmentConfig(cCtx) - if err != nil { - return fmt.Errorf("failed to get environment config: %w", err) - } - - if err := common.SetAppName(environmentConfig.Name, appIDOrName, newName); err != nil { - return fmt.Errorf("failed to set app name: %w", err) - } - - logger := common.LoggerFromContext(cCtx) - if newName == "" { - logger.Info("App name removed successfully") - } else { - logger.Info("App name set to: %s", newName) - } - - return nil -} diff --git a/pkg/commands/app/profile.go b/pkg/commands/app/profile.go index 61a846b..1a821c4 100644 --- a/pkg/commands/app/profile.go +++ b/pkg/commands/app/profile.go @@ -35,7 +35,7 @@ func profileSetAction(cCtx *cli.Context) error { logger := common.LoggerFromContext(cCtx) // Get app ID - appID, err := utils.GetAppIDInteractive(cCtx, 0, "set profile for") + appID, err := utils.GetAppIDInteractive(cCtx, nil, 0, "set profile for") if err != nil { return err } @@ -61,6 +61,11 @@ func profileSetAction(cCtx *cli.Context) error { return fmt.Errorf("failed to upload profile: %w", err) } + // Invalidate profile cache to ensure fresh data on next command + if err := common.InvalidateProfileCache(); err != nil { + logger.Debug("Failed to invalidate profile cache: %v", err) + } + // Display success message with returned data logger.Info("✓ Profile updated successfully for app '%s'", response.Name) diff --git a/pkg/commands/app/upgrade.go b/pkg/commands/app/upgrade.go index e3016aa..a448b30 100644 --- a/pkg/commands/app/upgrade.go +++ b/pkg/commands/app/upgrade.go @@ -39,7 +39,7 @@ func upgradeAction(cCtx *cli.Context) error { } // 3. Get app ID from args or interactive selection - appID, err := utils.GetAppIDInteractive(cCtx, 0, "upgrade") + appID, err := utils.GetAppIDInteractive(cCtx, preflightCtx.Resolver, 0, "upgrade") if err != nil { return fmt.Errorf("failed to get app id: %w", err) } diff --git a/pkg/commands/utils/app_resolver.go b/pkg/commands/utils/app_resolver.go new file mode 100644 index 0000000..ee27ade --- /dev/null +++ b/pkg/commands/utils/app_resolver.go @@ -0,0 +1,210 @@ +package utils + +import ( + "fmt" + "math/big" + "strings" + "time" + + "github.com/Layr-Labs/eigenx-cli/pkg/common" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + ethcommon "github.com/ethereum/go-ethereum/common" + "github.com/urfave/cli/v2" +) + +// AppResolver provides centralized app name/ID resolution with caching. +// It checks remote profile names first, then falls back to local registry (legacy). +type AppResolver struct { + cCtx *cli.Context + environmentName string + apps []ethcommon.Address + profileNames map[string]string // appID.Hex() → profile name + localRegistry *common.AppRegistry + cacheInitialized bool +} + +// NewAppResolver creates a new resolver instance for the current environment +func NewAppResolver(cCtx *cli.Context) (*AppResolver, error) { + environmentConfig, err := GetEnvironmentConfig(cCtx) + if err != nil { + return nil, fmt.Errorf("failed to get environment config: %w", err) + } + + return &AppResolver{ + cCtx: cCtx, + environmentName: environmentConfig.Name, + profileNames: make(map[string]string), + }, nil +} + +// ResolveAppID resolves an app ID or name by checking remote profile names first, +// then falling back to the local registry (legacy) +func (r *AppResolver) ResolveAppID(nameOrID string) (ethcommon.Address, error) { + // First check if it's already a valid hex address + if ethcommon.IsHexAddress(nameOrID) { + return ethcommon.HexToAddress(nameOrID), nil + } + + // Ensure cache is initialized + if err := r.ensureCacheInitialized(); err != nil { + return ethcommon.Address{}, err + } + + // Check remote profile names first (case-insensitive) + nameOrIDLower := strings.ToLower(nameOrID) + for appHex, profileName := range r.profileNames { + if strings.ToLower(profileName) == nameOrIDLower { + return ethcommon.HexToAddress(appHex), nil + } + } + + // Not found remotely, fall back to local registry + if app, exists := r.localRegistry.Apps[nameOrID]; exists { + return ethcommon.HexToAddress(app.AppID), nil + } + + return ethcommon.Address{}, fmt.Errorf("app not found: %s", nameOrID) +} + +// GetAppName returns the app name by checking remote profile first, +// then falling back to local registry +func (r *AppResolver) GetAppName(appID ethcommon.Address) string { + // Ensure cache is initialized + if err := r.ensureCacheInitialized(); err != nil { + return "" + } + + // Check remote profile first + if profileName, exists := r.profileNames[appID.Hex()]; exists && profileName != "" { + return profileName + } + + // Fall back to local registry (case-insensitive lookup) + appIDLower := strings.ToLower(appID.Hex()) + for name, app := range r.localRegistry.Apps { + if strings.ToLower(app.AppID) == appIDLower { + return name + } + } + + return "" +} + +// IsNameAvailable checks if a name is available by checking both remote profiles +// and local registry +func (r *AppResolver) IsNameAvailable(name string) bool { + // Ensure cache is initialized + if err := r.ensureCacheInitialized(); err != nil { + return false // If we can't check, assume not available to be safe + } + + // Check remote profile names (case-insensitive) + nameLower := strings.ToLower(name) + for _, profileName := range r.profileNames { + if strings.ToLower(profileName) == nameLower { + return false // Name is taken + } + } + + // Check local registry + _, existsInLocal := r.localRegistry.Apps[name] + return !existsInLocal +} + +// FindAvailableName finds an available variant of the base name by appending numbers +func (r *AppResolver) FindAvailableName(baseName string) string { + if r.IsNameAvailable(baseName) { + return baseName + } + + // Try appending numbers until we find an available name + for i := 2; i <= 100; i++ { + candidate := fmt.Sprintf("%s-%d", baseName, i) + if r.IsNameAvailable(candidate) { + return candidate + } + } + + // Fallback: return base name with large random-ish suffix + return fmt.Sprintf("%s-new", baseName) +} + +// GetAllApps returns the cached list of app addresses +func (r *AppResolver) GetAllApps() ([]ethcommon.Address, error) { + if err := r.ensureCacheInitialized(); err != nil { + return nil, err + } + return r.apps, nil +} + +// FormatAppDisplay returns a user-friendly display string for an app +// Returns "name (0x123...)" if name exists, or just "0x123..." if no name +// Checks remote profile first, then falls back to local registry +func (r *AppResolver) FormatAppDisplay(appID ethcommon.Address) string { + name := r.GetAppName(appID) + if name != "" { + return fmt.Sprintf("%s (%s)", name, appID.Hex()) + } + return appID.Hex() +} + +// ensureCacheInitialized lazily loads app list and profile names once per resolver instance +func (r *AppResolver) ensureCacheInitialized() error { + if r.cacheInitialized { + return nil + } + + // Fetch apps from contract + client, appController, err := GetAppControllerBinding(r.cCtx) + if err != nil { + return fmt.Errorf("failed to get app controller binding: %w", err) + } + defer client.Close() + + developerAddr, err := GetDeveloperAddress(r.cCtx) + if err != nil { + return fmt.Errorf("failed to get developer address: %w", err) + } + + result, err := appController.GetAppsByDeveloper(&bind.CallOpts{Context: r.cCtx.Context}, developerAddr, big.NewInt(0), big.NewInt(100)) + if err != nil { + return fmt.Errorf("failed to get apps: %w", err) + } + + r.apps = result.Apps + + // Try to load cached profile names (24-hour TTL) + cachedProfiles, cacheTimestamp, err := common.LoadProfileCache(r.environmentName) + if err != nil { + cachedProfiles = make(map[string]string) + cacheTimestamp = 0 + } + + // Check if cache is fresh (< 24 hours old) + now := time.Now().Unix() + cacheAge := time.Duration(now-cacheTimestamp) * time.Second + cacheFresh := cacheTimestamp > 0 && cacheAge < 24*time.Hour + + if cacheFresh { + // Use cached profiles + r.profileNames = cachedProfiles + } else { + // Cache is stale or missing, fetch from API + r.profileNames = getProfileNamesForApps(r.cCtx, r.apps) + + // Save to cache + _ = common.SaveProfileCache(r.environmentName, r.profileNames) + } + + // Load local registry + registry, err := common.LoadAppRegistry(r.environmentName) + if err != nil { + // Local registry might not exist yet, that's okay + r.localRegistry = &common.AppRegistry{Apps: make(map[string]common.App)} + } else { + r.localRegistry = registry + } + + r.cacheInitialized = true + return nil +} diff --git a/pkg/commands/utils/contract_utils.go b/pkg/commands/utils/contract_utils.go index 8d83c22..ad4d315 100644 --- a/pkg/commands/utils/contract_utils.go +++ b/pkg/commands/utils/contract_utils.go @@ -22,35 +22,6 @@ import ( "github.com/urfave/cli/v2" ) -// GetAppID gets the app id from CLI args or auto-detects from project context. App id is the address of the app contract on L1. -func GetAppID(cCtx *cli.Context, argIndex int) (ethcommon.Address, error) { - // Check if app_id provided as argument - if cCtx.Args().Len() > argIndex { - nameOrID := cCtx.Args().Get(argIndex) - - // Get environment config for context - environmentConfig, err := GetEnvironmentConfig(cCtx) - if err != nil { - return ethcommon.Address{}, fmt.Errorf("failed to get environment config: %w", err) - } - - // First try to resolve as a name from the registry - resolvedID, err := common.ResolveAppID(environmentConfig.Name, nameOrID) - if err == nil { - return ethcommon.HexToAddress(resolvedID), nil - } - - // If not a name, check if it's a valid hex address - if ethcommon.IsHexAddress(nameOrID) { - return ethcommon.HexToAddress(nameOrID), nil - } - - return ethcommon.Address{}, fmt.Errorf("invalid app id or name: %s", nameOrID) - } - - return ethcommon.Address{}, fmt.Errorf("app id or name required. Provide as argument or ensure you're in a project directory with deployment info") -} - func GetAppControllerBinding(cCtx *cli.Context) (*ethclient.Client, *AppController.AppController, error) { environmentConfig, err := GetEnvironmentConfig(cCtx) if err != nil { @@ -121,6 +92,7 @@ func GetContractCaller(cCtx *cli.Context) (*common.ContractCaller, error) { environmentConfig, client, logger, + nil, ) if err != nil { return nil, fmt.Errorf("failed to create contract caller: %w", err) @@ -176,21 +148,6 @@ func CalculateAndSignApiPermissionDigest( return signature, nil } -// GetAppProfileName fetches the profile name for an app from the API -// Returns empty string if profile doesn't exist or API call fails -func GetAppProfileName(cCtx *cli.Context, appID ethcommon.Address) string { - userApiClient, err := NewUserApiClient(cCtx) - if err != nil { - return "" - } - - info, err := userApiClient.GetInfos(cCtx, []ethcommon.Address{appID}, 1) - if err == nil && len(info.Apps) > 0 && info.Apps[0].Profile != nil { - return info.Apps[0].Profile.Name - } - return "" -} - func GetAndPrintAppInfo(cCtx *cli.Context, appID ethcommon.Address, statusOverride ...string) error { logger := common.LoggerFromContext(cCtx) @@ -272,7 +229,7 @@ func PrintAppInfoWithStatus(ctx context.Context, logger iface.Logger, client *et // Show app name - prioritize profile name, fall back to local registry if info.Profile != nil && info.Profile.Name != "" { logger.Info("App Name: %s", info.Profile.Name) - } else if name := common.GetAppName(environmentName, appID.Hex()); name != "" { + } else if name := common.GetAppNameFromLocalRegistry(environmentName, appID.Hex()); name != "" { logger.Info("App Name: %s", name) } diff --git a/pkg/commands/utils/interactive.go b/pkg/commands/utils/interactive.go index 553ee41..427a9cd 100644 --- a/pkg/commands/utils/interactive.go +++ b/pkg/commands/utils/interactive.go @@ -239,14 +239,32 @@ func GetLayeredTargetImageInteractive(cCtx *cli.Context, sourceImageRef string) } // GetAppIDInteractive gets app ID from args or interactive selection -func GetAppIDInteractive(cCtx *cli.Context, argIndex int, action string) (ethcommon.Address, error) { - // First try to get from args - appID, err := GetAppID(cCtx, argIndex) - if err == nil { - return appID, nil +// If resolver is provided, uses it to avoid duplicate API calls. +// If resolver is nil, creates a new one. +func GetAppIDInteractive(cCtx *cli.Context, resolver *AppResolver, argIndex int, action string) (ethcommon.Address, error) { + // Create resolver if not provided + if resolver == nil { + var err error + resolver, err = NewAppResolver(cCtx) + if err != nil { + return ethcommon.Address{}, fmt.Errorf("failed to create app resolver: %w", err) + } + } + + // Check if app ID or name provided as argument + if cCtx.Args().Len() > argIndex { + nameOrID := cCtx.Args().Get(argIndex) + + // Try to resolve (checks remote profiles first, then local registry) + resolvedID, err := resolver.ResolveAppID(nameOrID) + if err == nil { + return resolvedID, nil + } + // Resolution failed - return error since user explicitly provided a name/ID + return ethcommon.Address{}, fmt.Errorf("failed to resolve app '%s': %w", nameOrID, err) } - // If not provided, show interactive selection + // If no argument provided, show interactive selection message fmt.Printf("\nSelect an app to %s:\n", action) // Get list of apps for the user @@ -270,12 +288,6 @@ func GetAppIDInteractive(cCtx *cli.Context, argIndex int, action string) (ethcom return ethcommon.Address{}, fmt.Errorf("no apps found for your address") } - // Get environment config for context - environmentConfig, err := GetEnvironmentConfig(cCtx) - if err != nil { - return ethcommon.Address{}, fmt.Errorf("failed to get environment config: %w", err) - } - // Build apps list with status priority type appItem struct { addr ethcommon.Address @@ -288,9 +300,6 @@ func GetAppIDInteractive(cCtx *cli.Context, argIndex int, action string) (ethcom // Get API statuses for all Started apps to identify which have exited exitedApps := getExitedApps(cCtx, result.Apps, result.AppConfigsMem) - // Get profile names for all apps from API (for better display in selection list) - profileNames := getProfileNamesForApps(cCtx, result.Apps) - // Determine which apps are eligible for the action isEligible := func(status common.AppStatus, addr ethcommon.Address) bool { switch action { @@ -319,10 +328,7 @@ func GetAppIDInteractive(cCtx *cli.Context, argIndex int, action string) (ethcom statusStr = "Exited" } - // Prioritize API profile name, fall back to local registry - profileName := profileNames[appAddr.Hex()] - displayName := common.FormatAppDisplay(environmentConfig.Name, appAddr, profileName) - + displayName := resolver.FormatAppDisplay(appAddr) appItems = append(appItems, appItem{ addr: appAddr, config: config, @@ -384,75 +390,26 @@ func GetAppIDInteractive(cCtx *cli.Context, argIndex int, action string) (ethcom return ethcommon.Address{}, fmt.Errorf("failed to find selected app") } -// GetOrPromptAppName gets app name from flag or prompts interactively -func GetOrPromptAppName(cCtx *cli.Context, context string, imageRef string) (string, error) { - // Check if provided via flag - if name := cCtx.String(common.NameFlag.Name); name != "" { - // Validate the provided name - if err := common.ValidateAppName(name); err != nil { - return "", fmt.Errorf("invalid app name: %w", err) - } - // Check if it's available - if !IsAppNameAvailable(context, name) { - fmt.Printf("Warning: App name '%s' is already taken.\n", name) - return GetAvailableAppNameInteractive(context, imageRef) - } - return name, nil - } - - // No flag provided, get interactively - return GetAvailableAppNameInteractive(context, imageRef) -} - // ExtractAndFindAvailableName extracts a base name from imageRef and finds an available variant -func ExtractAndFindAvailableName(context, imageRef string) (string, error) { +func ExtractAndFindAvailableName(cCtx *cli.Context, context, imageRef string) (string, error) { baseName, err := extractAppNameFromImage(imageRef) if err != nil { return "", fmt.Errorf("failed to extract app name from image reference %s: %w", imageRef, err) } - return findAvailableName(context, baseName), nil + return findAvailableName(cCtx, nil, baseName), nil } -// GetAvailableAppNameInteractive interactively gets an available app name -func GetAvailableAppNameInteractive(context, imageRef string) (string, error) { - // Start with a suggestion from the image - suggestedName, err := ExtractAndFindAvailableName(context, imageRef) - if err != nil { - return "", err - } - - for { - fmt.Printf("\nApp name selection:\n") - name, err := output.InputString( - "Enter app name:", - fmt.Sprintf("A friendly name to identify your app (suggested: %s)", suggestedName), - suggestedName, - common.ValidateAppName, - ) +// IsAppNameAvailable checks if an app name is available in the given context. +// If resolver is provided, uses it; otherwise creates a new one. +func IsAppNameAvailable(cCtx *cli.Context, resolver *AppResolver, name string) bool { + if resolver == nil { + var err error + resolver, err = NewAppResolver(cCtx) if err != nil { - // If input fails, use the suggestion - name = suggestedName - } - - // Check if the name is available - if IsAppNameAvailable(context, name) { - return name, nil + return false // If we can't check, assume not available to be safe } - - // Name is taken, suggest alternatives and loop - fmt.Printf("App name '%s' is already taken.\n", name) - - // Suggest alternatives based on their input - suggestedName = findAvailableName(context, name) - fmt.Printf("Suggested alternative: %s\n", suggestedName) } -} - -// IsAppNameAvailable checks if an app name is available in the given context -func IsAppNameAvailable(context, name string) bool { - apps, _ := common.ListApps(context) - _, exists := apps[name] - return !exists + return resolver.IsNameAvailable(name) } // GetEnvFileInteractive prompts for env file path if not provided @@ -717,25 +674,18 @@ func displayDetectedRegistries(registries []registryInfo, appName string) { fmt.Println() } -// findAvailableName finds an available name by appending numbers if needed -func findAvailableName(context, baseName string) string { - apps, _ := common.ListApps(context) - - // Check if base name is available - if _, exists := apps[baseName]; !exists { - return baseName - } - - // Try with incrementing numbers - for i := 2; i <= 100; i++ { - candidate := fmt.Sprintf("%s-%d", baseName, i) - if _, exists := apps[candidate]; !exists { - return candidate +// findAvailableName finds an available name by appending numbers if needed. +// If resolver is provided, uses it; otherwise creates a new one. +func findAvailableName(cCtx *cli.Context, resolver *AppResolver, baseName string) string { + if resolver == nil { + var err error + resolver, err = NewAppResolver(cCtx) + if err != nil { + // Fallback to timestamp if resolver fails + return fmt.Sprintf("%s-%d", baseName, time.Now().Unix()) } } - - // Fallback to timestamp if somehow we have 100+ duplicates - return fmt.Sprintf("%s-%d", baseName, time.Now().Unix()) + return resolver.FindAvailableName(baseName) } // extractAppNameFromImage extracts the app name from an image reference diff --git a/pkg/commands/utils/preflight.go b/pkg/commands/utils/preflight.go index 1f90fe8..d01212e 100644 --- a/pkg/commands/utils/preflight.go +++ b/pkg/commands/utils/preflight.go @@ -17,6 +17,7 @@ type PreflightContext struct { Caller *common.ContractCaller EnvironmentConfig *common.EnvironmentConfig Client *ethclient.Client + Resolver *AppResolver PrivateKey string } @@ -58,13 +59,20 @@ func DoPreflightChecks(cCtx *cli.Context) (*PreflightContext, error) { return nil, fmt.Errorf("failed to get chain ID from %s: %w", rpcURL, err) } - // 6. Create contract caller + // 6. Create app resolver for name/ID resolution + resolver, err := NewAppResolver(cCtx) + if err != nil { + return nil, fmt.Errorf("failed to create app resolver: %w", err) + } + + // 7. Create contract caller contractCaller, err := common.NewContractCaller( privateKey, chainID, environmentConfig, client, logger, + resolver, ) if err != nil { return nil, fmt.Errorf("failed to create contract caller: %w", err) @@ -75,6 +83,7 @@ func DoPreflightChecks(cCtx *cli.Context) (*PreflightContext, error) { EnvironmentConfig: &environmentConfig, Client: client, PrivateKey: privateKey, + Resolver: resolver, }, nil } diff --git a/pkg/common/app_registry.go b/pkg/common/app_registry.go index 1b62180..0045e9b 100644 --- a/pkg/common/app_registry.go +++ b/pkg/common/app_registry.go @@ -7,7 +7,6 @@ import ( "strings" "time" - "github.com/ethereum/go-ethereum/common" "gopkg.in/yaml.v3" ) @@ -69,96 +68,9 @@ func LoadAppRegistry(context string) (*AppRegistry, error) { return ®istry, nil } -// SaveAppRegistry saves the app registry to disk -func SaveAppRegistry(context string, registry *AppRegistry) error { - path, err := GetAppRegistryPath(context) - if err != nil { - return err - } - - // Ensure directory exists - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("failed to create directory: %w", err) - } - - data, err := yaml.Marshal(registry) - if err != nil { - return fmt.Errorf("failed to marshal app registry: %w", err) - } - - if err := os.WriteFile(path, data, 0644); err != nil { - return fmt.Errorf("failed to write app registry: %w", err) - } - - return nil -} - -// SetAppName sets or updates a name for an app -func SetAppName(context, appIDOrName, newName string) error { - registry, err := LoadAppRegistry(context) - if err != nil { - return err - } - - // Resolve the target app ID and find any existing name - targetAppID, err := ResolveAppID(context, appIDOrName) - if err != nil { - // If can't resolve, check if it's a valid app ID - if !common.IsHexAddress(appIDOrName) { - return fmt.Errorf("invalid app ID or name: %s", appIDOrName) - } - targetAppID = appIDOrName - } - - // Normalize app ID for comparison - targetAppIDLower := strings.ToLower(targetAppID) - - // Find and remove any existing names for this app ID - for name, app := range registry.Apps { - if strings.ToLower(app.AppID) == targetAppIDLower { - delete(registry.Apps, name) - } - } - - // If newName is empty, we're just removing the name - if newName == "" { - return SaveAppRegistry(context, registry) - } - - // Add the new name entry - registry.Apps[newName] = App{ - AppID: targetAppID, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } - - return SaveAppRegistry(context, registry) -} - -// ResolveAppID resolves a name or app ID to an app ID -func ResolveAppID(context, nameOrID string) (string, error) { - // First check if it's already a valid hex address - if common.IsHexAddress(nameOrID) { - return nameOrID, nil - } - - // Try to load from registry - registry, err := LoadAppRegistry(context) - if err != nil { - return "", err - } - - // Look up by name - if app, exists := registry.Apps[nameOrID]; exists { - return app.AppID, nil - } - - return "", fmt.Errorf("app not found: %s", nameOrID) -} - -// GetAppName returns the name for a given app ID, or empty string if not found -func GetAppName(context, appID string) string { +// GetAppNameFromLocalRegistry returns the name for a given app ID from the local registry only (legacy fallback). +// Returns empty string if not found. For new code, use utils.GetAppName which checks remote profiles first. +func GetAppNameFromLocalRegistry(context, appID string) string { registry, err := LoadAppRegistry(context) if err != nil { return "" @@ -175,26 +87,3 @@ func GetAppName(context, appID string) string { return "" } - -// ListApps returns all apps in the registry -func ListApps(context string) (map[string]App, error) { - registry, err := LoadAppRegistry(context) - if err != nil { - return nil, err - } - return registry.Apps, nil -} - -// FormatAppDisplay returns a user-friendly display string for an app -// Prioritizes profileName over local registry name -// Returns "name (0x123...)" if name exists, or just "0x123..." if no name -func FormatAppDisplay(context string, appID common.Address, profileName string) string { - name := profileName - if name == "" { - name = GetAppName(context, appID.Hex()) - } - if name != "" { - return fmt.Sprintf("%s (%s)", name, appID.Hex()) - } - return appID.Hex() -} diff --git a/pkg/common/contract_caller.go b/pkg/common/contract_caller.go index 692e123..83231a5 100644 --- a/pkg/common/contract_caller.go +++ b/pkg/common/contract_caller.go @@ -45,9 +45,10 @@ type ContractCaller struct { permissionControllerBinding *permissioncontrollerV2.IPermissionController erc7702DelegatorBinding *erc7702delegatorV2.EIP7702StatelessDeleGator SelfAddress common.Address + Resolver iface.AppNameResolver } -func NewContractCaller(privateKeyHex string, chainID *big.Int, environmentConfig EnvironmentConfig, client *ethclient.Client, logger iface.Logger) (*ContractCaller, error) { +func NewContractCaller(privateKeyHex string, chainID *big.Int, environmentConfig EnvironmentConfig, client *ethclient.Client, logger iface.Logger, resolver iface.AppNameResolver) (*ContractCaller, error) { privateKey, err := crypto.HexToECDSA(strings.TrimPrefix(privateKeyHex, "0x")) if err != nil { return nil, fmt.Errorf("invalid private key: %w", err) @@ -64,6 +65,7 @@ func NewContractCaller(privateKeyHex string, chainID *big.Int, environmentConfig permissionControllerBinding: permissioncontrollerV2.NewIPermissionController(), erc7702DelegatorBinding: erc7702delegatorV2.NewEIP7702StatelessDeleGator(), SelfAddress: SelfAddress, + Resolver: resolver, }, nil } @@ -167,13 +169,13 @@ func (cc *ContractCaller) UpgradeApp(ctx context.Context, appAddress common.Addr } // Prepare confirmation and pending messages - appName := GetAppName(cc.environmentConfig.Name, appAddress.Hex()) - confirmationPrompt := "Upgrade app" pendingMessage := "Upgrading app..." - if appName != "" { - confirmationPrompt = fmt.Sprintf("%s '%s'", confirmationPrompt, appName) - pendingMessage = fmt.Sprintf("Upgrading app '%s'...", appName) + if cc.Resolver != nil { + if appName := cc.Resolver.GetAppName(appAddress); appName != "" { + confirmationPrompt = fmt.Sprintf("%s '%s'", confirmationPrompt, appName) + pendingMessage = fmt.Sprintf("Upgrading app '%s'...", appName) + } } confirmationPrompt = fmt.Sprintf("%s with image: %s", confirmationPrompt, imageRef) @@ -194,13 +196,13 @@ func (cc *ContractCaller) StartApp(ctx context.Context, appAddress common.Addres } // Prepare confirmation and pending messages - appName := GetAppName(cc.environmentConfig.Name, appAddress.Hex()) - confirmationPrompt := "Start app" pendingMessage := "Starting app..." - if appName != "" { - confirmationPrompt = fmt.Sprintf("%s '%s'", confirmationPrompt, appName) - pendingMessage = fmt.Sprintf("Starting app '%s'...", appName) + if cc.Resolver != nil { + if appName := cc.Resolver.GetAppName(appAddress); appName != "" { + confirmationPrompt = fmt.Sprintf("%s '%s'", confirmationPrompt, appName) + pendingMessage = fmt.Sprintf("Starting app '%s'...", appName) + } } return cc.SendAndWaitForTransaction(ctx, "StartApp", callMsg, cc.isMainnet(), confirmationPrompt, pendingMessage) @@ -220,13 +222,13 @@ func (cc *ContractCaller) StopApp(ctx context.Context, appAddress common.Address } // Prepare confirmation and pending messages - appName := GetAppName(cc.environmentConfig.Name, appAddress.Hex()) - confirmationPrompt := "Stop app" pendingMessage := "Stopping app..." - if appName != "" { - confirmationPrompt = fmt.Sprintf("%s '%s'", confirmationPrompt, appName) - pendingMessage = fmt.Sprintf("Stopping app '%s'...", appName) + if cc.Resolver != nil { + if appName := cc.Resolver.GetAppName(appAddress); appName != "" { + confirmationPrompt = fmt.Sprintf("%s '%s'", confirmationPrompt, appName) + pendingMessage = fmt.Sprintf("Stopping app '%s'...", appName) + } } return cc.SendAndWaitForTransaction(ctx, "StopApp", callMsg, cc.isMainnet(), confirmationPrompt, pendingMessage) @@ -246,13 +248,13 @@ func (cc *ContractCaller) TerminateApp(ctx context.Context, appAddress common.Ad } // Prepare confirmation and pending messages - appName := GetAppName(cc.environmentConfig.Name, appAddress.Hex()) - confirmationPrompt := "⚠️ \033[1mPermanently\033[0m destroy app" pendingMessage := "Terminating app..." - if appName != "" { - confirmationPrompt = fmt.Sprintf("%s '%s'", confirmationPrompt, appName) - pendingMessage = fmt.Sprintf("Terminating app '%s'...", appName) + if cc.Resolver != nil { + if appName := cc.Resolver.GetAppName(appAddress); appName != "" { + confirmationPrompt = fmt.Sprintf("%s '%s'", confirmationPrompt, appName) + pendingMessage = fmt.Sprintf("Terminating app '%s'...", appName) + } } // Note: Terminate always needs confirmation unless force is specified diff --git a/pkg/common/global_config.go b/pkg/common/global_config.go index a97cfbe..2e6339c 100644 --- a/pkg/common/global_config.go +++ b/pkg/common/global_config.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "time" "gopkg.in/yaml.v3" ) @@ -22,6 +23,10 @@ type GlobalConfig struct { LastVersionCheck int64 `yaml:"last_version_check,omitempty"` // LastKnownVersion stores the last known latest version from the server LastKnownVersion string `yaml:"last_known_version,omitempty"` + // LastProfileCacheUpdate stores the timestamp of the last profile cache update + LastProfileCacheUpdate int64 `yaml:"last_profile_cache_update,omitempty"` + // ProfileCache stores app profiles (appID → profile name) per environment + ProfileCache map[string]map[string]string `yaml:"profile_cache,omitempty"` } // GetGlobalConfigDir returns the XDG-compliant directory where global eigenx config should be stored @@ -175,3 +180,57 @@ func SetDefaultEnvironment(environment string) error { return SaveGlobalConfig(config) } + +// LoadProfileCache loads the cached app profiles for a given environment +// Returns cached profiles and timestamp, using 24-hour TTL +func LoadProfileCache(environment string) (profiles map[string]string, timestamp int64, err error) { + config, err := LoadGlobalConfig() + if err != nil { + return nil, 0, err + } + + // Initialize cache map if nil + if config.ProfileCache == nil { + return make(map[string]string), 0, nil + } + + // Get profiles for this environment + envProfiles, exists := config.ProfileCache[environment] + if !exists { + return make(map[string]string), 0, nil + } + + return envProfiles, config.LastProfileCacheUpdate, nil +} + +// SaveProfileCache saves app profiles to cache with current timestamp +func SaveProfileCache(environment string, profiles map[string]string) error { + config, err := LoadGlobalConfig() + if err != nil { + return err + } + + // Initialize cache map if nil + if config.ProfileCache == nil { + config.ProfileCache = make(map[string]map[string]string) + } + + // Save profiles for this environment + config.ProfileCache[environment] = profiles + config.LastProfileCacheUpdate = time.Now().Unix() + + return SaveGlobalConfig(config) +} + +// InvalidateProfileCache clears the profile cache timestamp to force refresh +func InvalidateProfileCache() error { + config, err := LoadGlobalConfig() + if err != nil { + return err + } + + config.LastProfileCacheUpdate = 0 + config.ProfileCache = nil + + return SaveGlobalConfig(config) +} diff --git a/pkg/common/iface/resolver.go b/pkg/common/iface/resolver.go new file mode 100644 index 0000000..c9e69f6 --- /dev/null +++ b/pkg/common/iface/resolver.go @@ -0,0 +1,10 @@ +package iface + +import common "github.com/ethereum/go-ethereum/common" + +// AppNameResolver resolves app IDs to display names. +type AppNameResolver interface { + // GetAppName returns the app name by checking remote profile first, + // then falling back to local registry. Returns empty string if not found. + GetAppName(appID common.Address) string +}