Skip to content

feat: add domain registration & DNS management commands#189

Merged
yuaanlin merged 2 commits intomainfrom
feat/domain-registration-dns
Mar 20, 2026
Merged

feat: add domain registration & DNS management commands#189
yuaanlin merged 2 commits intomainfrom
feat/domain-registration-dns

Conversation

@yuaanlin
Copy link
Member

@yuaanlin yuaanlin commented Mar 20, 2026

Summary

  • Add complete domain lifecycle CLI commands: search, purchase, list, renew, auto-renew
  • Add DNS record management: list, create, update, delete
  • Add registrant profile management: list, create, update, delete
  • Align all GraphQL queries/mutations with backend API naming (checkDomainRegistrationAvailability, registeredDomainDNSRecords, RegisteredDomainDNSRecordType, etc.)

UX Highlights

  • DNS commands accept --domain (e.g. --domain zbr-test-final.org) instead of requiring --domain-id
  • dns update and dns delete identify records by --type + --name (e.g. --type A --name test-cli) — no need to look up record IDs
  • All commands support both interactive (guided prompts) and non-interactive (flags) modes
  • All commands support --json output
  • Purchase command uses 90s timeout to handle backend saga (OpenSRS + Cloudflare)

New Commands

Command Description
domain search <domain> Check domain registration availability
domain purchase <domain> Purchase a domain
domain list-registered List all owned domains
domain get-registered Get details of an owned domain
domain renew Renew a domain
domain auto-renew Toggle auto-renewal
domain dns list List DNS records
domain dns create Create a DNS record
domain dns update Update a DNS record
domain dns delete Delete a DNS record
domain registrant list List registrant profiles
domain registrant create Create a registrant profile
domain registrant update Update a registrant profile
domain registrant delete Delete a registrant profile

Test plan

  • go build ./... compiles successfully
  • go vet ./... no warnings
  • zeabur domain search example.com returns availability + price
  • zeabur domain list-registered lists owned domains
  • zeabur domain dns list --domain zbr-test-final.org lists DNS records
  • zeabur domain dns create --domain zbr-test-final.org --type A --name test-cli --content 1.2.3.4 creates record
  • zeabur domain dns delete --domain zbr-test-final.org --type A --name test-cli deletes record

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Domain search and purchase from the CLI.
    • Manage registered domains: view details, renew, and toggle auto-renew.
    • Full DNS management for registered domains: list, create, update, and delete records.
    • Registrant profile management: list, create, update, and delete contact profiles.
    • Improved CLI commands with interactive prompts and JSON/table output options.

… commands

Add complete domain lifecycle management to the CLI:
- `domain search` — check domain registration availability
- `domain purchase` — purchase a domain with 90s timeout for backend saga
- `domain list-registered` / `domain get-registered` — view owned domains
- `domain renew` / `domain auto-renew` — renewal management
- `domain dns list/create/update/delete` — DNS record CRUD
- `domain registrant list/create/update/delete` — registrant profile management

UX improvements:
- DNS commands accept `--domain` (name) instead of requiring `--domain-id`
- `dns update` and `dns delete` identify records by `--type` + `--name`
  instead of requiring users to look up record IDs
- All commands support interactive mode with guided prompts
- All commands support `--json` output

Aligns CLI with backend GraphQL API naming:
- `checkDomainRegistrationAvailability` (not `searchDomains`)
- `registeredDomainDNSRecords` / `createRegisteredDomainDNSRecord` etc.
- `RegisteredDomainDNSRecordType` enum

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link

coderabbitai bot commented Mar 20, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4d0a1238-46f7-4525-a6a4-b43b8acf0bfd

📥 Commits

Reviewing files that changed from the base of the PR and between f49aa99 and 0f5750e.

📒 Files selected for processing (4)
  • internal/cmd/domain/auto-renew/auto_renew.go
  • internal/cmd/domain/dns/update/update.go
  • internal/cmd/domain/get-registered/get.go
  • pkg/model/registered_domain.go
✅ Files skipped from review due to trivial changes (2)
  • internal/cmd/domain/auto-renew/auto_renew.go
  • pkg/model/registered_domain.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/cmd/domain/get-registered/get.go
  • internal/cmd/domain/dns/update/update.go

