From c115792e65132b3594bd221800c0057c655b718d Mon Sep 17 00:00:00 2001 From: stawan15 Date: Sun, 19 Jul 2026 00:23:23 +0700 Subject: [PATCH 1/4] feat: redesign UI, add DB dump/restore, and implement confirmations --- .gitignore | 1 + README.md | 86 ++++++++++++--------- kamal.go | 60 +++++++++++++-- main.go | 218 ++++++++++++++++++++++++++++++++++++++++++++++++++--- styles.go | 25 +++--- 5 files changed, 329 insertions(+), 61 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a7989f3 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +kamal-tui diff --git a/README.md b/README.md index 5a31074..7dabc3b 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,76 @@ # kamal-tui -[![Go Report Card](https://goreportcard.com/badge/github.com/stawan15/kamal-tui)](https://goreportcard.com/report/github.com/stawan15/kamal-tui) -[![GitHub Release](https://img.shields.io/github/v/release/stawan15/kamal-tui)](https://github.com/stawan15/kamal-tui/releases) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +A multi-panel, terminal UI (TUI) for managing [Kamal](https://kamal-deploy.org/) deployments effortlessly. -A `lazygit`-style Terminal UI (TUI) dashboard for [Kamal](https://kamal-deploy.org). +Built with Go and [Bubble Tea](https://github.com/charmbracelet/bubbletea), it provides a fast, interactive, and secure way to manage your Rails/Docker applications. -Instead of running `kamal deploy -d destination` blindly and waiting, `kamal-tui` provides a rich interactive interface that lets you select your destination, perform actions (deploy, rollback, logs, etc.), and watch the streamed output all on one screen. +![Demo](https://github.com/stawan15/kamal-tui/assets/placeholder.png) -![Demo](demo.gif) +## 🌟 Features -## Features +- **Lazygit-style UI:** See Destinations, Actions, and Logs all in one unified screen. +- **Secure Secrets Manager:** Store Kamal secrets (like `DATABASE_URL`) directly in your OS Keychain (like Azure Pipelines). No more plaintext `.env` or `.kamal/secrets` files hanging around! +- **Interactive Confirmations:** Built-in confirmations for all commands to prevent accidental deployments or rollbacks. +- **DB Dump & Restore:** Built-in actions to quickly run `pg_dump` and `pg_restore` (Fully customizable for your stack). +- **Keyboard & Mouse Support:** Navigate lists with mouse scroll, clicks, or keyboard (`j/k`, `tab`). -- **Multi-Panel Interface:** See your destinations, actions, and output streams simultaneously. -- **Mouse & Keyboard Support:** Click on elements or use handy keybindings (`d` for deploy, `r` for rollback, `l` for logs). -- **Fast Navigation:** Use `Tab` or `h`/`l` to jump between panels. -- **Streaming Output:** View `kamal` command output directly in the dashboard without switching context. - -## Installation - -### Using Homebrew (macOS / Linux) +## 🚀 Installation +### Option 1: Using Homebrew (macOS / Linux) ```bash -brew install stawan15/tap/kamal-tui +# Add the tap and install +brew tap stawan15/kamal-tui +brew install kamal-tui ``` -### Using Go - +### Option 2: Using Go +If you have Go 1.20+ installed, you can build and install it directly: ```bash go install github.com/stawan15/kamal-tui@latest ``` -### Binary Release - -Download the pre-compiled binary for your OS/Architecture from the [Releases page](https://github.com/stawan15/kamal-tui/releases). +## 🎮 Usage -## Usage - -Simply run `kamal-tui` from the root of your project where `config/deploy.yml` (and other destination configs like `config/deploy.staging.yml`) is located. +Simply run `kamal-tui` from the root of your Rails (or any Kamal-managed) project: ```bash +cd your-rails-project kamal-tui ``` -### Keybindings +### Keyboard Shortcuts -- **`Tab`, `h`, `l`**: Switch focus between panels (Destinations / Actions / Logs) -- **`d`**: Quick deploy the selected destination -- **`r`**: Quick rollback the selected destination -- **`l`**: Quick view logs for the selected destination -- **`Enter`**: Execute the highlighted action on the selected destination -- **`q`, `Ctrl+C`**: Quit -- **`Esc`**: Cancel current input / operation +| Key | Action | +|-----|--------| +| `d` | Quick Deploy | +| `r` | Quick Rollback (Prompts for version) | +| `l` | View App Logs | +| `s` | Open Secure Secrets Manager | +| `tab` | Switch between Destinations, Actions, and Logs panels | +| `q` | Quit | -## Contributing +### 🔐 Managing Secrets Securely + +1. Press `s` in the TUI to open the **Secure Secrets Manager**. +2. Press `a` to add a new secret (e.g. `DATABASE_URL`). +3. Type the secret value (it will be hidden as `***`). +4. Press `Enter` to save. It is securely encrypted in your OS keychain. +5. When you run any `kamal` command from the TUI, these secrets are injected into the command's environment on the fly. +6. Press `delete` or `x` to remove a secret. -See [CONTRIBUTING.md](CONTRIBUTING.md) for how you can help! +## 💾 Customizing Database Backups (Dump/Restore) + +By default, the DB Dump and Restore actions in `kamal-tui` are configured for PostgreSQL (`pg_dump` and `pg_restore`). They execute inside the Kamal app container using your `$DATABASE_URL`. + +If you use a different database (e.g. MySQL, SQLite) or want to run an accessory command, you can customize this by editing `kamal.go`: + +```go +// In kamal.go +args = append(args, "--", "/bin/sh", "-c", "pg_dump $DATABASE_URL -F c > /tmp/db.dump") +``` + +## Requirements +- `kamal` command available in your PATH (or `bundle exec kamal` in a Ruby project). + +## Contributing +Bug reports and pull requests are welcome on GitHub! diff --git a/kamal.go b/kamal.go index 05ad77d..071ee15 100644 --- a/kamal.go +++ b/kamal.go @@ -26,21 +26,39 @@ func (a actionItem) FilterValue() string { return a.title } func actions() []actionItem { return []actionItem{ { - title: "Deploy", + title: "🚀 Deploy", desc: "kamal deploy -d ", buildArgs: func(dest, _ string) []string { return withDest([]string{"deploy"}, dest) }, }, { +<<<<<<< Updated upstream title: "Redeploy", +======= + title: "⚙️ Setup", + desc: "kamal setup -d (provision servers & deploy)", + buildArgs: func(dest, _ string) []string { + return withDest([]string{"setup"}, dest) + }, + }, + { + title: "🔑 Env Push", + desc: "kamal env push -d (push .env variables to servers)", + buildArgs: func(dest, _ string) []string { + return withDest([]string{"env", "push"}, dest) + }, + }, + { + title: "♻️ Redeploy", +>>>>>>> Stashed changes desc: "kamal redeploy -d (skip build cache invalidation steps)", buildArgs: func(dest, _ string) []string { return withDest([]string{"redeploy"}, dest) }, }, { - title: "Rollback", + title: "⏪ Rollback", desc: "kamal rollback -d ", needsVersion: true, buildArgs: func(dest, version string) []string { @@ -49,33 +67,63 @@ func actions() []actionItem { }, }, { - title: "App Details", + title: "💾 DB Dump (Backup)", + desc: "kamal app exec -i -- /bin/sh -c 'pg_dump ...'", + buildArgs: func(dest, _ string) []string { + args := []string{"app", "exec", "-i"} + args = withDest(args, dest) + args = append(args, "--", "/bin/sh", "-c", "pg_dump $DATABASE_URL -F c > /tmp/db.dump") + return args + }, + }, + { + title: "💿 DB Restore", + desc: "kamal app exec -i -- /bin/sh -c 'pg_restore ...'", + buildArgs: func(dest, _ string) []string { + args := []string{"app", "exec", "-i"} + args = withDest(args, dest) + args = append(args, "--", "/bin/sh", "-c", "pg_restore -d $DATABASE_URL --clean --no-owner /tmp/db.dump") + return args + }, + }, + { + title: "ℹ️ App Details", desc: "kamal app details -d ", buildArgs: func(dest, _ string) []string { return withDest([]string{"app", "details"}, dest) }, }, { - title: "App Logs", + title: "📝 App Logs", desc: "kamal app logs -d (last lines, no follow)", buildArgs: func(dest, _ string) []string { return withDest([]string{"app", "logs"}, dest) }, }, { - title: "App Boot", + title: "⚡ App Boot", desc: "kamal app boot -d ", buildArgs: func(dest, _ string) []string { return withDest([]string{"app", "boot"}, dest) }, }, { - title: "Audit", + title: "🕒 Audit", desc: "kamal audit -d (recent deploy history)", buildArgs: func(dest, _ string) []string { return withDest([]string{"audit"}, dest) }, }, +<<<<<<< Updated upstream +======= + { + title: "🗑️ Remove", + desc: "kamal remove -d (remove containers and images from servers)", + buildArgs: func(dest, _ string) []string { + return withDest([]string{"remove"}, dest) + }, + }, +>>>>>>> Stashed changes } } diff --git a/main.go b/main.go index 08a68c2..0169f21 100644 --- a/main.go +++ b/main.go @@ -41,6 +41,17 @@ func destSuffix(d destItem) string { return "." + string(d) } +<<<<<<< Updated upstream +======= +type secretItem struct { + key string +} + +func (s secretItem) Title() string { return s.key } +func (s secretItem) Description() string { return "********" } +func (s secretItem) FilterValue() string { return s.key } + +>>>>>>> Stashed changes type logLineMsg string type logStreamClosedMsg struct{} type cmdDoneMsg struct{ err error } @@ -71,6 +82,24 @@ type model struct { showVersionInput bool versionAction actionItem +<<<<<<< Updated upstream +======= + + // Secrets Manager State + showSecrets bool + addingSecret bool + stepSecretKey bool // true = key input, false = value input + secList list.Model + secKeyIn textinput.Model + secValIn textinput.Model + + // Confirmation State + showConfirm bool + confirmCmd []string + confirmAct actionItem + confirmDest string + confirmVer string +>>>>>>> Stashed changes } func initialModel() model { @@ -146,8 +175,12 @@ func (m *model) layout() { if bodyH < 3 { bodyH = 3 } +<<<<<<< Updated upstream // Left column width +======= + +>>>>>>> Stashed changes leftW := 30 if m.width < 80 { leftW = m.width / 3 @@ -159,16 +192,16 @@ func (m *model) layout() { m.destList.SetSize(leftW-4, destH-2) m.actionList.SetSize(leftW-4, actionH-2) - + m.viewport.Width = rightW - 4 m.viewport.Height = bodyH - 2 } -func (m model) handleShortcutAction(actionTitle string) (tea.Model, tea.Cmd) { +func (m model) handleShortcutAction(titleSubstr string) (tea.Model, tea.Cmd) { var action actionItem found := false for _, a := range actions() { - if a.title == actionTitle { + if strings.Contains(a.title, titleSubstr) { action = a found = true break @@ -189,7 +222,18 @@ func (m model) handleShortcutAction(actionTitle string) (tea.Model, tea.Cmd) { m.verInput.Focus() return m, textinput.Blink } - return m.startRun(action, dest, "") + + return m.promptConfirm(action, dest, "") +} + +func (m model) promptConfirm(action actionItem, dest, version string) (tea.Model, tea.Cmd) { + m.showConfirm = true + m.confirmAct = action + m.confirmDest = dest + m.confirmVer = version + args := action.buildArgs(dest, version) + m.confirmCmd = append([]string{"kamal"}, args...) + return m, nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -202,6 +246,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.MouseMsg: +<<<<<<< Updated upstream +======= + if m.showSecrets || m.addingSecret || m.showVersionInput || m.showConfirm { + return m, nil + } +>>>>>>> Stashed changes leftW := 30 if m.width < 80 { leftW = m.width / 3 @@ -234,13 +284,34 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, tea.Quit case "q": +<<<<<<< Updated upstream if !m.showVersionInput && !m.running { +======= + if !m.showVersionInput && !m.running && !m.showSecrets && !m.addingSecret && !m.showConfirm { +>>>>>>> Stashed changes if m.cancel != nil { m.cancel() } return m, tea.Quit } case "esc": +<<<<<<< Updated upstream +======= + if m.showConfirm { + m.showConfirm = false + return m, nil + } + if m.addingSecret { + m.addingSecret = false + m.secKeyIn.Blur() + m.secValIn.Blur() + return m, nil + } + if m.showSecrets { + m.showSecrets = false + return m, nil + } +>>>>>>> Stashed changes if m.showVersionInput { m.showVersionInput = false m.verInput.Blur() @@ -250,30 +321,110 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { break // use ctrl+c to abort } case "tab": +<<<<<<< Updated upstream if !m.showVersionInput { +======= + if !m.showVersionInput && !m.showSecrets && !m.addingSecret && !m.showConfirm { +>>>>>>> Stashed changes m.activePanel = (m.activePanel + 1) % 3 return m, nil } case "shift+tab": +<<<<<<< Updated upstream if !m.showVersionInput { +======= + if !m.showVersionInput && !m.showSecrets && !m.addingSecret && !m.showConfirm { +>>>>>>> Stashed changes m.activePanel = (m.activePanel - 1 + 3) % 3 return m, nil } } +<<<<<<< Updated upstream +======= + if m.showConfirm { + switch msg.String() { + case "y", "Y", "enter": + m.showConfirm = false + return m.startRun(m.confirmAct, m.confirmDest, m.confirmVer) + case "n", "N", "q": + m.showConfirm = false + return m, nil + } + return m, nil + } + + if m.addingSecret { + switch msg.String() { + case "enter": + if m.stepSecretKey { + key := strings.TrimSpace(m.secKeyIn.Value()) + if key != "" { + m.stepSecretKey = false + m.secKeyIn.Blur() + m.secValIn.Focus() + return m, textinput.Blink + } + } else { + val := strings.TrimSpace(m.secValIn.Value()) + if val != "" { + addSecret(strings.TrimSpace(m.secKeyIn.Value()), val) + m.addingSecret = false + m.secKeyIn.Blur() + m.secValIn.Blur() + m.refreshSecrets() + return m, nil + } + } + default: + var cmd tea.Cmd + if m.stepSecretKey { + m.secKeyIn, cmd = m.secKeyIn.Update(msg) + } else { + m.secValIn, cmd = m.secValIn.Update(msg) + } + cmds = append(cmds, cmd) + } + return m, tea.Batch(cmds...) + } + + if m.showSecrets { + switch msg.String() { + case "a": + m.addingSecret = true + m.stepSecretKey = true + m.secKeyIn.SetValue("") + m.secValIn.SetValue("") + m.secKeyIn.Focus() + return m, textinput.Blink + case "x", "delete": + if it, ok := m.secList.SelectedItem().(secretItem); ok { + removeSecret(it.key) + m.refreshSecrets() + } + return m, nil + default: + var cmd tea.Cmd + m.secList, cmd = m.secList.Update(msg) + cmds = append(cmds, cmd) + } + return m, tea.Batch(cmds...) + } + +>>>>>>> Stashed changes if m.showVersionInput { switch msg.String() { case "enter": if strings.TrimSpace(m.verInput.Value()) != "" { ver := strings.TrimSpace(m.verInput.Value()) m.showVersionInput = false - + dest := "" if it, ok := m.destList.SelectedItem().(destItem); ok { dest = string(it) } - - return m.startRun(m.versionAction, dest, ver) + + return m.promptConfirm(m.versionAction, dest, ver) } default: var cmd tea.Cmd @@ -295,7 +446,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // Panel specific updates +<<<<<<< Updated upstream if !m.showVersionInput { +======= + if !m.showVersionInput && !m.showSecrets && !m.showConfirm { +>>>>>>> Stashed changes switch m.activePanel { case panelDestinations: var cmd tea.Cmd @@ -381,6 +536,43 @@ func (m model) View() string { return "loading…" } +<<<<<<< Updated upstream +======= + // Render overlay if needed + if m.showConfirm { + cmdStr := strings.Join(m.confirmCmd, " ") + content := lipgloss.JoinVertical(lipgloss.Left, + titleStyle.Render("Are you sure?"), + "", + lipgloss.NewStyle().Foreground(colorWarning).Render("This will run:"), + lipgloss.NewStyle().Bold(true).Render("$ "+cmdStr), + "", + helpStyle.Render("Press 'y' to confirm, 'n' or 'esc' to cancel"), + ) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, activePanelStyle.Width(m.width-10).Render(content)) + } + if m.addingSecret { + content := lipgloss.JoinVertical(lipgloss.Left, + titleStyle.Render("Add New Secure Secret"), + "", + m.secKeyIn.View(), + "", + m.secValIn.View(), + "", + helpStyle.Render("enter: next/save · esc: cancel"), + ) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, activePanelStyle.Width(50).Render(content)) + } + if m.showSecrets { + content := lipgloss.JoinVertical(lipgloss.Left, + m.secList.View(), + "", + helpStyle.Render("a: add secret · x/delete: remove · esc: back"), + ) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, activePanelStyle.Width(m.width-6).Height(m.height-2).Render(content)) + } + +>>>>>>> Stashed changes leftW := 30 if m.width < 80 { leftW = m.width / 3 @@ -414,7 +606,7 @@ func (m model) View() string { if m.activePanel == panelLogs { style = activePanelStyle } - + logContent := m.viewport.View() if m.showVersionInput { overlay := lipgloss.JoinVertical(lipgloss.Left, @@ -442,18 +634,24 @@ func destLabel(d string) string { func (m model) footerView() string { var left string - + actionHint := "" if m.running { actionHint = m.spinner.View() + " running... " } - + if m.statusLine != "" { actionHint += m.statusLine + " · " } +<<<<<<< Updated upstream left = actionHint + "d:deploy r:rollback l:logs tab:switch panel q:quit" +======= + + left = actionHint + "d:deploy r:rollback l:logs s:secrets tab:switch panel q:quit" + +>>>>>>> Stashed changes return statusBarStyle.Width(m.width).Render(left) } diff --git a/styles.go b/styles.go index 8db0ebf..bf36722 100644 --- a/styles.go +++ b/styles.go @@ -3,17 +3,20 @@ package main import "github.com/charmbracelet/lipgloss" var ( - colorAccent = lipgloss.Color("205") - colorMuted = lipgloss.Color("241") - colorGood = lipgloss.Color("42") - colorBad = lipgloss.Color("197") - colorBorder = lipgloss.Color("62") - colorActive = lipgloss.Color("212") - colorHeading = lipgloss.Color("212") + // Tokyo Night-ish / Modern theme + colorBg = lipgloss.Color("#1a1b26") + colorFg = lipgloss.Color("#c0caf5") + colorAccent = lipgloss.Color("#7aa2f7") // Blue + colorActive = lipgloss.Color("#bb9af7") // Purple + colorBorder = lipgloss.Color("#414868") // Dark Gray + colorMuted = lipgloss.Color("#565f89") + colorGood = lipgloss.Color("#9ece6a") // Green + colorBad = lipgloss.Color("#f7768e") // Red + colorWarning = lipgloss.Color("#e0af68") // Yellow titleStyle = lipgloss.NewStyle(). Bold(true). - Foreground(colorHeading). + Foreground(colorActive). Padding(0, 1) subtitleStyle = lipgloss.NewStyle(). @@ -21,8 +24,8 @@ var ( Padding(0, 1) statusBarStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("230")). - Background(lipgloss.Color("62")). + Foreground(colorFg). + Background(lipgloss.Color("#24283b")). // slightly lighter bg Padding(0, 1) inactivePanelStyle = lipgloss.NewStyle(). @@ -31,7 +34,7 @@ var ( Padding(0, 1) activePanelStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). + Border(lipgloss.ThickBorder()). // Thick border for active panel BorderForeground(colorActive). Padding(0, 1) From 3b387970dd06e058855fbb07c62891096e72bbbb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:29:44 +0000 Subject: [PATCH 2/4] fix: resolve merge conflict markers in main and action definitions --- kamal.go | 7 --- main.go | 163 ++----------------------------------------------------- 2 files changed, 5 insertions(+), 165 deletions(-) diff --git a/kamal.go b/kamal.go index 071ee15..674b8ae 100644 --- a/kamal.go +++ b/kamal.go @@ -33,9 +33,6 @@ func actions() []actionItem { }, }, { -<<<<<<< Updated upstream - title: "Redeploy", -======= title: "⚙️ Setup", desc: "kamal setup -d (provision servers & deploy)", buildArgs: func(dest, _ string) []string { @@ -51,7 +48,6 @@ func actions() []actionItem { }, { title: "♻️ Redeploy", ->>>>>>> Stashed changes desc: "kamal redeploy -d (skip build cache invalidation steps)", buildArgs: func(dest, _ string) []string { return withDest([]string{"redeploy"}, dest) @@ -114,8 +110,6 @@ func actions() []actionItem { return withDest([]string{"audit"}, dest) }, }, -<<<<<<< Updated upstream -======= { title: "🗑️ Remove", desc: "kamal remove -d (remove containers and images from servers)", @@ -123,7 +117,6 @@ func actions() []actionItem { return withDest([]string{"remove"}, dest) }, }, ->>>>>>> Stashed changes } } diff --git a/main.go b/main.go index 0169f21..1c2d176 100644 --- a/main.go +++ b/main.go @@ -41,17 +41,6 @@ func destSuffix(d destItem) string { return "." + string(d) } -<<<<<<< Updated upstream -======= -type secretItem struct { - key string -} - -func (s secretItem) Title() string { return s.key } -func (s secretItem) Description() string { return "********" } -func (s secretItem) FilterValue() string { return s.key } - ->>>>>>> Stashed changes type logLineMsg string type logStreamClosedMsg struct{} type cmdDoneMsg struct{ err error } @@ -82,16 +71,6 @@ type model struct { showVersionInput bool versionAction actionItem -<<<<<<< Updated upstream -======= - - // Secrets Manager State - showSecrets bool - addingSecret bool - stepSecretKey bool // true = key input, false = value input - secList list.Model - secKeyIn textinput.Model - secValIn textinput.Model // Confirmation State showConfirm bool @@ -99,7 +78,6 @@ type model struct { confirmAct actionItem confirmDest string confirmVer string ->>>>>>> Stashed changes } func initialModel() model { @@ -175,12 +153,6 @@ func (m *model) layout() { if bodyH < 3 { bodyH = 3 } -<<<<<<< Updated upstream - - // Left column width -======= - ->>>>>>> Stashed changes leftW := 30 if m.width < 80 { leftW = m.width / 3 @@ -246,12 +218,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.MouseMsg: -<<<<<<< Updated upstream -======= - if m.showSecrets || m.addingSecret || m.showVersionInput || m.showConfirm { + if m.showVersionInput || m.showConfirm { return m, nil } ->>>>>>> Stashed changes leftW := 30 if m.width < 80 { leftW = m.width / 3 @@ -284,34 +253,17 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, tea.Quit case "q": -<<<<<<< Updated upstream - if !m.showVersionInput && !m.running { -======= - if !m.showVersionInput && !m.running && !m.showSecrets && !m.addingSecret && !m.showConfirm { ->>>>>>> Stashed changes + if !m.showVersionInput && !m.running && !m.showConfirm { if m.cancel != nil { m.cancel() } return m, tea.Quit } case "esc": -<<<<<<< Updated upstream -======= if m.showConfirm { m.showConfirm = false return m, nil } - if m.addingSecret { - m.addingSecret = false - m.secKeyIn.Blur() - m.secValIn.Blur() - return m, nil - } - if m.showSecrets { - m.showSecrets = false - return m, nil - } ->>>>>>> Stashed changes if m.showVersionInput { m.showVersionInput = false m.verInput.Blur() @@ -321,27 +273,17 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { break // use ctrl+c to abort } case "tab": -<<<<<<< Updated upstream - if !m.showVersionInput { -======= - if !m.showVersionInput && !m.showSecrets && !m.addingSecret && !m.showConfirm { ->>>>>>> Stashed changes + if !m.showVersionInput && !m.showConfirm { m.activePanel = (m.activePanel + 1) % 3 return m, nil } case "shift+tab": -<<<<<<< Updated upstream - if !m.showVersionInput { -======= - if !m.showVersionInput && !m.showSecrets && !m.addingSecret && !m.showConfirm { ->>>>>>> Stashed changes + if !m.showVersionInput && !m.showConfirm { m.activePanel = (m.activePanel - 1 + 3) % 3 return m, nil } } -<<<<<<< Updated upstream -======= if m.showConfirm { switch msg.String() { case "y", "Y", "enter": @@ -353,65 +295,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil } - - if m.addingSecret { - switch msg.String() { - case "enter": - if m.stepSecretKey { - key := strings.TrimSpace(m.secKeyIn.Value()) - if key != "" { - m.stepSecretKey = false - m.secKeyIn.Blur() - m.secValIn.Focus() - return m, textinput.Blink - } - } else { - val := strings.TrimSpace(m.secValIn.Value()) - if val != "" { - addSecret(strings.TrimSpace(m.secKeyIn.Value()), val) - m.addingSecret = false - m.secKeyIn.Blur() - m.secValIn.Blur() - m.refreshSecrets() - return m, nil - } - } - default: - var cmd tea.Cmd - if m.stepSecretKey { - m.secKeyIn, cmd = m.secKeyIn.Update(msg) - } else { - m.secValIn, cmd = m.secValIn.Update(msg) - } - cmds = append(cmds, cmd) - } - return m, tea.Batch(cmds...) - } - - if m.showSecrets { - switch msg.String() { - case "a": - m.addingSecret = true - m.stepSecretKey = true - m.secKeyIn.SetValue("") - m.secValIn.SetValue("") - m.secKeyIn.Focus() - return m, textinput.Blink - case "x", "delete": - if it, ok := m.secList.SelectedItem().(secretItem); ok { - removeSecret(it.key) - m.refreshSecrets() - } - return m, nil - default: - var cmd tea.Cmd - m.secList, cmd = m.secList.Update(msg) - cmds = append(cmds, cmd) - } - return m, tea.Batch(cmds...) - } - ->>>>>>> Stashed changes if m.showVersionInput { switch msg.String() { case "enter": @@ -446,11 +329,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // Panel specific updates -<<<<<<< Updated upstream - if !m.showVersionInput { -======= - if !m.showVersionInput && !m.showSecrets && !m.showConfirm { ->>>>>>> Stashed changes + if !m.showVersionInput && !m.showConfirm { switch m.activePanel { case panelDestinations: var cmd tea.Cmd @@ -536,8 +415,6 @@ func (m model) View() string { return "loading…" } -<<<<<<< Updated upstream -======= // Render overlay if needed if m.showConfirm { cmdStr := strings.Join(m.confirmCmd, " ") @@ -551,28 +428,6 @@ func (m model) View() string { ) return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, activePanelStyle.Width(m.width-10).Render(content)) } - if m.addingSecret { - content := lipgloss.JoinVertical(lipgloss.Left, - titleStyle.Render("Add New Secure Secret"), - "", - m.secKeyIn.View(), - "", - m.secValIn.View(), - "", - helpStyle.Render("enter: next/save · esc: cancel"), - ) - return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, activePanelStyle.Width(50).Render(content)) - } - if m.showSecrets { - content := lipgloss.JoinVertical(lipgloss.Left, - m.secList.View(), - "", - helpStyle.Render("a: add secret · x/delete: remove · esc: back"), - ) - return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, activePanelStyle.Width(m.width-6).Height(m.height-2).Render(content)) - } - ->>>>>>> Stashed changes leftW := 30 if m.width < 80 { leftW = m.width / 3 @@ -643,15 +498,7 @@ func (m model) footerView() string { if m.statusLine != "" { actionHint += m.statusLine + " · " } -<<<<<<< Updated upstream - left = actionHint + "d:deploy r:rollback l:logs tab:switch panel q:quit" - -======= - - left = actionHint + "d:deploy r:rollback l:logs s:secrets tab:switch panel q:quit" - ->>>>>>> Stashed changes return statusBarStyle.Width(m.width).Render(left) } From d2d046ff494b8b80d94ed251472503cfd66216b2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:37:07 +0000 Subject: [PATCH 3/4] fix: address merge follow-up review findings --- go.mod | 4 ++-- go.sum | 12 ++++++++++-- kamal.go | 2 +- main.go | 2 +- secrets.go | 5 ++++- 5 files changed, 18 insertions(+), 7 deletions(-) diff --git a/go.mod b/go.mod index 808104f..d46f39d 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,8 @@ require ( github.com/charmbracelet/bubbles v0.20.0 github.com/charmbracelet/bubbletea v1.1.2 github.com/charmbracelet/lipgloss v1.0.0 + github.com/joho/godotenv v1.5.1 + github.com/zalando/go-keyring v0.2.8 ) require ( @@ -16,7 +18,6 @@ require ( github.com/danieljoos/wincred v1.2.3 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect - github.com/joho/godotenv v1.5.1 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect @@ -26,7 +27,6 @@ require ( github.com/muesli/termenv v0.15.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect - github.com/zalando/go-keyring v0.2.8 // indirect golang.org/x/sync v0.8.0 // indirect golang.org/x/sys v0.27.0 // indirect golang.org/x/text v0.3.8 // indirect diff --git a/go.sum b/go.sum index 0b2815c..5ac1da8 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4h github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= @@ -36,20 +38,26 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= -golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/kamal.go b/kamal.go index e0e3284..221055a 100644 --- a/kamal.go +++ b/kamal.go @@ -192,7 +192,7 @@ func runKamal(ctx context.Context, dest string, prefixArgs []string, args []stri full := append(append([]string{}, prefixArgs...), args...) bin, extra, ok := kamalBinaryAvailable() if !ok { - doneCh <- fmt.Errorf("kamal binary not found") + doneCh <- errBinaryNotFound close(lineCh) return } diff --git a/main.go b/main.go index 170cfb5..c2b5da7 100644 --- a/main.go +++ b/main.go @@ -154,7 +154,7 @@ func initialModel() model { verInput: ti, viewport: vp, spinner: sp, - outputBuf: []string{"Welcome to kamal-tui! Select a destination and action.", "Press 's' to manage secure Azure-style secrets."}, + outputBuf: []string{"Welcome to kamal-tui! Select a destination and action.", "Press 's' to manage secure secrets."}, secList: secList, secKeyIn: secKeyIn, secValIn: secValIn, diff --git a/secrets.go b/secrets.go index cc7d027..f47be3b 100644 --- a/secrets.go +++ b/secrets.go @@ -12,7 +12,10 @@ import ( const keyringService = "kamal-tui-secrets" func getProjectID() string { - cwd, _ := os.Getwd() + cwd, err := os.Getwd() + if err != nil { + return "kamal-tui" + } return filepath.Base(cwd) + "-" + cwd } From 48b83ba3a8ad5e726fe481f5199a4156d2608fb1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:38:09 +0000 Subject: [PATCH 4/4] fix: handle secret manager errors and tighten project id --- kamal.go | 2 +- main.go | 16 +++++++++++----- secrets.go | 5 ++++- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/kamal.go b/kamal.go index 221055a..5f73def 100644 --- a/kamal.go +++ b/kamal.go @@ -252,7 +252,7 @@ func loadEnvForDest(dest string) []string { } } - // Load securely stored secrets from keychain + // Load secrets from keychain last so they intentionally override file values. keychainSecrets := loadSecrets() for k, v := range keychainSecrets { envMap[k] = v diff --git a/main.go b/main.go index c2b5da7..765df3e 100644 --- a/main.go +++ b/main.go @@ -131,7 +131,7 @@ func initialModel() model { vp := viewport.New(0, 0) secList := list.New([]list.Item{}, list.NewDefaultDelegate(), 0, 0) - secList.Title = "Secure Secrets Manager" + secList.Title = "Secrets Manager" secList.SetShowStatusBar(false) secList.SetFilteringEnabled(false) secList.SetShowHelp(false) @@ -154,7 +154,7 @@ func initialModel() model { verInput: ti, viewport: vp, spinner: sp, - outputBuf: []string{"Welcome to kamal-tui! Select a destination and action.", "Press 's' to manage secure secrets."}, + outputBuf: []string{"Welcome to kamal-tui! Select a destination and action.", "Press 's' to manage secrets."}, secList: secList, secKeyIn: secKeyIn, secValIn: secValIn, @@ -366,7 +366,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { val := strings.TrimSpace(m.secValIn.Value()) if val != "" { - addSecret(strings.TrimSpace(m.secKeyIn.Value()), val) + if err := addSecret(strings.TrimSpace(m.secKeyIn.Value()), val); err != nil { + m.statusLine = badStyle.Render("failed to save secret: " + err.Error()) + return m, nil + } m.addingSecret = false m.secKeyIn.Blur() m.secValIn.Blur() @@ -397,7 +400,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, textinput.Blink case "x", "d", "delete": if it, ok := m.secList.SelectedItem().(secretItem); ok { - removeSecret(it.key) + if err := removeSecret(it.key); err != nil { + m.statusLine = badStyle.Render("failed to delete secret: " + err.Error()) + return m, nil + } m.refreshSecrets() } return m, nil @@ -535,7 +541,7 @@ func (m model) View() string { // Render overlay if needed if m.addingSecret { content := lipgloss.JoinVertical(lipgloss.Left, - titleStyle.Render("Add New Secure Secret"), + titleStyle.Render("Add New Secret"), "", m.secKeyIn.View(), "", diff --git a/secrets.go b/secrets.go index f47be3b..c006d66 100644 --- a/secrets.go +++ b/secrets.go @@ -1,6 +1,8 @@ package main import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "os" "path/filepath" @@ -16,7 +18,8 @@ func getProjectID() string { if err != nil { return "kamal-tui" } - return filepath.Base(cwd) + "-" + cwd + sum := sha256.Sum256([]byte(cwd)) + return filepath.Base(cwd) + "-" + hex.EncodeToString(sum[:8]) } func loadSecrets() map[string]string {