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/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 0a930df..5f73def 100644 --- a/kamal.go +++ b/kamal.go @@ -29,35 +29,35 @@ 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) }, }, { - title: "Setup", + title: "โš™๏ธ Setup", desc: "kamal setup -d (provision servers & deploy)", buildArgs: func(dest, _ string) []string { return withDest([]string{"setup"}, dest) }, }, { - title: "Env Push", + 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", + title: "โ™ป๏ธ Redeploy", 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 { @@ -66,35 +66,55 @@ 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) }, }, { - title: "Remove", + title: "๐Ÿ—‘๏ธ Remove", desc: "kamal remove -d (remove containers and images from servers)", buildArgs: func(dest, _ string) []string { return withDest([]string{"remove"}, dest) @@ -172,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 } @@ -232,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 @@ -255,4 +275,3 @@ func loadEnvForDest(dest string) []string { } return cmdEnv } - diff --git a/main.go b/main.go index 86b1589..765df3e 100644 --- a/main.go +++ b/main.go @@ -43,11 +43,11 @@ func destSuffix(d destItem) string { 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 } - type logLineMsg string type logStreamClosedMsg struct{} type cmdDoneMsg struct{ err error } @@ -86,6 +86,13 @@ type model struct { secList list.Model secKeyIn textinput.Model secValIn textinput.Model + + // Confirmation State + showConfirm bool + confirmCmd []string + confirmAct actionItem + confirmDest string + confirmVer string } func initialModel() model { @@ -124,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) @@ -147,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 secrets."}, secList: secList, secKeyIn: secKeyIn, secValIn: secValIn, @@ -181,7 +188,6 @@ func (m *model) layout() { if bodyH < 3 { bodyH = 3 } - leftW := 30 if m.width < 80 { leftW = m.width / 3 @@ -193,7 +199,7 @@ 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 @@ -209,11 +215,11 @@ func (m *model) refreshSecrets() { m.secList.SetItems(items) } -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 @@ -234,7 +240,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) { @@ -247,7 +264,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.MouseMsg: - if m.showSecrets || m.addingSecret || m.showVersionInput { + if m.showSecrets || m.addingSecret || m.showVersionInput || m.showConfirm { return m, nil } leftW := 30 @@ -282,13 +299,17 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, tea.Quit case "q": - if !m.showVersionInput && !m.running && !m.showSecrets && !m.addingSecret { + if !m.showVersionInput && !m.running && !m.showSecrets && !m.addingSecret && !m.showConfirm { if m.cancel != nil { m.cancel() } return m, tea.Quit } case "esc": + if m.showConfirm { + m.showConfirm = false + return m, nil + } if m.addingSecret { m.addingSecret = false m.secKeyIn.Blur() @@ -308,17 +329,29 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { break // use ctrl+c to abort } case "tab": - if !m.showVersionInput && !m.showSecrets && !m.addingSecret { + if !m.showVersionInput && !m.showSecrets && !m.addingSecret && !m.showConfirm { m.activePanel = (m.activePanel + 1) % 3 return m, nil } case "shift+tab": - if !m.showVersionInput && !m.showSecrets && !m.addingSecret { + if !m.showVersionInput && !m.showSecrets && !m.addingSecret && !m.showConfirm { m.activePanel = (m.activePanel - 1 + 3) % 3 return m, nil } } + 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": @@ -333,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() @@ -364,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 @@ -375,20 +414,19 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, tea.Batch(cmds...) } - 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 @@ -414,7 +452,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // Panel specific updates - if !m.showVersionInput && !m.showSecrets { + if !m.showVersionInput && !m.showSecrets && !m.addingSecret && !m.showConfirm { switch m.activePanel { case panelDestinations: var cmd tea.Cmd @@ -503,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(), "", @@ -521,7 +559,18 @@ func (m model) View() string { ) return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, activePanelStyle.Width(m.width-6).Height(m.height-2).Render(content)) } - + 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)) + } leftW := 30 if m.width < 80 { @@ -556,7 +605,7 @@ func (m model) View() string { if m.activePanel == panelLogs { style = activePanelStyle } - + logContent := m.viewport.View() if m.showVersionInput { overlay := lipgloss.JoinVertical(lipgloss.Left, @@ -584,18 +633,16 @@ 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 + " ยท " } - left = actionHint + "d:deploy r:rollback l:logs s:secrets tab:switch panel q:quit" - return statusBarStyle.Width(m.width).Render(left) } diff --git a/secrets.go b/secrets.go index cc7d027..c006d66 100644 --- a/secrets.go +++ b/secrets.go @@ -1,6 +1,8 @@ package main import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "os" "path/filepath" @@ -12,8 +14,12 @@ import ( const keyringService = "kamal-tui-secrets" func getProjectID() string { - cwd, _ := os.Getwd() - return filepath.Base(cwd) + "-" + cwd + cwd, err := os.Getwd() + if err != nil { + return "kamal-tui" + } + sum := sha256.Sum256([]byte(cwd)) + return filepath.Base(cwd) + "-" + hex.EncodeToString(sum[:8]) } func loadSecrets() map[string]string { 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)