Walkthrough

Adds a full registered-domain feature set to the CLI: domain search/purchase/renew/auto-renew, DNS record CRUD, registrant profile CRUD, corresponding API client methods, and new data models with CLI output helpers.

Changes

Cohort / File(s) Summary
Registered Domain Commands
internal/cmd/domain/search/search.go, internal/cmd/domain/purchase/purchase.go, internal/cmd/domain/renew/renew.go, internal/cmd/domain/auto-renew/auto_renew.go, internal/cmd/domain/list-registered/list.go, internal/cmd/domain/get-registered/get.go
New CLI commands for searching, purchasing, listing, retrieving, renewing, and toggling auto-renew of registered domains. Include interactive prompts, flag validation, spinner usage, API calls, and JSON/table output handling.
DNS Subcommands & Utilities
internal/cmd/domain/dns/dns.go, internal/cmd/domain/dns/list/list.go, internal/cmd/domain/dns/create/create.go, internal/cmd/domain/dns/update/update.go, internal/cmd/domain/dns/delete/delete.go, internal/cmd/domain/dns/dnsutil/resolve.go
Introduces dns parent command and list/create/update/delete subcommands. Adds domain ID resolution and record-matching helpers; implements interactive and non-interactive flows, validations, and conditional field updates for DNS records.
Registrant Profile Commands
internal/cmd/domain/registrant/registrant.go, internal/cmd/domain/registrant/list/list.go, internal/cmd/domain/registrant/create/create.go, internal/cmd/domain/registrant/update/update.go, internal/cmd/domain/registrant/delete/delete.go
Adds registrant profile management commands (list/create/update/delete) with interactive prompts, required-field validation, partial-update inputs, selection/confirmation flows, and JSON/table output.
Domain Command Registration
internal/cmd/domain/domain.go
Registers new subcommands under the existing domain parent (search, purchase, list-registered, get-registered, renew, auto-renew, dns, registrant).
API Surface & Implementation
pkg/api/interface.go, pkg/api/registered_domain.go
Extends the exported Client interface with a new RegisteredDomainAPI and implements GraphQL-backed methods for domain availability, purchase, list/get/renew/auto-renew, DNS record CRUD, and registrant profile CRUD.
Data Models
pkg/model/registered_domain.go
Adds models for registered domains, domain search results, DNS records, registrant profiles, purchase results, input structs for create/update, DNS record type constants, and CLI table helper methods (Header()/Rows()).

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant User
  participant CLI
  participant Prompter
  participant ApiClient
  participant Printer
  User->>CLI: run "purchase [domain]" (flags)
  CLI->>Prompter: prompt for missing inputs (domain, registrant)
  Prompter-->>CLI: user selections
  CLI->>ApiClient: CheckDomainRegistrationAvailability(domain)
  ApiClient-->>CLI: availability + price
  CLI->>Prompter: confirm purchase (if interactive)
  Prompter-->>CLI: confirmation
  CLI->>ApiClient: PurchaseDomain(domain, registrantID)
  ApiClient-->>CLI: PurchaseDomainResult
  CLI->>Printer: print JSON or table/log
  Printer-->>User: output
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely summarizes the main change: adding domain registration and DNS management commands to the CLI.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/domain-registration-dns
📝 Coding Plan
  • Generate coding plan for human review comments

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (5)
internal/cmd/domain/domain.go (1)

25-25: Redundant alias: the alias matches the command name.

Aliases: []string{"domain"} is the same as the Use field value. This alias has no effect since users would already invoke the command as zeabur domain.

🔧 Proposed fix
-		Aliases: []string{"domain"},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/cmd/domain/domain.go` at line 25, Remove the redundant alias
declaration that duplicates the command name: delete the Aliases:
[]string{"domain"} entry from the Cobra command definition (the command block
containing the Use: "domain" field / the command variable where Aliases is
defined), or set Aliases to nil/empty if explicit presence is required; this
ensures no-op alias is removed while leaving the rest of the command (Use,
Short, Run, etc.) unchanged.
internal/cmd/domain/dns/dnsutil/resolve.go (1)

25-37: Clarify behavior when both filters are empty.

When both recordType and name are empty strings, FindRecord returns all records. This appears intentional based on the filter logic, but consider adding a doc comment to clarify expected behavior.

📝 Suggested documentation
+// FindRecord filters DNS records by type and/or name (case-insensitive).
+// Pass empty strings to skip filtering on that field.
+// Returns all records if both filters are empty.
 func FindRecord(records model.DNSRecords, recordType, name string) ([]model.DNSRecord, error) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/cmd/domain/dns/dnsutil/resolve.go` around lines 25 - 37, Add a doc
comment for the FindRecord function that explicitly documents its filter
semantics: that passing empty string for recordType and/or name acts as a
wildcard (matches any type/name) and that when both are empty the function
returns all records; reference the function name FindRecord and its parameters
recordType and name (and the types model.DNSRecords/model.DNSRecord) so callers
understand the intended behavior and returned error when no matches are found.
internal/cmd/domain/registrant/create/create.go (1)

59-130: Interactive mode allows empty required fields.

In runCreateInteractive, if a user presses Enter without typing anything, the field remains empty (e.g., firstName stays ""). The function then calls runCreateNonInteractive which will catch this and return an error, but the UX could be improved by re-prompting for required fields.

💡 Example: loop until non-empty for required fields
for opts.firstName == "" {
    opts.firstName, err = f.Prompter.Input("First name (required): ", "")
    if err != nil {
        return err
    }
    if opts.firstName == "" {
        f.Log.Warn("First name is required")
    }
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/cmd/domain/registrant/create/create.go` around lines 59 - 130,
runCreateInteractive allows required fields to remain empty if the user just
hits Enter, delegating validation to runCreateNonInteractive; update
runCreateInteractive so each required field (firstName, lastName, email, phone,
address1, city, state, country, postalCode) uses a loop that re-prompts via
f.Prompter.Input until a non-empty value is provided (handle and return any
input error), and optionally call f.Log.Warn or similar between attempts to tell
the user the field is required; keep optional fields (address2, organization)
single-prompt. Ensure you only change runCreateInteractive and keep
runCreateNonInteractive validation as-is.
internal/cmd/domain/registrant/update/update.go (1)

79-112: Consider validating that at least one field is provided for update.

Currently, a user can call update --id <id> without specifying any fields to change. This would result in an API call with an empty input, which may be a no-op or could cause unexpected behavior depending on the backend.

💡 Proposed validation
+	// Check if at least one field is being updated
+	if opts.firstName == "" && opts.lastName == "" && opts.email == "" &&
+		opts.phone == "" && opts.address1 == "" && opts.address2 == "" &&
+		opts.city == "" && opts.state == "" && opts.country == "" &&
+		opts.postalCode == "" && opts.organization == "" {
+		return fmt.Errorf("at least one field must be specified to update")
+	}
+
 	input := model.UpdateRegistrantProfileInput{}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/cmd/domain/registrant/update/update.go` around lines 79 - 112, The
update handler currently builds a model.UpdateRegistrantProfileInput from opts
but doesn't ensure any field was set; after populating input (the variable named
input of type model.UpdateRegistrantProfileInput built from opts), add a check
that at least one field pointer in input is non-nil (or equivalently that any
opts.* string was non-empty) and if none are set return or exit with a clear
error (e.g., "no fields to update") to prevent sending an empty update; update
the calling function that handles the command to surface this error to the user
(referencing opts, input, and model.UpdateRegistrantProfileInput to locate the
code).
internal/cmd/domain/dns/list/list.go (1)

63-98: Consider extracting domain resolution to a shared helper.

The resolveDomainID function here duplicates the same logic found in dns/delete/delete.go and dns/update/update.go. While dnsutil.ResolveDomainID handles the name→ID lookup, the interactive selection fallback is repeated in each command.

♻️ Suggested approach

Extract the full resolution logic (including interactive fallback) to dnsutil:

// In dnsutil/resolve.go
func ResolveDomainIDInteractive(ctx context.Context, f *cmdutil.Factory, domainID, domain string) (string, error) {
	if domainID != "" {
		return domainID, nil
	}
	if domain != "" {
		return ResolveDomainID(ctx, f.ApiClient, domain)
	}
	if !f.Interactive {
		return "", fmt.Errorf("--domain is required")
	}
	// ... interactive selection logic
}

