From 4266bfbfc2465a4ad43346d2739c105f8459ea50 Mon Sep 17 00:00:00 2001 From: juancwu Date: Wed, 21 Jan 2026 21:27:08 +0000 Subject: [PATCH] add action items menu --- go.mod | 2 +- go.sum | 10 +++- internal/app/app.go | 110 +++++++++++++++++++++++++++++++------ internal/porkbun/client.go | 46 ++++++++++++++++ internal/porkbun/models.go | 24 ++++++++ 5 files changed, 172 insertions(+), 20 deletions(-) create mode 100644 internal/porkbun/models.go diff --git a/go.mod b/go.mod index beffffb..8076c9b 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,6 @@ go 1.25.6 require ( github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.10 - github.com/joho/godotenv v1.5.1 ) require ( @@ -25,6 +24,7 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/sahilm/fuzzy v0.1.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sys v0.36.0 // indirect golang.org/x/text v0.3.8 // indirect diff --git a/go.sum b/go.sum index 8770deb..a5aaf28 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= @@ -14,12 +16,14 @@ github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7 github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= -github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -37,6 +41,8 @@ github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= diff --git a/internal/app/app.go b/internal/app/app.go index 6f5892a..fe2a9e2 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -5,6 +5,7 @@ import ( "git.juancwu.dev/juancwu/porkbacon/internal/pass" "git.juancwu.dev/juancwu/porkbacon/internal/porkbun" + "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" @@ -13,7 +14,9 @@ import ( const ( StateInit uint = iota StateLoadingCredentials - StateIdle + StateMenu + StateExecuting + StateResult ) type CredentialsLoadedMsg struct { @@ -21,8 +24,20 @@ type CredentialsLoadedMsg struct { SecretKey string } +type PingResultMsg struct { + IP string +} + type ErrMsg error +type item struct { + title, desc string +} + +func (i item) Title() string { return i.title } +func (i item) Description() string { return i.desc } +func (i item) FilterValue() string { return i.title } + type App struct { state uint @@ -32,8 +47,10 @@ type App struct { textInput textinput.Model spinner spinner.Model + list list.Model - err error + result string + err error } func New() App { @@ -45,10 +62,24 @@ func New() App { s := spinner.New() s.Spinner = spinner.Dot + items := []list.Item{ + item{title: "Domain: List All", desc: "List all domains in your account"}, + item{title: "Domain: Get Details", desc: "Get details for a specific domain"}, + item{title: "DNS: Retrieve Records", desc: "Retrieve DNS records for a domain"}, + item{title: "DNS: Create Record", desc: "Create a new DNS record"}, + item{title: "DNS: Edit Record", desc: "Edit an existing DNS record"}, + item{title: "DNS: Delete Record", desc: "Delete a DNS record"}, + item{title: "Util: Ping", desc: "Ping Porkbun"}, + } + + l := list.New(items, list.NewDefaultDelegate(), 0, 0) + l.Title = "Porkbun Actions" + return App{ state: StateInit, textInput: ti, spinner: s, + list: l, } } @@ -65,20 +96,38 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.String() { case "ctrl+c": return a, tea.Quit + case "esc": + if a.state == StateResult { + a.state = StateMenu + a.result = "" + a.err = nil + return a, nil + } case "enter": if a.state == StateInit { return a.handleEnter() + } else if a.state == StateMenu { + return a.handleMenuEnter() } } + case tea.WindowSizeMsg: + a.list.SetWidth(msg.Width) + a.list.SetHeight(msg.Height) + case CredentialsLoadedMsg: a.client = porkbun.New(msg.APIKey, msg.SecretKey) - a.state = StateIdle + a.state = StateMenu + return a, nil + + case PingResultMsg: + a.result = fmt.Sprintf("Ping successful!\nYour IP: %s", msg.IP) + a.state = StateResult return a, nil case ErrMsg: a.err = msg - a.state = StateIdle // Or stay in error state + a.state = StateResult // Show error in result view return a, nil } @@ -87,17 +136,26 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) } - if a.state == StateLoadingCredentials { + if a.state == StateLoadingCredentials || a.state == StateExecuting { a.spinner, cmd = a.spinner.Update(msg) cmds = append(cmds, cmd) } + if a.state == StateMenu { + a.list, cmd = a.list.Update(msg) + cmds = append(cmds, cmd) + } + return a, tea.Sequence(cmds...) } func (a *App) View() string { - if a.err != nil { - return fmt.Sprintf("Error: %v\n\nPress Ctrl+C to quit.", a.err) + if a.state == StateResult { + content := a.result + if a.err != nil { + content = fmt.Sprintf("Error: %v", a.err) + } + return fmt.Sprintf("%s\n\n(Press Esc to go back)", content) } switch a.state { @@ -105,20 +163,14 @@ func (a *App) View() string { return a.renderPorkbunCredentialForm() case StateLoadingCredentials: return fmt.Sprintf("%s Loading credentials...", a.spinner.View()) - case StateIdle: - return fmt.Sprintf("Ready!\nAPI Key: %s...\nSecret: %s...\n", - mask(a.client.APIKey), mask(a.client.SecretAPIKey)) + case StateMenu: + return a.list.View() + case StateExecuting: + return fmt.Sprintf("%s Executing...", a.spinner.View()) } return "Invalid state" } -func mask(s string) string { - if len(s) > 4 { - return s[:4] + "...." - } - return "...." -} - func (a *App) renderPorkbunCredentialForm() string { title := "Enter Porkbun API Key Name (pass entry):" if a.apiKeyName != "" { @@ -151,6 +203,30 @@ func (a *App) handleEnter() (tea.Model, tea.Cmd) { return a, nil } +func (a *App) handleMenuEnter() (tea.Model, tea.Cmd) { + i, ok := a.list.SelectedItem().(item) + if !ok { + return a, nil + } + + switch i.title { + case "Util: Ping": + a.state = StateExecuting + return a, a.performPing() + } + return a, nil +} + +func (a *App) performPing() tea.Cmd { + return func() tea.Msg { + resp, err := a.client.Ping() + if err != nil { + return ErrMsg(err) + } + return PingResultMsg{IP: resp.YourIP} + } +} + func retrievePorkbunCredentialsCmd(apiKeyName, secretKeyName string) tea.Cmd { return func() tea.Msg { apiKey, err := pass.Get(apiKeyName) diff --git a/internal/porkbun/client.go b/internal/porkbun/client.go index f8ada33..3cda6f7 100644 --- a/internal/porkbun/client.go +++ b/internal/porkbun/client.go @@ -25,3 +25,49 @@ func New(apiKey, secretKey string) *Client { }, } } + +func (c *Client) Ping() (*PingResponse, error) { + reqBody := PingRequest{ + BaseRequest: BaseRequest{ + APIKey: c.APIKey, + SecretAPIKey: c.SecretAPIKey, + }, + } + + var resp PingResponse + err := c.post("/ping", reqBody, &resp) + if err != nil { + return nil, err + } + + return &resp, nil +} + +func (c *Client) post(endpoint string, body interface{}, target interface{}) error { + jsonBody, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("failed to marshal request body: %w", err) + } + + req, err := http.NewRequest("POST", BaseURL+endpoint, bytes.NewBuffer(jsonBody)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + res, err := c.HTTPClient.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer res.Body.Close() + + if res.StatusCode >= 400 { + return fmt.Errorf("API returned error status: %d", res.StatusCode) + } + + if err := json.NewDecoder(res.Body).Decode(target); err != nil { + return fmt.Errorf("failed to decode response: %w", err) + } + + return nil +} diff --git a/internal/porkbun/models.go b/internal/porkbun/models.go new file mode 100644 index 0000000..fd23a10 --- /dev/null +++ b/internal/porkbun/models.go @@ -0,0 +1,24 @@ +package porkbun + +// BaseRequest contains the authentication fields required for most Porkbun API calls. +type BaseRequest struct { + APIKey string `json:"apikey"` + SecretAPIKey string `json:"secretapikey"` +} + +// BaseResponse contains the common fields returned by the Porkbun API. +type BaseResponse struct { + Status string `json:"status"` + Message string `json:"message,omitempty"` +} + +// PingRequest is used to verify credentials. +type PingRequest struct { + BaseRequest +} + +// PingResponse is the response from the ping endpoint. +type PingResponse struct { + BaseResponse + YourIP string `json:"yourIp"` +}