From 19a663edcc599f35b43a6c7188b4329c7f7d89c7 Mon Sep 17 00:00:00 2001 From: yuaanlin Date: Sun, 22 Mar 2026 17:17:00 +0800 Subject: [PATCH 1/4] feat: add domain verification status command Add `zeabur domain verification status` to check ICANN registrant verification status. Also show verification status in `domain list-registered`. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/cmd/domain/verification/status.go | 87 +++++++++++++++++++ .../cmd/domain/verification/verification.go | 1 + pkg/model/registered_domain.go | 7 +- 3 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 internal/cmd/domain/verification/status.go diff --git a/internal/cmd/domain/verification/status.go b/internal/cmd/domain/verification/status.go new file mode 100644 index 0000000..78d228b --- /dev/null +++ b/internal/cmd/domain/verification/status.go @@ -0,0 +1,87 @@ +package verification + +import ( + "context" + "fmt" + + "github.com/briandowns/spinner" + "github.com/spf13/cobra" + "github.com/zeabur/cli/internal/cmdutil" +) + +type statusOptions struct { + id string +} + +func newCmdStatus(f *cmdutil.Factory) *cobra.Command { + opts := &statusOptions{} + + cmd := &cobra.Command{ + Use: "status", + Short: "Check the ICANN registrant verification status of a domain", + RunE: func(cmd *cobra.Command, args []string) error { + return runStatus(f, opts) + }, + } + + cmd.Flags().StringVar(&opts.id, "id", "", "Registered domain ID") + + return cmd +} + +func runStatus(f *cmdutil.Factory, opts *statusOptions) error { + ctx := context.Background() + + if opts.id == "" { + if !f.Interactive { + return fmt.Errorf("--id is required") + } + domains, err := f.ApiClient.ListRegisteredDomains(ctx) + if err != nil { + return fmt.Errorf("list registered domains failed: %w", err) + } + if len(domains) == 0 { + return fmt.Errorf("no registered domains found") + } + + options := make([]string, len(domains)) + for i, d := range domains { + options[i] = d.Domain + } + idx, err := f.Prompter.Select("Select domain to check verification status", "", options) + if err != nil { + return err + } + opts.id = domains[idx].ID + } + + s := spinner.New(cmdutil.SpinnerCharSet, cmdutil.SpinnerInterval, + spinner.WithColor(cmdutil.SpinnerColor), + spinner.WithSuffix(" Checking verification status..."), + ) + s.Start() + domain, err := f.ApiClient.GetRegisteredDomain(ctx, opts.id) + s.Stop() + if err != nil { + return fmt.Errorf("get registered domain failed: %w", err) + } + + status := "N/A" + if domain.RegistrantVerificationStatus != nil { + status = *domain.RegistrantVerificationStatus + } + + if f.JSON { + return f.Printer.JSON(map[string]string{ + "domain": domain.Domain, + "status": status, + }) + } + + f.Printer.Table( + []string{"Domain", "Verification Status"}, + [][]string{{domain.Domain, status}}, + ) + + return nil +} diff --git a/internal/cmd/domain/verification/verification.go b/internal/cmd/domain/verification/verification.go index c03e377..6ac42c1 100644 --- a/internal/cmd/domain/verification/verification.go +++ b/internal/cmd/domain/verification/verification.go @@ -15,6 +15,7 @@ func NewCmdVerification(f *cmdutil.Factory) *cobra.Command { Short: "Manage registrant verification for registered domains", } + cmd.AddCommand(newCmdStatus(f)) cmd.AddCommand(newCmdResend(f)) return cmd diff --git a/pkg/model/registered_domain.go b/pkg/model/registered_domain.go index 6966791..bf2fc64 100644 --- a/pkg/model/registered_domain.go +++ b/pkg/model/registered_domain.go @@ -19,14 +19,19 @@ type RegisteredDomain struct { } func (d RegisteredDomain) Header() []string { - return []string{"ID", "Domain", "Status", "Auto-Renew", "Expires", "Price/yr"} + return []string{"ID", "Domain", "Status", "Verification", "Auto-Renew", "Expires", "Price/yr"} } func (d RegisteredDomain) Rows() [][]string { + verification := "N/A" + if d.RegistrantVerificationStatus != nil { + verification = *d.RegistrantVerificationStatus + } return [][]string{{ d.ID, d.Domain, d.Status, + verification, fmt.Sprintf("%v", d.AutoRenew), d.ExpiresAt.Format("2006-01-02"), fmt.Sprintf("$%.2f", float64(d.RenewalPrice)/100), From 5beb41fdbd8e7b18013cc465582a166cce5e3b79 Mon Sep 17 00:00:00 2001 From: yuaanlin Date: Sun, 22 Mar 2026 17:21:14 +0800 Subject: [PATCH 2/4] feat: show registrant profile in get-registered output Query the nested registrantProfile field so users can see which registrant email is associated with a domain. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/cmd/domain/get-registered/get.go | 11 +++++++++++ pkg/model/registered_domain.go | 21 +++++++++++---------- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/internal/cmd/domain/get-registered/get.go b/internal/cmd/domain/get-registered/get.go index e9f54f5..a3057c0 100644 --- a/internal/cmd/domain/get-registered/get.go +++ b/internal/cmd/domain/get-registered/get.go @@ -62,5 +62,16 @@ func runGet(f *cmdutil.Factory, opts *Options) error { } f.Printer.Table(domain.Header(), domain.Rows()) + + if domain.RegistrantProfile != nil { + p := domain.RegistrantProfile + f.Log.Infof("") + f.Log.Infof("Registrant Profile:") + f.Printer.Table( + []string{"Name", "Email", "Phone", "Country"}, + [][]string{{p.FirstName + " " + p.LastName, p.Email, p.Phone, p.Country}}, + ) + } + return nil } diff --git a/pkg/model/registered_domain.go b/pkg/model/registered_domain.go index bf2fc64..bf953b5 100644 --- a/pkg/model/registered_domain.go +++ b/pkg/model/registered_domain.go @@ -6,16 +6,17 @@ import ( ) type RegisteredDomain struct { - ID string `json:"_id" graphql:"_id"` - Domain string `json:"domain" graphql:"domain"` - TLD string `json:"tld" graphql:"tld"` - Status string `json:"status" graphql:"status"` - AutoRenew bool `json:"autoRenew" graphql:"autoRenew"` - ExpiresAt time.Time `json:"expiresAt" graphql:"expiresAt"` - RegisteredAt time.Time `json:"registeredAt" graphql:"registeredAt"` - PurchasePrice int `json:"purchasePrice" graphql:"purchasePrice"` - RenewalPrice int `json:"renewalPrice" graphql:"renewalPrice"` - RegistrantVerificationStatus *string `json:"registrantVerificationStatus" graphql:"registrantVerificationStatus"` + ID string `json:"_id" graphql:"_id"` + Domain string `json:"domain" graphql:"domain"` + TLD string `json:"tld" graphql:"tld"` + Status string `json:"status" graphql:"status"` + AutoRenew bool `json:"autoRenew" graphql:"autoRenew"` + ExpiresAt time.Time `json:"expiresAt" graphql:"expiresAt"` + RegisteredAt time.Time `json:"registeredAt" graphql:"registeredAt"` + PurchasePrice int `json:"purchasePrice" graphql:"purchasePrice"` + RenewalPrice int `json:"renewalPrice" graphql:"renewalPrice"` + RegistrantVerificationStatus *string `json:"registrantVerificationStatus" graphql:"registrantVerificationStatus"` + RegistrantProfile *RegistrantProfile `json:"registrantProfile" graphql:"registrantProfile"` } func (d RegisteredDomain) Header() []string { From 4cb4f05c1dc75fcc8ddf7579e8d50bbf5a835cb5 Mon Sep 17 00:00:00 2001 From: yuaanlin Date: Sun, 22 Mar 2026 17:34:29 +0800 Subject: [PATCH 3/4] feat: add verification update-contact command Allow updating the registrant contact info on a domain via `zeabur domain verification update-contact`. Changing the email triggers a new ICANN verification flow. Interactive mode pre-fills from the current registrant profile. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cmd/domain/verification/update_contact.go | 208 ++++++++++++++++++ .../cmd/domain/verification/verification.go | 1 + 2 files changed, 209 insertions(+) create mode 100644 internal/cmd/domain/verification/update_contact.go diff --git a/internal/cmd/domain/verification/update_contact.go b/internal/cmd/domain/verification/update_contact.go new file mode 100644 index 0000000..e42e2e2 --- /dev/null +++ b/internal/cmd/domain/verification/update_contact.go @@ -0,0 +1,208 @@ +package verification + +import ( + "context" + "fmt" + + "github.com/briandowns/spinner" + "github.com/spf13/cobra" + "github.com/zeabur/cli/internal/cmdutil" + "github.com/zeabur/cli/pkg/model" +) + +type updateContactOptions struct { + id string + firstName string + lastName string + email string + phone string + address1 string + address2 string + city string + state string + country string + postalCode string + organization string +} + +func newCmdUpdateContact(f *cmdutil.Factory) *cobra.Command { + opts := &updateContactOptions{} + + cmd := &cobra.Command{ + Use: "update-contact", + Short: "Update the registrant contact info on a domain (changing email triggers new ICANN verification)", + RunE: func(cmd *cobra.Command, args []string) error { + return runUpdateContact(f, opts) + }, + } + + cmd.Flags().StringVar(&opts.id, "id", "", "Registered domain ID") + cmd.Flags().StringVar(&opts.firstName, "first-name", "", "First name") + cmd.Flags().StringVar(&opts.lastName, "last-name", "", "Last name") + cmd.Flags().StringVar(&opts.email, "email", "", "Email address") + cmd.Flags().StringVar(&opts.phone, "phone", "", "Phone number (E.164 format, e.g. +1.5551234567)") + cmd.Flags().StringVar(&opts.address1, "address1", "", "Address line 1") + cmd.Flags().StringVar(&opts.address2, "address2", "", "Address line 2") + cmd.Flags().StringVar(&opts.city, "city", "", "City") + cmd.Flags().StringVar(&opts.state, "state", "", "State/Province") + cmd.Flags().StringVar(&opts.country, "country", "", "Country (ISO 3166-1 alpha-2, e.g. US)") + cmd.Flags().StringVar(&opts.postalCode, "postal-code", "", "Postal code") + cmd.Flags().StringVar(&opts.organization, "organization", "", "Organization (optional)") + + return cmd +} + +func runUpdateContact(f *cmdutil.Factory, opts *updateContactOptions) error { + ctx := context.Background() + + if opts.id == "" { + if !f.Interactive { + return fmt.Errorf("--id is required") + } + domains, err := f.ApiClient.ListRegisteredDomains(ctx) + if err != nil { + return fmt.Errorf("list registered domains failed: %w", err) + } + if len(domains) == 0 { + return fmt.Errorf("no registered domains found") + } + + options := make([]string, len(domains)) + for i, d := range domains { + options[i] = d.Domain + } + idx, err := f.Prompter.Select("Select domain to update registrant contact", "", options) + if err != nil { + return err + } + opts.id = domains[idx].ID + } + + if f.Interactive { + // Pre-fill from current registrant profile if available + domain, err := f.ApiClient.GetRegisteredDomain(ctx, opts.id) + if err == nil && domain.RegistrantProfile != nil { + p := domain.RegistrantProfile + if opts.firstName == "" { + opts.firstName, _ = f.Prompter.Input("First name: ", p.FirstName) + } + if opts.lastName == "" { + opts.lastName, _ = f.Prompter.Input("Last name: ", p.LastName) + } + if opts.email == "" { + opts.email, _ = f.Prompter.Input("Email: ", p.Email) + } + if opts.phone == "" { + opts.phone, _ = f.Prompter.Input("Phone (e.g. +1.5551234567): ", p.Phone) + } + if opts.address1 == "" { + opts.address1, _ = f.Prompter.Input("Address: ", p.Address1) + } + if opts.address2 == "" { + opts.address2, _ = f.Prompter.Input("Address line 2 (optional): ", "") + } + if opts.city == "" { + opts.city, _ = f.Prompter.Input("City: ", p.City) + } + if opts.state == "" { + opts.state, _ = f.Prompter.Input("State/Province: ", p.State) + } + if opts.country == "" { + opts.country, _ = f.Prompter.Input("Country (e.g. US): ", p.Country) + } + if opts.postalCode == "" { + opts.postalCode, _ = f.Prompter.Input("Postal code: ", p.PostalCode) + } + if opts.organization == "" { + opts.organization, _ = f.Prompter.Input("Organization (optional): ", p.Organization) + } + } else { + if opts.firstName == "" { + opts.firstName, _ = f.Prompter.Input("First name: ", "") + } + if opts.lastName == "" { + opts.lastName, _ = f.Prompter.Input("Last name: ", "") + } + if opts.email == "" { + opts.email, _ = f.Prompter.Input("Email: ", "") + } + if opts.phone == "" { + opts.phone, _ = f.Prompter.Input("Phone (e.g. +1.5551234567): ", "") + } + if opts.address1 == "" { + opts.address1, _ = f.Prompter.Input("Address: ", "") + } + if opts.address2 == "" { + opts.address2, _ = f.Prompter.Input("Address line 2 (optional): ", "") + } + if opts.city == "" { + opts.city, _ = f.Prompter.Input("City: ", "") + } + if opts.state == "" { + opts.state, _ = f.Prompter.Input("State/Province: ", "") + } + if opts.country == "" { + opts.country, _ = f.Prompter.Input("Country (e.g. US): ", "") + } + if opts.postalCode == "" { + opts.postalCode, _ = f.Prompter.Input("Postal code: ", "") + } + if opts.organization == "" { + opts.organization, _ = f.Prompter.Input("Organization (optional): ", "") + } + } + } + + // Validate required fields + for _, check := range []struct{ val, flag string }{ + {opts.firstName, "--first-name"}, + {opts.lastName, "--last-name"}, + {opts.email, "--email"}, + {opts.phone, "--phone"}, + {opts.address1, "--address1"}, + {opts.city, "--city"}, + {opts.state, "--state"}, + {opts.country, "--country"}, + {opts.postalCode, "--postal-code"}, + } { + if check.val == "" { + return fmt.Errorf("%s is required", check.flag) + } + } + + input := model.UpdateRegistrantContactInput{ + FirstName: opts.firstName, + LastName: opts.lastName, + Email: opts.email, + Phone: opts.phone, + Address1: opts.address1, + City: opts.city, + State: opts.state, + Country: opts.country, + PostalCode: opts.postalCode, + } + if opts.address2 != "" { + input.Address2 = &opts.address2 + } + if opts.organization != "" { + input.Organization = &opts.organization + } + + s := spinner.New(cmdutil.SpinnerCharSet, cmdutil.SpinnerInterval, + spinner.WithColor(cmdutil.SpinnerColor), + spinner.WithSuffix(" Updating registrant contact..."), + ) + s.Start() + err := f.ApiClient.UpdateRegistrantContact(ctx, opts.id, input) + s.Stop() + if err != nil { + return fmt.Errorf("update registrant contact failed: %w", err) + } + + f.Log.Infof("Registrant contact updated successfully") + if opts.email != "" { + f.Log.Infof("Note: changing the email triggers a new ICANN verification flow") + } + + return nil +} diff --git a/internal/cmd/domain/verification/verification.go b/internal/cmd/domain/verification/verification.go index 6ac42c1..adde375 100644 --- a/internal/cmd/domain/verification/verification.go +++ b/internal/cmd/domain/verification/verification.go @@ -17,6 +17,7 @@ func NewCmdVerification(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(newCmdStatus(f)) cmd.AddCommand(newCmdResend(f)) + cmd.AddCommand(newCmdUpdateContact(f)) return cmd } From 8776ef938ed72a21dea61a56dc07e674bc652e7a Mon Sep 17 00:00:00 2001 From: yuaanlin Date: Sun, 22 Mar 2026 17:39:36 +0800 Subject: [PATCH 4/4] fix: only show ICANN verification note when email actually changed Track the original registrant email before prompts and compare after update to avoid always showing the verification warning. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/cmd/domain/verification/update_contact.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/cmd/domain/verification/update_contact.go b/internal/cmd/domain/verification/update_contact.go index e42e2e2..b2d7341 100644 --- a/internal/cmd/domain/verification/update_contact.go +++ b/internal/cmd/domain/verification/update_contact.go @@ -78,11 +78,14 @@ func runUpdateContact(f *cmdutil.Factory, opts *updateContactOptions) error { opts.id = domains[idx].ID } + var originalEmail string + if f.Interactive { // Pre-fill from current registrant profile if available domain, err := f.ApiClient.GetRegisteredDomain(ctx, opts.id) if err == nil && domain.RegistrantProfile != nil { p := domain.RegistrantProfile + originalEmail = p.Email if opts.firstName == "" { opts.firstName, _ = f.Prompter.Input("First name: ", p.FirstName) } @@ -200,7 +203,7 @@ func runUpdateContact(f *cmdutil.Factory, opts *updateContactOptions) error { } f.Log.Infof("Registrant contact updated successfully") - if opts.email != "" { + if opts.email != originalEmail { f.Log.Infof("Note: changing the email triggers a new ICANN verification flow") }