This would reduce duplication across list/create/update/delete commands.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/cmd/domain/dns/list/list.go` around lines 63 - 98, Extract the
repeated domain-resolution + interactive fallback into a new dnsutil helper
(e.g. func ResolveDomainIDInteractive(ctx context.Context, f *cmdutil.Factory,
domainID, domain string) (string, error)) that implements the current logic in
resolveDomainID: return domainID if set, call dnsutil.ResolveDomainID(ctx,
f.ApiClient, domain) when domain is provided, and if neither is set and
f.Interactive is true present the interactive selection using
f.ApiClient.ListRegisteredDomains and f.Prompter.Select (return an error when
non‑interactive). Replace the local resolveDomainID function in list.go and the
duplicated implementations in dns/delete/delete.go and dns/update/update.go to
call dnsutil.ResolveDomainIDInteractive and assign the returned ID to
opts.domainID, preserving existing error messages.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/cmd/domain/auto-renew/auto_renew.go`:
- Around line 65-82: The mutual exclusivity check for opts.enable and
opts.disable must run before any interactive prompts; move the existing check
(the fmt.Errorf("cannot use both --enable and --disable") guard that references
opts.enable and opts.disable) to immediately after flag parsing and before the
block that checks !opts.enable && !opts.disable and the Prompter.Select call
(which uses f.Interactive and f.Prompter.Select). This ensures you validate the
flags first and return early if both are set, avoiding prompting for domain
selection when the flags are invalid.

In `@internal/cmd/domain/dns/update/update.go`:
- Around line 69-81: The update logic treats zero-values as "not set",
preventing explicit disabling/zeroing; change flag handling to detect whether
each flag was explicitly provided (use cmd.Flags().Changed for the proxied, ttl
and priority flags) and only set input.Proxied, input.TTL, and input.Priority
when the corresponding "was set" boolean is true; add boolean variables (e.g.
proxiedSet, ttlSet, prioritySet) when parsing flags and use them in runUpdate to
decide whether to assign opts.proxied, opts.ttl, opts.priority into the
model.UpdateDNSRecordInput fields.

In `@internal/cmd/domain/get-registered/get.go`:
- Around line 18-19: The cobra command is incorrectly named "get" — update the
cobra.Command literal assigned to cmd so its Use field is "get-registered"
(matching the package name get-registered and the PR objective); locate the cmd
:= &cobra.Command{ ... } construct and change Use: "get" to Use:
"get-registered" so the CLI becomes `zeabur domain get-registered`.

In `@pkg/model/registered_domain.go`:
- Around line 62-75: The Rows method on DomainSearchResults is causing the
prealloc lint failure because rows is built with repeated appends; preallocate
the slice capacity to avoid reallocations and satisfy the linter: initialize
rows with make([][]string, 0, len(ds)) (or allocate a len(ds) slice and assign
by index) and then populate it using the existing loop that builds each []string
for d.Domain, avail, price; update the function Rows accordingly.
- Around line 41-46: The Rows method on RegisteredDomains currently appends into
rows without preallocating, triggering the linter prealloc failure; change
Rows() to preallocate the outer slice using make with length or capacity based
on len(ds) (referencing RegisteredDomains.Rows and the local variable rows) and
then populate it (e.g., iterate with index i and assign rows[i] = d.Rows()[0] or
use append with preallocated capacity) so the slice is allocated up-front.

---

Nitpick comments:
In `@internal/cmd/domain/dns/dnsutil/resolve.go`:
- Around line 25-37: Add a doc comment for the FindRecord function that
explicitly documents its filter semantics: that passing empty string for
recordType and/or name acts as a wildcard (matches any type/name) and that when
both are empty the function returns all records; reference the function name
FindRecord and its parameters recordType and name (and the types
model.DNSRecords/model.DNSRecord) so callers understand the intended behavior
and returned error when no matches are found.

