feat: formatted JSON and certificates, errors in the status pane, no scope access list (#19)
**Formatted values.** A revealed value is shown as it reads best: JSON indented, and PEM certificates as subject, issuer, valid dates, names and SHA-1 thumbprint instead of base64. Any other PEM block (a private key in a bundle) is named, `EC PRIVATE KEY (not shown)`, never shown. Anything else shows as before. `y` copies the value exactly as stored, and the hints line says so (`JSON, formatted: y copies it as stored`). **Errors in the status pane.** This session's errors, newest first, up to 20, with the time and what they were about (`12:32 kv-restricted: 403 Forbidden: …`): lists that couldn't load, values and details that couldn't be read, sources, saving the cache, and whatever the footer flashed (a failed copy) before you moved on. A repeat moves to the top. Sign-ins aren't listed (the footer and the account say those). Only the first line of each, and not kept after quitting. `y` copies one. **No access list for Databricks scopes.** It was the one request of its own left (one call per scope). Removed with its tests and docs; refusals still say what to check. **Copying over SSH: checked, nothing to change.** Over SSH (`SSH_TTY`/`SSH_CONNECTION`), `y` copies with OSC 52, and the 30-second clear works the same way. Your tmux (3.7, `set-clipboard on`) passes it through and kitty (`clipboard_control write-clipboard`) accepts it; docs/config.md already says what's needed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com> Reviewed-on: #19
This commit was merged in pull request #19.
This commit is contained in:
1 parent
17054ad0ec
commit
b530c043f7
14 files changed
+271
-90
No files matched your search
+18
-4
@@ -56,7 +56,7 @@ const (
|
||||
slotDescribe // extra metadata for the selected object
|
||||
slotCopy // fetch a value straight to the clipboard
|
||||
slotVersions // count the selected object's versions
|
||||
slotDetails // a vault's, scope's or file's details
|
||||
slotDetails // a file's details
|
||||
slotCount
|
||||
)
|
||||
|
||||
@@ -177,9 +177,13 @@ type Model struct {
|
||||
fields panes.Fields
|
||||
lastLeft pane
|
||||
details map[string]detailsState // by containerKey
|
||||
getenv func(string) string
|
||||
nvimOpen func(server, path string, line int) error // edit.go
|
||||
now func() time.Time
|
||||
// errLog is this session's errors, newest first; loggedFlash the
|
||||
// footer's text when last looked at (errlog.go).
|
||||
errLog []loggedErr
|
||||
loggedFlash string
|
||||
getenv func(string) string
|
||||
nvimOpen func(server, path string, line int) error // edit.go
|
||||
now func() time.Time
|
||||
// The cache file, whether the cache changed since it was saved, and
|
||||
// when each provider's Sources list was loaded (persist.go).
|
||||
cachePath string
|
||||
@@ -623,6 +627,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.done(slotContainers)
|
||||
if msg.err != nil {
|
||||
m.noteErr(msg.err)
|
||||
if src, ok := m.selectedSource(); ok {
|
||||
m.logErrOf(src.Name, msg.err)
|
||||
}
|
||||
delete(m.cache.containers, msg.key)
|
||||
m.containers.SetError(msg.err)
|
||||
} else {
|
||||
@@ -641,6 +648,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.closeVersions()
|
||||
} else if msg.err != nil {
|
||||
m.noteErr(msg.err)
|
||||
if c, ok := m.containers.Selected(); ok {
|
||||
m.logErrOf(c.Name, msg.err)
|
||||
}
|
||||
delete(m.cache.objects, msg.key)
|
||||
m.secrets.SetError(msg.err)
|
||||
} else {
|
||||
@@ -663,6 +673,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.done(slotValue)
|
||||
if msg.err != nil {
|
||||
m.noteErr(msg.err)
|
||||
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
|
||||
m.detail.SetValueError(msg.err)
|
||||
} else {
|
||||
m.detail.SetValue(msg.value)
|
||||
@@ -689,6 +700,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.done(slotDescribe)
|
||||
if msg.err != nil {
|
||||
m.noteErr(msg.err)
|
||||
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
|
||||
m.detail.SetDescribeError(msg.err)
|
||||
} else {
|
||||
m.cache.details[msg.key] = entry[provider.Object]{msg.obj, m.now()}
|
||||
@@ -759,6 +771,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.focus != paneDetail {
|
||||
m.lastLeft = m.focus
|
||||
}
|
||||
m.logFlash()
|
||||
cmds = append(cmds, m.syncFields())
|
||||
if m.anyLoading() && !m.spinning {
|
||||
m.spinning = true
|
||||
@@ -1353,6 +1366,7 @@ func (m *Model) handleSources(msg sourcesMsg) tea.Cmd {
|
||||
for _, p := range m.providers {
|
||||
if r, ok := m.sourceResults[p.ID()]; ok && r.err != nil {
|
||||
m.noteErr(r.err)
|
||||
m.logErrOf(p.Label(), r.err)
|
||||
errs = append(errs, fmt.Sprintf("%s: %v", p.Label(), r.err))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1300,3 +1300,25 @@ func TestExpiringInStatus(t *testing.T) {
|
||||
t.Errorf("enter went to %s/%s\n%s", v, o, d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorLog(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriverOpts(t, Options{}, fake.Azure(0))
|
||||
d.keys("2", "j", "j", "3") // kv-restricted: its secrets can't be listed
|
||||
d.m.flash = flash{text: "copy failed: no clipboard", isErr: true}
|
||||
d.send(nil)
|
||||
d.send(nil) // still showing: logged once
|
||||
d.keys("0")
|
||||
scr := d.screen()
|
||||
if !strings.Contains(scr, "errors") || !strings.Contains(scr, "copy failed: no clipboard") || !strings.Contains(scr, "kv-restricted: 403 Forbidden") {
|
||||
t.Errorf("errors not listed:\n%s", scr)
|
||||
}
|
||||
if n := len(d.m.errLog); n != 2 || !strings.HasPrefix(d.m.errLog[0].text, "copy failed") {
|
||||
t.Errorf("log %+v", d.m.errLog)
|
||||
}
|
||||
d.m.logErr("403 Forbidden: again") // a new one, and an old one again: on top
|
||||
d.m.logErr(d.m.errLog[2].text)
|
||||
if len(d.m.errLog) != 3 || d.m.errLog[0].text == "403 Forbidden: again" {
|
||||
t.Errorf("log %+v", d.m.errLog)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
||||
)
|
||||
|
||||
// The status pane lists this session's errors, newest first: what a pane
|
||||
// showed (a list that couldn't load) and what the footer flashed (a copy
|
||||
// that failed), which go when you move on. Only the first line of each,
|
||||
// and never kept after quitting.
|
||||
|
||||
// maxErrors is how many errors are kept.
|
||||
const maxErrors = 20
|
||||
|
||||
type loggedErr struct {
|
||||
at time.Time
|
||||
text string
|
||||
}
|
||||
|
||||
// logErr records text; an error already there moves to the top, with
|
||||
// the time it happened again.
|
||||
func (m *Model) logErr(text string) {
|
||||
text, _, _ = strings.Cut(strings.TrimSpace(text), "\n")
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
m.errLog = slices.DeleteFunc(m.errLog, func(e loggedErr) bool { return e.text == text })
|
||||
m.errLog = append([]loggedErr{{m.now(), text}}, m.errLog[:min(len(m.errLog), maxErrors-1)]...)
|
||||
}
|
||||
|
||||
// logErrOf records err, about what, except a sign-in, which the footer
|
||||
// and the account's details say.
|
||||
func (m *Model) logErrOf(what string, err error) {
|
||||
var auth *provider.AuthError
|
||||
if !errors.As(err, &auth) {
|
||||
m.logErr(what + ": " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// logFlash records an error the footer is showing, once.
|
||||
func (m *Model) logFlash() {
|
||||
if m.flash.isErr && m.flash.text != m.loggedFlash {
|
||||
m.logErr(m.flash.text)
|
||||
}
|
||||
m.loggedFlash = m.flash.text
|
||||
}
|
||||
|
||||
// errorFields are the status pane's rows for them.
|
||||
func (m Model) errorFields() []panes.Field {
|
||||
out := make([]panes.Field, len(m.errLog))
|
||||
for i, e := range m.errLog {
|
||||
out[i] = panes.Field{Value: e.at.Format("15:04") + " " + e.text, Err: true}
|
||||
}
|
||||
if len(out) > 0 {
|
||||
out[0].Label = "errors"
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
// The fourth pane shows Fields for anything but a secret. On a provider's
|
||||
// row: its account, from what the provider knows locally.
|
||||
|
||||
// A vault's, scope's or file's details beyond its listing load once a
|
||||
// A file's details beyond its listing load once a
|
||||
// session, when the cursor rests on it, and again with refresh. They're
|
||||
// extra: moving on cancels the load, and search never waits for them.
|
||||
type detailsState struct {
|
||||
|
||||
@@ -3,7 +3,6 @@ package app
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
@@ -244,6 +243,6 @@ func (m *Model) restorePlace() {
|
||||
|
||||
func (m *Model) handlePersisted(msg persistedMsg) {
|
||||
if msg.err != nil {
|
||||
m.noteErr(fmt.Errorf("saving the cache or place: %w", msg.err))
|
||||
m.logErrOf("saving the cache or place", msg.err)
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,7 @@ func (m Model) statusFields() []panes.Field {
|
||||
}
|
||||
|
||||
out = append(out, m.expiringFields()...)
|
||||
out = append(out, m.errorFields()...)
|
||||
|
||||
for i, path := range m.configFiles {
|
||||
label := ""
|
||||
|
||||
Reference in new issue
Block a user