diff --git a/CLAUDE.md b/CLAUDE.md index a7c4604..b7e7784 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,7 +63,7 @@ The CLI is built with `urfave/cli/v2` and organized hierarchically: | Command | Description | | --- | --- | | `eigenx app create [name] [language]` | Create new app project from template | -| `eigenx app name [new-name]` | Set, change, or remove a friendly name for your app | +| `eigenx app profile set ` | Set app profile (name, website, description, X URL, image) | | `eigenx app deploy [image_ref]` | Build, push, deploy to TEE | | `eigenx app upgrade ` | Upgrade existing deployment | | `eigenx app start [app-id\|name]` | Start stopped app (start GCP instance) | @@ -86,7 +86,7 @@ Optional parameters are requested interactively when not provided: Commands auto-detect project context when run in directory containing `Dockerfile`. Makes `name` parameter optional for: `deploy`. -Commands also support app name resolution - you can use either the full app ID (0x123...) or a friendly name you've set with `eigenx app name`. +Commands also support app name resolution - you can use either the full app ID (0x123...) or a friendly name you've set with `eigenx app profile set`. ### Configuration System Global configuration with XDG Base Directory compliance: diff --git a/README.md b/README.md index b15e01e..7cc3b1a 100644 --- a/README.md +++ b/README.md @@ -223,7 +223,7 @@ ACME_FORCE_ISSUE=true # Only if staging cert exists | --- | --- | | `eigenx app create [name] [language]` | Create new project from template | | `eigenx app configure tls` | Add TLS configuration to your project | -| `eigenx app name ` | Set a friendly name for your app | +| `eigenx app profile set ` | Set app profile (name, website, description, social links, icon) | ### Deployment & Updates diff --git a/config/README.md b/config/README.md index e48c0be..23638ba 100644 --- a/config/README.md +++ b/config/README.md @@ -34,9 +34,9 @@ eigenx app upgrade [app-name] [image] # Update deployment eigenx app configure tls # Configure TLS ``` -### App Naming +### App Profile ```bash -eigenx app name [app-id] [new-name] # Update friendly name +eigenx app profile set [app-id] # Set app name, website, description, social links, and icon ``` ## TLS Configuration (Optional) diff --git a/pkg/commands/app.go b/pkg/commands/app.go index 4c0ebe9..668be87 100644 --- a/pkg/commands/app.go +++ b/pkg/commands/app.go @@ -18,7 +18,7 @@ var AppCommand = &cli.Command{ app.ListCommand, app.InfoCommand, app.LogsCommand, - app.NameCommand, + app.ProfileCommand, app.ConfigureTLSCommand, }, } diff --git a/pkg/commands/app/deploy.go b/pkg/commands/app/deploy.go index 2de0bee..955ca41 100644 --- a/pkg/commands/app/deploy.go +++ b/pkg/commands/app/deploy.go @@ -20,9 +20,13 @@ var DeployCommand = &cli.Command{ common.PrivateKeyFlag, common.EnvFlag, common.FileFlag, - common.NameFlag, common.LogVisibilityFlag, common.InstanceTypeFlag, + common.NameFlag, + common.WebsiteFlag, + common.DescriptionFlag, + common.XURLFlag, + common.ImageFlag, }...), Action: deployAction, } @@ -60,39 +64,32 @@ func deployAction(cCtx *cli.Context) error { return fmt.Errorf("failed to get image reference: %w", err) } - // 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) - } - - // 7. Get environment file configuration + // 6. Get environment file configuration envFilePath, err := utils.GetEnvFileInteractive(cCtx) if err != nil { return fmt.Errorf("failed to get env file path: %w", err) } - // 8. Get instance type selection (uses first from backend as default for new apps) + // 7. 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) } - // 9. Get log settings from flags or interactive prompt + // 8. 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) } - // 10. Generate random salt + // 9. Generate random salt salt := [32]byte{} _, err = rand.Read(salt[:]) if err != nil { return fmt.Errorf("failed to generate random salt: %w", err) } - // 11. Get app ID + // 10. Get app ID _, appController, err := utils.GetAppControllerBinding(cCtx) if err != nil { return fmt.Errorf("failed to get app controller binding: %w", err) @@ -102,23 +99,47 @@ func deployAction(cCtx *cli.Context) error { return fmt.Errorf("failed to get app id: %w", err) } - // 12. Prepare the release (includes build/push if needed, with automatic retry on permission errors) + // 11. 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 } - // 13. Deploy the app + // 12. 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) } - // 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) + // 13. Collect app profile while deployment is in progress (optional) + environment := preflightCtx.EnvironmentConfig.Name + suggestedName, err := utils.ExtractAndFindAvailableName(environment, imageRef) + if err != nil { + logger.Warn("Failed to extract suggested name: %s", err.Error()) + suggestedName = "" + } + + logger.Info("Deployment confirmed onchain. While your instance provisions, set up a public profile") + profile, err := utils.GetAppProfileInteractive(cCtx, suggestedName, true) + if err != nil { + logger.Warn("Failed to collect profile: %s", err.Error()) + profile = nil + } + + // 14. Upload profile if provided (non-blocking - warn on failure but don't fail deployment) + if profile != nil { + logger.Info("Uploading app profile...") + userApiClient, err := utils.NewUserApiClient(cCtx) + if err != nil { + logger.Warn("Failed to create API client for profile upload: %s", err.Error()) + } else { + _, err := userApiClient.UploadAppProfile(cCtx, appID.Hex(), profile.Name, profile.Website, profile.Description, profile.XURL, profile.ImagePath) + if err != nil { + logger.Warn("Failed to upload profile: %s", err.Error()) + } else { + logger.Info("✓ Profile uploaded successfully") + } + } } // 15. Watch until deployment completes diff --git a/pkg/commands/app/info.go b/pkg/commands/app/info.go index bc32a26..da0dec0 100644 --- a/pkg/commands/app/info.go +++ b/pkg/commands/app/info.go @@ -173,7 +173,8 @@ func logsAction(cCtx *cli.Context) error { return fmt.Errorf("failed to create API client: %w", err) } - formattedApp := common.FormatAppDisplay(environmentConfig.Name, appID) + profileName := utils.GetAppProfileName(cCtx, appID) + formattedApp := common.FormatAppDisplay(environmentConfig.Name, appID, profileName) 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 2ccfa39..c23b9dd 100644 --- a/pkg/commands/app/lifecycle.go +++ b/pkg/commands/app/lifecycle.go @@ -61,7 +61,8 @@ func startAction(cCtx *cli.Context) error { return fmt.Errorf("failed to get app address: %w", err) } - formattedApp := common.FormatAppDisplay(preflightCtx.EnvironmentConfig.Name, appID) + profileName := utils.GetAppProfileName(cCtx, appID) + formattedApp := common.FormatAppDisplay(preflightCtx.EnvironmentConfig.Name, appID, profileName) // Call AppController.StartApp err = preflightCtx.Caller.StartApp(ctx, appID) @@ -94,7 +95,8 @@ func stopAction(cCtx *cli.Context) error { return fmt.Errorf("failed to get app address: %w", err) } - formattedApp := common.FormatAppDisplay(preflightCtx.EnvironmentConfig.Name, appID) + profileName := utils.GetAppProfileName(cCtx, appID) + formattedApp := common.FormatAppDisplay(preflightCtx.EnvironmentConfig.Name, appID, profileName) // Call AppController.StopApp err = preflightCtx.Caller.StopApp(ctx, appID) @@ -137,7 +139,8 @@ func terminateAction(cCtx *cli.Context) error { return err } - logger.Info("App %s terminated successfully", common.FormatAppDisplay(preflightCtx.EnvironmentConfig.Name, appID)) + profileName := utils.GetAppProfileName(cCtx, appID) + logger.Info("App %s terminated successfully", common.FormatAppDisplay(preflightCtx.EnvironmentConfig.Name, appID, profileName)) return utils.GetAndPrintAppInfo(cCtx, appID, common.AppStatusTerminating) } diff --git a/pkg/commands/app/profile.go b/pkg/commands/app/profile.go new file mode 100644 index 0000000..61a846b --- /dev/null +++ b/pkg/commands/app/profile.go @@ -0,0 +1,84 @@ +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 ProfileCommand = &cli.Command{ + Name: "profile", + Usage: "Manage public app profile", + ArgsUsage: "", + Subcommands: []*cli.Command{ + { + Name: "set", + Usage: "Set public profile information for an app", + ArgsUsage: "", + Flags: append(common.GlobalFlags, []cli.Flag{ + common.EnvironmentFlag, + common.RpcUrlFlag, + common.NameFlag, + common.WebsiteFlag, + common.DescriptionFlag, + common.XURLFlag, + common.ImageFlag, + }...), + Action: profileSetAction, + }, + }, +} + +func profileSetAction(cCtx *cli.Context) error { + logger := common.LoggerFromContext(cCtx) + + // Get app ID + appID, err := utils.GetAppIDInteractive(cCtx, 0, "set profile for") + if err != nil { + return err + } + + logger.Info("Setting profile for app: %s", appID.Hex()) + + // Collect profile fields using shared function + profile, err := utils.GetAppProfileInteractive(cCtx, "", false) + if err != nil { + return err + } + + // Upload profile via API + logger.Info("Uploading app profile...") + + userApiClient, err := utils.NewUserApiClient(cCtx) + if err != nil { + return fmt.Errorf("failed to create API client: %w", err) + } + + response, err := userApiClient.UploadAppProfile(cCtx, appID.Hex(), profile.Name, profile.Website, profile.Description, profile.XURL, profile.ImagePath) + if err != nil { + return fmt.Errorf("failed to upload profile: %w", err) + } + + // Display success message with returned data + logger.Info("✓ Profile updated successfully for app '%s'", response.Name) + + // Show uploaded profile data + fmt.Println("\nUploaded Profile:") + fmt.Printf(" Name: %s\n", response.Name) + if response.Website != nil { + fmt.Printf(" Website: %s\n", *response.Website) + } + if response.Description != nil { + fmt.Printf(" Description: %s\n", *response.Description) + } + if response.XURL != nil { + fmt.Printf(" X URL: %s\n", *response.XURL) + } + if response.ImageURL != nil { + fmt.Printf(" Image URL: %s\n", *response.ImageURL) + } + + return nil +} diff --git a/pkg/commands/utils/contract_utils.go b/pkg/commands/utils/contract_utils.go index 971f5f9..8d83c22 100644 --- a/pkg/commands/utils/contract_utils.go +++ b/pkg/commands/utils/contract_utils.go @@ -176,6 +176,21 @@ 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) @@ -254,8 +269,10 @@ func PrintAppInfoWithStatus(ctx context.Context, logger iface.Logger, client *et } fmt.Println() - // Show app name if available - if name := common.GetAppName(environmentName, appID.Hex()); name != "" { + // 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 != "" { logger.Info("App Name: %s", name) } @@ -268,6 +285,22 @@ func PrintAppInfoWithStatus(ctx context.Context, logger iface.Logger, client *et logger.Info("Instance: %s", info.MachineType) logger.Info("IP: %s", info.Ip) + // Display app profile if available + if info.Profile != nil { + if info.Profile.Website != nil { + logger.Info("Website: %s", *info.Profile.Website) + } + if info.Profile.Description != nil { + logger.Info("Description: %s", *info.Profile.Description) + } + if info.Profile.XURL != nil { + logger.Info("X URL: %s", *info.Profile.XURL) + } + if info.Profile.ImageURL != nil { + logger.Info("Image URL: %s", *info.Profile.ImageURL) + } + } + // Display addresses if available if len(info.EVMAddresses) > 0 { printEVMAddresses(logger, info.EVMAddresses) diff --git a/pkg/commands/utils/interactive.go b/pkg/commands/utils/interactive.go index f1ff5e6..553ee41 100644 --- a/pkg/commands/utils/interactive.go +++ b/pkg/commands/utils/interactive.go @@ -288,10 +288,13 @@ 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 { - case "view": + case "view", "set profile for": return true case "start": return status == common.ContractAppStatusStopped || status == common.ContractAppStatusSuspended || exitedApps[addr.Hex()] @@ -316,11 +319,9 @@ func GetAppIDInteractive(cCtx *cli.Context, argIndex int, action string) (ethcom statusStr = "Exited" } - appName := common.GetAppName(environmentConfig.Name, appAddr.Hex()) - displayName := appAddr.Hex() - if appName != "" { - displayName = fmt.Sprintf("%s (%s)", appName, appAddr.Hex()) - } + // Prioritize API profile name, fall back to local registry + profileName := profileNames[appAddr.Hex()] + displayName := common.FormatAppDisplay(environmentConfig.Name, appAddr, profileName) appItems = append(appItems, appItem{ addr: appAddr, @@ -403,16 +404,22 @@ func GetOrPromptAppName(cCtx *cli.Context, context string, imageRef string) (str return GetAvailableAppNameInteractive(context, imageRef) } -// GetAvailableAppNameInteractive interactively gets an available app name -func GetAvailableAppNameInteractive(context, imageRef string) (string, error) { - // Start with a suggestion from the image +// ExtractAndFindAvailableName extracts a base name from imageRef and finds an available variant +func ExtractAndFindAvailableName(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 +} - // Find the first available name based on the suggestion - suggestedName := findAvailableName(context, baseName) +// 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") @@ -1024,6 +1031,192 @@ func GetEnvironmentInteractive(cCtx *cli.Context, argIndex int) (string, error) return "", fmt.Errorf("failed to find selected environment") } +func GetAppNameInteractive(cCtx *cli.Context, defaultName string) (string, error) { + placeholder := "Display name for your app (required)" + if defaultName != "" { + placeholder = fmt.Sprintf("Display name for your app (suggested: %s)", defaultName) + } + + result, err := getFromFlagOrPrompt(cCtx, PromptConfig{ + FlagName: "name", + Prompt: "App name:", + Placeholder: placeholder, + Default: defaultName, + Validate: ValidateAppName, + Sanitize: func(s string) (string, error) { return SanitizeString(s), nil }, + }) + if err != nil || result == nil { + return "", err + } + return *result, nil +} + +func GetAppWebsiteInteractive(cCtx *cli.Context) (*string, error) { + return getFromFlagOrPrompt(cCtx, PromptConfig{ + FlagName: "website", + Prompt: "Website URL (optional):", + Placeholder: "Your app's website (e.g., https://example.com) - press Enter to skip", + Validate: func(s string) error { + if s == "" { + return nil + } + _, err := SanitizeURL(s) + return err + }, + Sanitize: SanitizeURL, + }) +} + +func GetAppDescriptionInteractive(cCtx *cli.Context) (*string, error) { + return getFromFlagOrPrompt(cCtx, PromptConfig{ + FlagName: "description", + Prompt: "Description (optional):", + Placeholder: "Brief description of your app (max 1000 characters) - press Enter to skip", + Validate: func(s string) error { + if s == "" { + return nil + } + return ValidateAppDescription(s) + }, + Sanitize: func(s string) (string, error) { return SanitizeString(s), nil }, + }) +} + +func GetAppXURLInteractive(cCtx *cli.Context) (*string, error) { + return getFromFlagOrPrompt(cCtx, PromptConfig{ + FlagName: "x-url", + Prompt: "X (Twitter) URL (optional):", + Placeholder: "Your X/Twitter profile (e.g., https://x.com/username or @username) - press Enter to skip", + Validate: func(s string) error { + if s == "" { + return nil + } + _, err := SanitizeXURL(s) + return err + }, + Sanitize: SanitizeXURL, + }) +} + +func GetAppImageInteractive(cCtx *cli.Context) (string, error) { + if imageFlag := cCtx.String("image"); imageFlag != "" { + cleanedPath, imgInfo, err := ValidateAndGetImageInfo(imageFlag) + if err != nil { + return "", fmt.Errorf("invalid image file: %w", err) + } + printImageInfo(imgInfo) + return cleanedPath, nil + } + + wantsImage, err := output.Confirm("Would you like to upload an app icon/logo?") + if err != nil || !wantsImage { + return "", nil + } + + imageInput, err := output.InputString( + "Image path:", + "Drag & drop image file or enter path (JPG/PNG, max 4MB, square recommended)", + "", + func(path string) error { + if path == "" { + return nil + } + _, _, err := ValidateAndGetImageInfo(path) + return err + }, + ) + if err != nil || imageInput == "" { + return "", nil + } + + cleanedPath, imgInfo, err := ValidateAndGetImageInfo(imageInput) + if err == nil { + printImageInfo(imgInfo) + } + return cleanedPath, err +} + +// CollectedProfile holds collected profile information with pointer fields for optional values +type CollectedProfile struct { + Name string + Website *string + Description *string + XURL *string + ImagePath string +} + +// GetAppProfileInteractive collects app profile information interactively +// If defaultName is provided, it will be used as the suggested name +// If allowRetry is true, user can re-enter information on rejection (deploy flow) +// If allowRetry is false, rejection returns an error (profile set flow) +// Returns CollectedProfile with at least a name (required), and optional fields +func GetAppProfileInteractive(cCtx *cli.Context, defaultName string, allowRetry bool) (*CollectedProfile, error) { + for { + // Collect name (required) + name, err := GetAppNameInteractive(cCtx, defaultName) + if err != nil { + return nil, err + } + + // Collect optional fields + website, err := GetAppWebsiteInteractive(cCtx) + if err != nil { + return nil, err + } + + description, err := GetAppDescriptionInteractive(cCtx) + if err != nil { + return nil, err + } + + xURL, err := GetAppXURLInteractive(cCtx) + if err != nil { + return nil, err + } + + imagePath, err := GetAppImageInteractive(cCtx) + if err != nil { + return nil, err + } + + profile := &CollectedProfile{ + Name: name, + Website: website, + Description: description, + XURL: xURL, + ImagePath: imagePath, + } + + // Always display profile for confirmation + fmt.Println(formatProfileForDisplay(profile)) + + confirmed, err := output.Confirm("Continue with this profile?") + if err != nil { + return nil, fmt.Errorf("failed to get confirmation: %w", err) + } + + if confirmed { + return profile, nil + } + + // User rejected the profile + if !allowRetry { + // Profile set flow: just return an error + return nil, fmt.Errorf("profile confirmation cancelled") + } + + // Deploy flow: ask if they want to re-enter + retry, err := output.Confirm("Would you like to re-enter the information?") + if err != nil || !retry { + // User doesn't want to set a profile - skip it entirely + return nil, nil + } + + // Loop back to re-collect information (keep the name) + defaultName = name + } +} + // ConfirmMainnetEnvironment shows a confirmation prompt for mainnet environments func ConfirmMainnetEnvironment(env string) error { if !common.IsMainnetEnvironment(env) { @@ -1046,3 +1239,122 @@ func ConfirmMainnetEnvironment(env string) error { return nil } + +// PromptConfig contains configuration for the flag-or-prompt pattern +type PromptConfig struct { + FlagName string + Prompt string + Placeholder string + Default string // Optional default value to suggest to user + Validate func(string) error + Sanitize func(string) (string, error) +} + +// getFromFlagOrPrompt handles the flag-or-prompt pattern for strings +func getFromFlagOrPrompt(cCtx *cli.Context, config PromptConfig) (*string, error) { + if flag := cCtx.String(config.FlagName); flag != "" { + sanitized, err := config.Sanitize(flag) + if err != nil { + return nil, fmt.Errorf("invalid %s: %w", config.FlagName, err) + } + return &sanitized, nil + } + + input, err := output.InputString(config.Prompt, config.Placeholder, config.Default, config.Validate) + if err != nil { + return nil, fmt.Errorf("failed to get %s: %w", config.FlagName, err) + } + if input == "" { + return nil, nil + } + sanitized, _ := config.Sanitize(input) + return &sanitized, nil +} + +// printImageInfo prints image ratio and pixel dimensions, including a warning if the image is not square +func printImageInfo(img *ImageInfo) { + fmt.Printf("📸 Image: %dx%d pixels, %.1f KB\n", img.Width, img.Height, img.SizeKB) + if !img.IsSquare() { + fmt.Printf("⚠️ Note: Image is not square (%.2f:1 ratio). Square images display best.\n", img.AspectRatio()) + } +} + +// getProfileNamesForApps fetches profile names for a list of apps from the API +// Batches requests and executes them in parallel +func getProfileNamesForApps(cCtx *cli.Context, apps []ethcommon.Address) map[string]string { + profileNames := make(map[string]string) + if len(apps) == 0 { + return profileNames + } + + userApiClient, err := NewUserApiClient(cCtx) + if err != nil { + return profileNames + } + + // Create batches + var batches [][]ethcommon.Address + for i := 0; i < len(apps); i += MaxAppsPerRequest { + end := min(i+MaxAppsPerRequest, len(apps)) + batches = append(batches, apps[i:end]) + } + + // Fetch all batches in parallel + type batchResult struct { + batch []ethcommon.Address + infos *AppInfoResponse + } + resultsCh := make(chan batchResult, len(batches)) + + for _, batch := range batches { + go func(b []ethcommon.Address) { + infos, _ := userApiClient.GetInfos(cCtx, b, 0) + resultsCh <- batchResult{batch: b, infos: infos} + }(batch) + } + + // Collect results and build profile names map + for range batches { + res := <-resultsCh + if res.infos != nil { + for j, info := range res.infos.Apps { + if info.Profile != nil && info.Profile.Name != "" { + profileNames[res.batch[j].Hex()] = info.Profile.Name + } + } + } + } + return profileNames +} + +// formatProfileForDisplay formats a profile for display to the user +func formatProfileForDisplay(profile *CollectedProfile) string { + output := "\nPublic App Profile:\n" + output += fmt.Sprintf(" Name: %s\n", profile.Name) + + if profile.Website != nil && *profile.Website != "" { + output += fmt.Sprintf(" Website: %s\n", *profile.Website) + } else { + output += " Website: (not provided)\n" + } + + if profile.Description != nil && *profile.Description != "" { + output += fmt.Sprintf(" Description: %s\n", *profile.Description) + } else { + output += " Description: (not provided)\n" + } + + if profile.XURL != nil && *profile.XURL != "" { + output += fmt.Sprintf(" X URL: %s\n", *profile.XURL) + } else { + output += " X URL: (not provided)\n" + } + + if profile.ImagePath != "" { + output += fmt.Sprintf(" Image: %s\n", profile.ImagePath) + } else { + output += " Image: (not provided)\n" + } + + return output +} diff --git a/pkg/commands/utils/profile_utils.go b/pkg/commands/utils/profile_utils.go new file mode 100644 index 0000000..52b0d1f --- /dev/null +++ b/pkg/commands/utils/profile_utils.go @@ -0,0 +1,244 @@ +package utils + +import ( + "fmt" + "html" + "image" + _ "image/jpeg" // Register JPEG format decoder + _ "image/png" // Register PNG format decoder + "net/url" + "os" + "path/filepath" + "slices" + "strings" +) + +const ( + MaxImageSize = 4 * 1024 * 1024 // 4MB + MaxAppNameLength = 100 + MaxDescriptionLength = 1000 + BytesPerMB = 1024 * 1024 +) + +var ( + ValidImageExtensions = []string{".jpg", ".jpeg", ".png"} + ValidXHosts = []string{"twitter.com", "www.twitter.com", "x.com", "www.x.com"} +) + +// AppProfile represents the profile information for an app +type AppProfile struct { + Name string `json:"name"` + Website string `json:"website"` + Description string `json:"description"` + XURL string `json:"xURL"` + ImageURL string `json:"imageURL"` +} + +// ImageInfo contains validated image metadata +type ImageInfo struct { + Width int + Height int + SizeKB float64 + Format string +} + +// IsSquare checks if image has approximately square aspect ratio +func (img *ImageInfo) IsSquare() bool { + if img.Width == 0 || img.Height == 0 { + return false + } + aspectRatio := float64(img.Width) / float64(img.Height) + return aspectRatio >= 0.8 && aspectRatio <= 1.25 +} + +// AspectRatio returns the width/height ratio +func (img *ImageInfo) AspectRatio() float64 { + if img.Height == 0 { + return 0 + } + return float64(img.Width) / float64(img.Height) +} + +// ValidateURL validates that a string is a valid URL +func ValidateURL(rawURL string) error { + if strings.TrimSpace(rawURL) == "" { + return fmt.Errorf("URL cannot be empty") + } + + parsedURL, err := url.ParseRequestURI(rawURL) + if err != nil { + return fmt.Errorf("invalid URL format: %w", err) + } + + if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + return fmt.Errorf("URL scheme must be http or https") + } + + return nil +} + +// ValidateXURL validates that a URL is a valid X (Twitter) URL +func ValidateXURL(rawURL string) error { + if err := ValidateURL(rawURL); err != nil { + return err + } + + parsedURL, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("failed to parse URL: %w", err) + } + host := strings.ToLower(parsedURL.Host) + + // Accept twitter.com and x.com domains + if !slices.Contains(ValidXHosts, host) { + return fmt.Errorf("URL must be a valid X/Twitter URL (x.com or twitter.com)") + } + + // Ensure it has a path (username/profile) + if parsedURL.Path == "" || parsedURL.Path == "/" { + return fmt.Errorf("x URL must include a username or profile path") + } + + return nil +} + +// ValidateAndGetImageInfo validates and extracts image information in one pass +// Returns the cleaned file path (with quotes stripped) along with image info +func ValidateAndGetImageInfo(filePath string) (string, *ImageInfo, error) { + // Strip quotes that may be added by terminal drag-and-drop or shell + filePath = strings.Trim(strings.TrimSpace(filePath), "'\"") + + if filePath == "" { + return "", nil, fmt.Errorf("image path cannot be empty") + } + + // Check if file exists + info, err := os.Stat(filePath) + if err != nil { + if os.IsNotExist(err) { + return "", nil, fmt.Errorf("image file not found: %s", filePath) + } + return "", nil, fmt.Errorf("failed to access image file: %w", err) + } + + if info.IsDir() { + return "", nil, fmt.Errorf("path is a directory, not a file") + } + + // Check file size + if info.Size() > MaxImageSize { + sizeMB := float64(info.Size()) / float64(BytesPerMB) + return "", nil, fmt.Errorf("image file size (%.2f MB) exceeds maximum allowed size of %d MB", + sizeMB, MaxImageSize/BytesPerMB) + } + + // Check file extension + ext := strings.ToLower(filepath.Ext(filePath)) + if !slices.Contains(ValidImageExtensions, ext) { + return "", nil, fmt.Errorf("image must be JPG or PNG format (found: %s)", ext) + } + + // Open and decode image (validates format and gets dimensions) + file, err := os.Open(filePath) + if err != nil { + return "", nil, fmt.Errorf("failed to open image file: %w", err) + } + defer file.Close() + + cfg, format, err := image.DecodeConfig(file) + if err != nil { + return "", nil, fmt.Errorf("invalid or corrupted image file: %w", err) + } + + return filePath, &ImageInfo{ + Width: cfg.Width, + Height: cfg.Height, + SizeKB: float64(info.Size()) / 1024, + Format: format, + }, nil +} + +// ValidateAppName validates an app name +func ValidateAppName(name string) error { + if err := validateNotEmpty(name, "name"); err != nil { + return err + } + + if len(name) > MaxAppNameLength { + return fmt.Errorf("name cannot exceed %d characters", MaxAppNameLength) + } + + return nil +} + +// ValidateAppDescription validates an app description +func ValidateAppDescription(description string) error { + if err := validateNotEmpty(description, "description"); err != nil { + return err + } + + if len(description) > MaxDescriptionLength { + return fmt.Errorf("description cannot exceed %d characters", MaxDescriptionLength) + } + + return nil +} + +// SanitizeString sanitizes a string by trimming whitespace and escaping HTML +func SanitizeString(s string) string { + return html.EscapeString(strings.TrimSpace(s)) +} + +// SanitizeURL sanitizes a URL by trimming whitespace and validating +func SanitizeURL(rawURL string) (string, error) { + rawURL = strings.TrimSpace(rawURL) + + // Add https:// if no scheme is present + if !hasScheme(rawURL) { + rawURL = "https://" + rawURL + } + + if err := ValidateURL(rawURL); err != nil { + return "", err + } + + return rawURL, nil +} + +// SanitizeXURL sanitizes an X URL +func SanitizeXURL(rawURL string) (string, error) { + rawURL = strings.TrimSpace(rawURL) + + // Handle username-only input (e.g., "@username" or "username") + if !strings.Contains(rawURL, "://") && !strings.Contains(rawURL, ".") { + // Remove @ if present + username := strings.TrimPrefix(rawURL, "@") + rawURL = fmt.Sprintf("https://x.com/%s", username) + } else if !hasScheme(rawURL) { + // Add https:// if URL-like but missing scheme + rawURL = "https://" + rawURL + } + + // Normalize twitter.com to x.com + rawURL = strings.Replace(rawURL, "twitter.com", "x.com", 1) + rawURL = strings.Replace(rawURL, "www.x.com", "x.com", 1) + + if err := ValidateXURL(rawURL); err != nil { + return "", err + } + + return rawURL, nil +} + +// validateNotEmpty checks if a string is empty after trimming +func validateNotEmpty(s, fieldName string) error { + if strings.TrimSpace(s) == "" { + return fmt.Errorf("%s cannot be empty", fieldName) + } + return nil +} + +// hasScheme checks if a URL has an http or https scheme +func hasScheme(rawURL string) bool { + return strings.HasPrefix(rawURL, "http://") || strings.HasPrefix(rawURL, "https://") +} diff --git a/pkg/commands/utils/userapi_client.go b/pkg/commands/utils/userapi_client.go index 0356ced..4a72071 100644 --- a/pkg/commands/utils/userapi_client.go +++ b/pkg/commands/utils/userapi_client.go @@ -1,12 +1,16 @@ package utils import ( + "bytes" "encoding/json" "fmt" "io" "math/big" + "mime/multipart" "net/http" "net/url" + "os" + "path/filepath" "strings" "time" @@ -33,7 +37,10 @@ const ( StatusInactive SubscriptionStatus = "inactive" ) -const MAX_ADDRESS_COUNT = 5 +const ( + MaxAddressCount = 5 // Max addresses to return per app + MaxAppsPerRequest = 10 // Max apps allowed per API request +) type AppStatusResponse struct { Apps []AppStatus `json:"apps"` @@ -80,11 +87,20 @@ type UserSubscriptionResponse struct { PortalURL *string `json:"portal_url,omitempty"` } +type AppProfileResponse struct { + Name string `json:"name"` + Website *string `json:"website,omitempty"` + Description *string `json:"description,omitempty"` + XURL *string `json:"xURL,omitempty"` + ImageURL *string `json:"imageURL,omitempty"` +} + type RawAppInfo struct { - Addresses json.RawMessage `json:"addresses"` - Status string `json:"app_status"` - Ip string `json:"ip"` - MachineType string `json:"machine_type"` + Addresses json.RawMessage `json:"addresses"` + Status string `json:"app_status"` + Ip string `json:"ip"` + MachineType string `json:"machine_type"` + Profile *AppProfileResponse `json:"profile,omitempty"` } // AppInfo contains the app info with parsed and validated addresses @@ -94,6 +110,7 @@ type AppInfo struct { Status string Ip string MachineType string + Profile *AppProfileResponse } type AppInfoResponse struct { @@ -128,7 +145,7 @@ func (cc *UserApiClient) GetStatuses(cCtx *cli.Context, appIDs []ethcommon.Addre fullURL := fmt.Sprintf("%s?%s", endpoint, params.Encode()) - resp, err := cc.makeAuthenticatedRequest(cCtx, "GET", fullURL, nil) + resp, err := cc.makeAuthenticatedRequest(cCtx, "GET", fullURL, nil, "", nil) if err != nil { return nil, err } @@ -147,8 +164,8 @@ func (cc *UserApiClient) GetStatuses(cCtx *cli.Context, appIDs []ethcommon.Addre } func (cc *UserApiClient) GetInfos(cCtx *cli.Context, appIDs []ethcommon.Address, addressCount int) (*AppInfoResponse, error) { - if addressCount > MAX_ADDRESS_COUNT { - addressCount = MAX_ADDRESS_COUNT + if addressCount > MaxAddressCount { + addressCount = MaxAddressCount } endpoint := fmt.Sprintf("%s/info", cc.environmentConfig.UserApiServerURL) @@ -159,7 +176,7 @@ func (cc *UserApiClient) GetInfos(cCtx *cli.Context, appIDs []ethcommon.Address, fullURL := fmt.Sprintf("%s?%s", endpoint, params.Encode()) - resp, err := cc.makeAuthenticatedRequest(cCtx, "GET", fullURL, &common.CanViewSensitiveAppInfoPermission) + resp, err := cc.makeAuthenticatedRequest(cCtx, "GET", fullURL, nil, "", &common.CanViewSensitiveAppInfoPermission) if err != nil { return nil, err } @@ -205,6 +222,7 @@ func (cc *UserApiClient) GetInfos(cCtx *cli.Context, appIDs []ethcommon.Address, Status: rawApp.Status, Ip: rawApp.Ip, MachineType: rawApp.MachineType, + Profile: rawApp.Profile, } } @@ -214,7 +232,7 @@ func (cc *UserApiClient) GetInfos(cCtx *cli.Context, appIDs []ethcommon.Address, func (cc *UserApiClient) GetLogs(cCtx *cli.Context, appID ethcommon.Address) (string, error) { endpoint := fmt.Sprintf("%s/logs/%s", cc.environmentConfig.UserApiServerURL, appID.Hex()) - resp, err := cc.makeAuthenticatedRequest(cCtx, "GET", endpoint, &common.CanViewAppLogsPermission) + resp, err := cc.makeAuthenticatedRequest(cCtx, "GET", endpoint, nil, "", &common.CanViewAppLogsPermission) if err != nil { return "", err } @@ -235,7 +253,7 @@ func (cc *UserApiClient) GetLogs(cCtx *cli.Context, appID ethcommon.Address) (st func (cc *UserApiClient) GetSKUs(cCtx *cli.Context) (*SKUListResponse, error) { endpoint := fmt.Sprintf("%s/skus", cc.environmentConfig.UserApiServerURL) - resp, err := cc.makeAuthenticatedRequest(cCtx, "GET", endpoint, nil) + resp, err := cc.makeAuthenticatedRequest(cCtx, "GET", endpoint, nil, "", nil) if err != nil { return nil, err } @@ -256,7 +274,7 @@ func (cc *UserApiClient) GetSKUs(cCtx *cli.Context) (*SKUListResponse, error) { func (cc *UserApiClient) CreateCheckoutSession(cCtx *cli.Context) (*CheckoutSessionResponse, error) { endpoint := fmt.Sprintf("%s/subscription", cc.environmentConfig.UserApiServerURL) - resp, err := cc.makeAuthenticatedRequest(cCtx, "POST", endpoint, &common.CanManageBillingPermission) + resp, err := cc.makeAuthenticatedRequest(cCtx, "POST", endpoint, nil, "", &common.CanManageBillingPermission) if err != nil { return nil, err } @@ -277,7 +295,7 @@ func (cc *UserApiClient) CreateCheckoutSession(cCtx *cli.Context) (*CheckoutSess func (cc *UserApiClient) GetUserSubscription(cCtx *cli.Context) (*UserSubscriptionResponse, error) { endpoint := fmt.Sprintf("%s/subscription", cc.environmentConfig.UserApiServerURL) - resp, err := cc.makeAuthenticatedRequest(cCtx, "GET", endpoint, &common.CanManageBillingPermission) + resp, err := cc.makeAuthenticatedRequest(cCtx, "GET", endpoint, nil, "", &common.CanManageBillingPermission) if err != nil { return nil, err } @@ -298,7 +316,7 @@ func (cc *UserApiClient) GetUserSubscription(cCtx *cli.Context) (*UserSubscripti func (cc *UserApiClient) CancelSubscription(cCtx *cli.Context) error { endpoint := fmt.Sprintf("%s/subscription", cc.environmentConfig.UserApiServerURL) - resp, err := cc.makeAuthenticatedRequest(cCtx, "DELETE", endpoint, &common.CanManageBillingPermission) + resp, err := cc.makeAuthenticatedRequest(cCtx, "DELETE", endpoint, nil, "", &common.CanManageBillingPermission) if err != nil { return err } @@ -311,6 +329,82 @@ func (cc *UserApiClient) CancelSubscription(cCtx *cli.Context) error { return nil } +// UploadAppProfile uploads app profile information with optional image +func (cc *UserApiClient) UploadAppProfile(cCtx *cli.Context, appAddress string, name string, website, description, xURL *string, imagePath string) (*AppProfileResponse, error) { + endpoint := fmt.Sprintf("%s/apps/%s/profile", cc.environmentConfig.UserApiServerURL, appAddress) + + // Create multipart form body + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + // Add required name field + if err := writer.WriteField("name", name); err != nil { + return nil, fmt.Errorf("failed to write name field: %w", err) + } + + // Add optional text fields + if website != nil && *website != "" { + if err := writer.WriteField("website", *website); err != nil { + return nil, fmt.Errorf("failed to write website field: %w", err) + } + } + + if description != nil && *description != "" { + if err := writer.WriteField("description", *description); err != nil { + return nil, fmt.Errorf("failed to write description field: %w", err) + } + } + + if xURL != nil && *xURL != "" { + if err := writer.WriteField("xURL", *xURL); err != nil { + return nil, fmt.Errorf("failed to write xURL field: %w", err) + } + } + + // Add optional image file + if imagePath != "" { + file, err := os.Open(imagePath) + if err != nil { + return nil, fmt.Errorf("failed to open image file: %w", err) + } + defer file.Close() + + part, err := writer.CreateFormFile("image", filepath.Base(imagePath)) + if err != nil { + return nil, fmt.Errorf("failed to create form file: %w", err) + } + + if _, err := io.Copy(part, file); err != nil { + return nil, fmt.Errorf("failed to copy image data: %w", err) + } + } + + // Close the multipart writer to finalize the form + if err := writer.Close(); err != nil { + return nil, fmt.Errorf("failed to close multipart writer: %w", err) + } + + // Use makeAuthenticatedRequest to handle authentication + resp, err := cc.makeAuthenticatedRequest(cCtx, "POST", endpoint, body, writer.FormDataContentType(), &common.CanUpdateAppProfilePermission) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + // Check for success (201 Created or 200 OK) + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + return nil, handleErrorResponse(resp) + } + + // Parse response + var result AppProfileResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + // buildAppIDsParam creates a comma-separated string of app IDs for URL parameters func buildAppIDsParam(appIDs []ethcommon.Address) string { appIDStrings := make([]string, len(appIDs)) @@ -320,9 +414,10 @@ func buildAppIDsParam(appIDs []ethcommon.Address) string { return strings.Join(appIDStrings, ",") } -// makeAuthenticatedRequest performs an HTTP request with optional authentication -func (cc *UserApiClient) makeAuthenticatedRequest(cCtx *cli.Context, method, url string, permission *[4]byte) (*http.Response, error) { - req, err := http.NewRequest(method, url, nil) +// makeAuthenticatedRequest performs an HTTP request with optional authentication and body +// contentType parameter allows setting custom Content-Type header (e.g., for multipart forms) +func (cc *UserApiClient) makeAuthenticatedRequest(cCtx *cli.Context, method, url string, body io.Reader, contentType string, permission *[4]byte) (*http.Response, error) { + req, err := http.NewRequest(method, url, body) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } @@ -331,6 +426,11 @@ func (cc *UserApiClient) makeAuthenticatedRequest(cCtx *cli.Context, method, url clientID := fmt.Sprintf("eigenx-cli/%s", version.GetVersion()) req.Header.Set("x-client-id", clientID) + // Set content type if provided + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + // Add auth headers if permission is specified if permission != nil { expiry := big.NewInt(time.Now().Add(5 * time.Minute).Unix()) diff --git a/pkg/common/app_registry.go b/pkg/common/app_registry.go index 58685fc..1b62180 100644 --- a/pkg/common/app_registry.go +++ b/pkg/common/app_registry.go @@ -186,9 +186,14 @@ func ListApps(context string) (map[string]App, error) { } // 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) string { - if name := GetAppName(context, appID.Hex()); 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/constants.go b/pkg/common/constants.go index 5948ece..8a18599 100644 --- a/pkg/common/constants.go +++ b/pkg/common/constants.go @@ -44,6 +44,10 @@ var ( // bytes4(keccak256("CAN_VIEW_SENSITIVE_APP_INFO()")) CanViewSensitiveAppInfoPermission = [4]byte{0x0e, 0x67, 0xb2, 0x2f} + // The permission to update app profile + // bytes4(keccak256("CAN_UPDATE_APP_PROFILE()")) + CanUpdateAppProfilePermission = [4]byte{0x03, 0x6f, 0xef, 0x61} + // The permission to manage billing and subscriptions // bytes4(keccak256("CAN_MANAGE_BILLING()")) CanManageBillingPermission = [4]byte{0xd6, 0xb8, 0x55, 0xa1} diff --git a/pkg/common/flags.go b/pkg/common/flags.go index 6125556..50cc3bc 100644 --- a/pkg/common/flags.go +++ b/pkg/common/flags.go @@ -65,11 +65,6 @@ var ( Value: 1, } - NameFlag = &cli.StringFlag{ - Name: "name", - Usage: "Friendly name for the app", - } - LogVisibilityFlag = &cli.StringFlag{ Name: "log-visibility", Usage: "Log visibility setting: public, private, or off", @@ -85,6 +80,32 @@ var ( Aliases: []string{"w"}, Usage: "Continuously fetch and display updates", } + + // Profile-related flags + NameFlag = &cli.StringFlag{ + Name: "name", + Usage: "App display name", + } + + WebsiteFlag = &cli.StringFlag{ + Name: "website", + Usage: "App website URL (optional)", + } + + DescriptionFlag = &cli.StringFlag{ + Name: "description", + Usage: "App description (optional)", + } + + XURLFlag = &cli.StringFlag{ + Name: "x-url", + Usage: "X (Twitter) profile URL (optional)", + } + + ImageFlag = &cli.StringFlag{ + Name: "image", + Usage: "Path to app icon/logo image - JPG/PNG, max 4MB, square recommended (optional)", + } ) // GlobalFlags defines flags that apply to the entire application (global flags).