In `@internal/cmd/domain/dns/list/list.go`:
- Around line 63-98: Extract the repeated domain-resolution + interactive
fallback into a new dnsutil helper (e.g. func ResolveDomainIDInteractive(ctx
context.Context, f *cmdutil.Factory, domainID, domain string) (string, error))
that implements the current logic in resolveDomainID: return domainID if set,
call dnsutil.ResolveDomainID(ctx, f.ApiClient, domain) when domain is provided,
and if neither is set and f.Interactive is true present the interactive
selection using f.ApiClient.ListRegisteredDomains and f.Prompter.Select (return
an error when non‑interactive). Replace the local resolveDomainID function in
list.go and the duplicated implementations in dns/delete/delete.go and
dns/update/update.go to call dnsutil.ResolveDomainIDInteractive and assign the
returned ID to opts.domainID, preserving existing error messages.

In `@internal/cmd/domain/domain.go`:
- Line 25: Remove the redundant alias declaration that duplicates the command
name: delete the Aliases: []string{"domain"} entry from the Cobra command
definition (the command block containing the Use: "domain" field / the command
variable where Aliases is defined), or set Aliases to nil/empty if explicit
presence is required; this ensures no-op alias is removed while leaving the rest
of the command (Use, Short, Run, etc.) unchanged.

In `@internal/cmd/domain/registrant/create/create.go`:
- Around line 59-130: runCreateInteractive allows required fields to remain
empty if the user just hits Enter, delegating validation to
runCreateNonInteractive; update runCreateInteractive so each required field
(firstName, lastName, email, phone, address1, city, state, country, postalCode)
uses a loop that re-prompts via f.Prompter.Input until a non-empty value is
provided (handle and return any input error), and optionally call f.Log.Warn or
similar between attempts to tell the user the field is required; keep optional
fields (address2, organization) single-prompt. Ensure you only change
runCreateInteractive and keep runCreateNonInteractive validation as-is.

In `@internal/cmd/domain/registrant/update/update.go`:
- Around line 79-112: The update handler currently builds a
model.UpdateRegistrantProfileInput from opts but doesn't ensure any field was
set; after populating input (the variable named input of type
model.UpdateRegistrantProfileInput built from opts), add a check that at least
one field pointer in input is non-nil (or equivalently that any opts.* string
was non-empty) and if none are set return or exit with a clear error (e.g., "no
fields to update") to prevent sending an empty update; update the calling
function that handles the command to surface this error to the user (referencing
opts, input, and model.UpdateRegistrantProfileInput to locate the code).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e6e743f3-e594-471a-a3ef-12dc48ad7b39

📥 Commits

Reviewing files that changed from the base of the PR and between ade1a3d and f49aa99.

📒 Files selected for processing (21)
  • internal/cmd/domain/auto-renew/auto_renew.go
  • internal/cmd/domain/dns/create/create.go
  • internal/cmd/domain/dns/delete/delete.go
  • internal/cmd/domain/dns/dns.go
  • internal/cmd/domain/dns/dnsutil/resolve.go
  • internal/cmd/domain/dns/list/list.go
  • internal/cmd/domain/dns/update/update.go
  • internal/cmd/domain/domain.go
  • internal/cmd/domain/get-registered/get.go
  • internal/cmd/domain/list-registered/list.go
  • internal/cmd/domain/purchase/purchase.go
  • internal/cmd/domain/registrant/create/create.go
  • internal/cmd/domain/registrant/delete/delete.go
  • internal/cmd/domain/registrant/list/list.go
  • internal/cmd/domain/registrant/registrant.go
  • internal/cmd/domain/registrant/update/update.go
  • internal/cmd/domain/renew/renew.go
  • internal/cmd/domain/search/search.go
  • pkg/api/interface.go
  • pkg/api/registered_domain.go
  • pkg/model/registered_domain.go

- Move --enable/--disable mutual exclusivity check before interactive prompts in auto-renew
- Use cmd.Flags().Changed() for ttl/priority/proxied in dns update to allow explicit zero values
- Fix get-registered command Use name from "get" to "get-registered"
- Preallocate slices in RegisteredDomains.Rows() and DomainSearchResults.Rows() to fix prealloc lint

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@yuaanlin yuaanlin merged commit b79f625 into main Mar 20, 2026
5 checks passed
@yuaanlin yuaanlin deleted the feat/domain-registration-dns branch March 20, 2026 08:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant