Prepare public release docs
This commit is contained in:
parent
c2b0e57f3a
commit
d66e103c73
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 mirivlad
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
27
README.md
27
README.md
|
|
@ -19,7 +19,7 @@ starts the system `ssh` client with the right options.
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.mirv.top/mirivlad/sshkeeper.git
|
git clone https://github.com/mirivlad/sshkeeper.git
|
||||||
cd sshkeeper
|
cd sshkeeper
|
||||||
go build -o ~/.local/bin/sshkeeper .
|
go build -o ~/.local/bin/sshkeeper .
|
||||||
```
|
```
|
||||||
|
|
@ -89,6 +89,22 @@ storing the secret.
|
||||||
|
|
||||||
Running `sshkeeper` without arguments opens the TUI.
|
Running `sshkeeper` without arguments opens the TUI.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
### Main Window
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Edit Server
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Template Manager
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Enter | Connect to selected server |
|
| Enter | Connect to selected server |
|
||||||
|
|
@ -142,6 +158,13 @@ sshkeeper vault change-password
|
||||||
password themselves because they need to decrypt the vault in the current
|
password themselves because they need to decrypt the vault in the current
|
||||||
process.
|
process.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
`sshkeeper` stores SSH passwords and key passphrases in an encrypted local vault
|
||||||
|
and avoids passing secrets through command-line arguments. The project has not
|
||||||
|
had an independent security audit; review the implementation and threat model
|
||||||
|
before using it for high-risk environments.
|
||||||
|
|
||||||
## Data Locations
|
## Data Locations
|
||||||
|
|
||||||
`sshkeeper` uses XDG-style app directories:
|
`sshkeeper` uses XDG-style app directories:
|
||||||
|
|
@ -181,4 +204,4 @@ sshkeeper/
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
MIT. See [LICENSE](LICENSE).
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 201 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 170 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 158 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 145 KiB |
|
|
@ -1,295 +0,0 @@
|
||||||
# Server List Scalability Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Keep the server list usable when the saved server count is larger than the terminal height.
|
|
||||||
|
|
||||||
**Architecture:** The list screen should render a bounded table viewport instead of rendering every server row. Selection remains owned by the existing `bubbles/list.Model`, while `viewServerList` derives a visible row range around the selected item and keeps the selected-server detail panel and footer visible.
|
|
||||||
|
|
||||||
**Tech Stack:** Go, Bubble Tea, Bubbles list, Lip Gloss, existing TUI tests in `internal/tui/app_test.go`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: Add Regression Coverage For Long Server Lists
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `internal/tui/app_test.go`
|
|
||||||
|
|
||||||
- [x] **Step 1: Add a test for a constrained terminal height**
|
|
||||||
|
|
||||||
Add a test that creates more servers than can fit on screen, sets a small terminal size, renders the list, and verifies the selected details and footer are still visible.
|
|
||||||
|
|
||||||
```go
|
|
||||||
func TestServerListViewKeepsDetailsVisibleWithManyServers(t *testing.T) {
|
|
||||||
servers := make([]*model.Server, 45)
|
|
||||||
for i := range servers {
|
|
||||||
servers[i] = &model.Server{
|
|
||||||
Alias: fmt.Sprintf("server-%02d", i+1),
|
|
||||||
DisplayName: fmt.Sprintf("Server %02d", i+1),
|
|
||||||
Host: fmt.Sprintf("host-%02d.example.org", i+1),
|
|
||||||
Port: 22,
|
|
||||||
User: "mirivlad",
|
|
||||||
AuthMethod: model.AuthKey,
|
|
||||||
LastTestStatus: model.TestUnknown,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
m := New(servers)
|
|
||||||
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 18})
|
|
||||||
model := updated.(*tuiModel)
|
|
||||||
|
|
||||||
view := model.View()
|
|
||||||
if !strings.Contains(view, "Server 01") {
|
|
||||||
t.Fatalf("expected first selected server to be visible:\n%s", view)
|
|
||||||
}
|
|
||||||
if !strings.Contains(view, "Selected") {
|
|
||||||
t.Fatalf("expected selected server details to remain visible:\n%s", view)
|
|
||||||
}
|
|
||||||
if !strings.Contains(view, "Enter connect") {
|
|
||||||
t.Fatalf("expected footer to remain visible:\n%s", view)
|
|
||||||
}
|
|
||||||
if count := strings.Count(view, "server-"); count >= len(servers) {
|
|
||||||
t.Fatalf("expected bounded row rendering, rendered %d server aliases", count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [x] **Step 2: Run the focused test and confirm it fails**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
env GOCACHE=/tmp/sshkeeper-go-cache go test ./internal/tui -run TestServerListViewKeepsDetailsVisibleWithManyServers -count=1
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: FAIL because the current table renders every server and can push the detail panel/footer below the visible terminal area.
|
|
||||||
|
|
||||||
### Task 2: Compute A Visible Row Window
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `internal/tui/app.go`
|
|
||||||
- Modify: `internal/tui/app_test.go`
|
|
||||||
|
|
||||||
- [x] **Step 1: Add focused tests for visible range calculation**
|
|
||||||
|
|
||||||
Add tests for a helper that computes the inclusive start and exclusive end indexes for rendered rows.
|
|
||||||
|
|
||||||
```go
|
|
||||||
func TestVisibleServerRangeKeepsSelectionInsideWindow(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
total int
|
|
||||||
selected int
|
|
||||||
available int
|
|
||||||
wantStart int
|
|
||||||
wantEnd int
|
|
||||||
}{
|
|
||||||
{name: "first page", total: 40, selected: 0, available: 10, wantStart: 0, wantEnd: 10},
|
|
||||||
{name: "middle page", total: 40, selected: 20, available: 10, wantStart: 11, wantEnd: 21},
|
|
||||||
{name: "last page", total: 40, selected: 39, available: 10, wantStart: 30, wantEnd: 40},
|
|
||||||
{name: "all fit", total: 5, selected: 3, available: 10, wantStart: 0, wantEnd: 5},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
start, end := visibleServerRange(tt.total, tt.selected, tt.available)
|
|
||||||
if start != tt.wantStart || end != tt.wantEnd {
|
|
||||||
t.Fatalf("visibleServerRange() = %d, %d; want %d, %d", start, end, tt.wantStart, tt.wantEnd)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [x] **Step 2: Implement `visibleServerRange`**
|
|
||||||
|
|
||||||
Add a small helper near `selectedServer`.
|
|
||||||
|
|
||||||
```go
|
|
||||||
func visibleServerRange(total, selected, available int) (int, int) {
|
|
||||||
if total <= 0 || available <= 0 {
|
|
||||||
return 0, 0
|
|
||||||
}
|
|
||||||
if available >= total {
|
|
||||||
return 0, total
|
|
||||||
}
|
|
||||||
if selected < 0 {
|
|
||||||
selected = 0
|
|
||||||
}
|
|
||||||
if selected >= total {
|
|
||||||
selected = total - 1
|
|
||||||
}
|
|
||||||
|
|
||||||
start := selected - available + 1
|
|
||||||
if start < 0 {
|
|
||||||
start = 0
|
|
||||||
}
|
|
||||||
end := start + available
|
|
||||||
if end > total {
|
|
||||||
end = total
|
|
||||||
start = end - available
|
|
||||||
}
|
|
||||||
return start, end
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [x] **Step 3: Run helper tests**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
env GOCACHE=/tmp/sshkeeper-go-cache go test ./internal/tui -run TestVisibleServerRangeKeepsSelectionInsideWindow -count=1
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
### Task 3: Render Only Rows That Fit
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `internal/tui/app.go`
|
|
||||||
- Modify: `internal/tui/app_test.go`
|
|
||||||
|
|
||||||
- [x] **Step 1: Reserve terminal space for fixed UI blocks**
|
|
||||||
|
|
||||||
Add a helper that decides how many server rows may be rendered while keeping the selected details and footer visible.
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (m *tuiModel) visibleServerRows() int {
|
|
||||||
if m.height <= 0 {
|
|
||||||
return len(m.servers)
|
|
||||||
}
|
|
||||||
|
|
||||||
const fixedRows = 16
|
|
||||||
rows := m.height - fixedRows
|
|
||||||
if rows < 3 {
|
|
||||||
return 3
|
|
||||||
}
|
|
||||||
return rows
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [x] **Step 2: Use the visible range in `viewServerList`**
|
|
||||||
|
|
||||||
In `viewServerList`, replace the loop over all servers with a bounded loop:
|
|
||||||
|
|
||||||
```go
|
|
||||||
selectedIndex := m.list.Index()
|
|
||||||
start, end := visibleServerRange(len(m.servers), selectedIndex, m.visibleServerRows())
|
|
||||||
for _, server := range m.servers[start:end] {
|
|
||||||
// existing row rendering body stays unchanged
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Then render a compact range hint when rows are hidden:
|
|
||||||
|
|
||||||
```go
|
|
||||||
if len(m.servers) > end-start {
|
|
||||||
b.WriteString(helpStyle.Render(fmt.Sprintf(" Showing %d-%d of %d", start+1, end, len(m.servers))))
|
|
||||||
b.WriteString("\n")
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [x] **Step 3: Run long-list regression test**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
env GOCACHE=/tmp/sshkeeper-go-cache go test ./internal/tui -run TestServerListViewKeepsDetailsVisibleWithManyServers -count=1
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
### Task 4: Verify Navigation Still Works
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `internal/tui/app_test.go`
|
|
||||||
|
|
||||||
- [x] **Step 1: Add a test for moving selection beyond the first window**
|
|
||||||
|
|
||||||
Use the existing `m.list.Update` path by sending `tea.KeyDown` messages and confirm the rendered window follows the selected server.
|
|
||||||
|
|
||||||
```go
|
|
||||||
func TestServerListViewScrollsWithSelection(t *testing.T) {
|
|
||||||
servers := make([]*model.Server, 45)
|
|
||||||
for i := range servers {
|
|
||||||
servers[i] = &model.Server{
|
|
||||||
Alias: fmt.Sprintf("server-%02d", i+1),
|
|
||||||
DisplayName: fmt.Sprintf("Server %02d", i+1),
|
|
||||||
Host: fmt.Sprintf("host-%02d.example.org", i+1),
|
|
||||||
Port: 22,
|
|
||||||
User: "mirivlad",
|
|
||||||
AuthMethod: model.AuthKey,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
m := New(servers)
|
|
||||||
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 18})
|
|
||||||
model := updated.(*tuiModel)
|
|
||||||
for i := 0; i < 20; i++ {
|
|
||||||
updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyDown})
|
|
||||||
model = updated.(*tuiModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
view := model.View()
|
|
||||||
if !strings.Contains(view, "Server 21") {
|
|
||||||
t.Fatalf("expected selected server to be visible after navigation:\n%s", view)
|
|
||||||
}
|
|
||||||
if !strings.Contains(view, "Showing") {
|
|
||||||
t.Fatalf("expected range hint for long server list:\n%s", view)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [x] **Step 2: Run TUI tests**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
env GOCACHE=/tmp/sshkeeper-go-cache go test ./internal/tui -count=1
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
### Task 5: Final Verification And Build
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- No source edits expected.
|
|
||||||
|
|
||||||
- [x] **Step 1: Run the full test suite**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
env GOCACHE=/tmp/sshkeeper-go-cache go test ./...
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: all packages pass.
|
|
||||||
|
|
||||||
- [x] **Step 2: Rebuild the project binary**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
env GOCACHE=/tmp/sshkeeper-go-cache go build -o bin/sshkeeper .
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: exit code 0 and updated `bin/sshkeeper`.
|
|
||||||
|
|
||||||
- [x] **Step 3: Commit the implementation**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add internal/tui/app.go internal/tui/app_test.go bin/sshkeeper
|
|
||||||
git commit -m "fix: keep server list usable with many servers"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: commit succeeds.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Self-Review
|
|
||||||
|
|
||||||
- Spec coverage: the plan covers the known failure mode for 40+ servers, keeps selected details visible, keeps the footer visible, and preserves existing `bubbles/list` navigation.
|
|
||||||
- Placeholder scan: no `TBD`, `TODO`, or open-ended implementation placeholders remain.
|
|
||||||
- Type consistency: helper names and files match the current TUI code shape.
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
# TUI Tags And Global Templates Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Add full TUI support for tags and global command templates, including multi-server template execution.
|
|
||||||
|
|
||||||
**Architecture:** Move command templates to global entities while preserving legacy server-scoped rows as importable data. Add server `startup_command`, richer tag CRUD helpers, and TUI screens for tag/template management. Template execution uses existing OpenSSH command construction, with foreground execution returning control to the terminal and background execution collecting per-server results in a TUI results screen.
|
|
||||||
|
|
||||||
**Tech Stack:** Go, Bubble Tea, Bubbles list/textinput, SQLite, existing Cobra CLI and TUI package.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: Data Model And CLI
|
|
||||||
|
|
||||||
- [x] Add `StartupCommand` to `model.Server`.
|
|
||||||
- [x] Add DB schema migration helpers for `servers.startup_command` and global `global_command_templates`.
|
|
||||||
- [x] Add DB CRUD for global templates and tag management.
|
|
||||||
- [x] Update CLI `template` commands to manage global templates.
|
|
||||||
- [x] Update `run-template` to run a global template on a server.
|
|
||||||
- [x] Add focused DB and CLI tests.
|
|
||||||
|
|
||||||
### Task 2: TUI Tags
|
|
||||||
|
|
||||||
- [x] Add tags to server form as comma-separated input.
|
|
||||||
- [x] Persist tags on add/edit.
|
|
||||||
- [x] Show tags in selected-server details.
|
|
||||||
- [x] Add a tag management screen with list, add, rename, delete, assign/remove selected servers.
|
|
||||||
- [x] Add tests for tag rendering and callbacks.
|
|
||||||
|
|
||||||
### Task 3: TUI Templates And Selection
|
|
||||||
|
|
||||||
- [x] Add multi-selection state toggled by Insert.
|
|
||||||
- [x] Show selected markers and selected count in list footer.
|
|
||||||
- [x] Add global template picker opened by Shift+Enter.
|
|
||||||
- [x] Add template manager screen with list/add/edit/delete.
|
|
||||||
- [x] Add startup command field to server form.
|
|
||||||
- [x] Add tests for selection and template screen state.
|
|
||||||
|
|
||||||
### Task 4: Template Execution
|
|
||||||
|
|
||||||
- [x] Add foreground template execution result path that exits TUI and lets caller run SSH.
|
|
||||||
- [x] Add background execution callback and results screen.
|
|
||||||
- [x] Run background template on selected servers, collecting stdout/stderr/status.
|
|
||||||
- [x] Add tests for run mode selection and result rendering.
|
|
||||||
|
|
||||||
### Task 5: Verification
|
|
||||||
|
|
||||||
- [x] Run `env GOCACHE=/tmp/sshkeeper-go-cache go test ./...`.
|
|
||||||
- [x] Run `env GOCACHE=/tmp/sshkeeper-go-cache go build -o bin/sshkeeper .`.
|
|
||||||
- [ ] Smoke-test CLI template CRUD on temporary XDG paths.
|
|
||||||
- [ ] Commit final implementation.
|
|
||||||
1352
sshkeeper_tz.md
1352
sshkeeper_tz.md
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue