Merge remote-tracking branch 'origin/main' into feat/disk-cache
CI / Checks (pull_request) Successful in 23s
PR title / Conventional title (pull_request) Successful in 1s

# Conflicts:
#	internal/ui/app/app.go
This commit is contained in:
keb committed 2026-10-11 09:51:55 +02:00
commit 72003b2541
11 files changed
+446 -13

No files matched your search

+2
View File
@@ -6,6 +6,8 @@ A terminal UI for finding, reading and sharing secrets. Inspired by the fantasti
- **Read-only**: lazysecrets never creates, changes or deletes a secret, so - **Read-only**: lazysecrets never creates, changes or deletes a secret, so
it's safe to point at production. That's by design and may stay that way. it's safe to point at production. That's by design and may stay that way.
(`e` opens a repo's SOPS or `.env` file in your editor, or `sops edit`;
any change is yours.)
- **Zero setup**: uses the logins you already have (`az`, the Databricks - **Zero setup**: uses the logins you already have (`az`, the Databricks
CLI, `sops`, `git`). No config file needed. CLI, `sops`, `git`). No config file needed.
- **Search everything**: `ctrl+s` searches every source at once, with - **Search everything**: `ctrl+s` searches every source at once, with
+1
View File
@@ -52,6 +52,7 @@ match case exactly.
| `Y` | copy the reference (`@Microsoft.KeyVault(...)`, key/certificate URL, `{{secrets/scope/key}}`) | | `Y` | copy the reference (`@Microsoft.KeyVault(...)`, key/certificate URL, `{{secrets/scope/key}}`) |
| `v` | versions of the selected item (Azure); `space`/`y`/`Y` then act on that version; `esc`, `⌫` or `v` to go back | | `v` | versions of the selected item (Azure); `space`/`y`/`Y` then act on that version; `esc`, `⌫` or `v` to go back |
| `o` | open the selected item in the browser (over SSH: copy the link) | | `o` | open the selected item in the browser (over SSH: copy the link) |
| `e` | open the selected SOPS or `.env` file, or a secret's file at its line ([how](repos.md#opening-files)) |
| `s` / `S` | copy a [link](links.md) to the selected item / share menu | | `s` / `S` | copy a [link](links.md) to the selected item / share menu |
| `ctrl+o` | open a link or reference (or just paste one) | | `ctrl+o` | open a link or reference (or just paste one) |
| `x` | [custom commands](custom-commands.md) | | `x` | [custom commands](custom-commands.md) |
+18
View File
@@ -63,6 +63,24 @@ copying or `lazysecrets get` decrypts one value with your `sops` binary, so
whatever sops uses works (age keys, Azure Key Vault through az, AWS/GCP whatever sops uses works (age keys, Azure Key Vault through az, AWS/GCP
KMS, PGP). When sops can't decrypt, its whole error message is shown. KMS, PGP). When sops can't decrypt, its whole error message is shown.
## Opening files
`e` opens the selected file, or on a secret, its file at that key's line:
- **SOPS files** open with `sops edit`, which decrypts them into your
editor (`$SOPS_EDITOR` or `$EDITOR`) and encrypts them again when you
save. lazysecrets comes back after, with the file's keys reloaded.
- **Other files**, when lazysecrets runs inside nvim (its plugin, or
`:terminal`): lazysecrets quits and the file opens in that nvim, in the
window you came from.
- Otherwise in `$VISUAL` or `$EDITOR` in the terminal, coming back after;
without either, in the system's default app for the file.
Editors that take a line (vim, nvim, nano, emacs, micro, helix, VS Code,
Zed, Sublime and others) start on the key. With `sops edit` that works for
editors taking `+N` (vim, nvim, nano, emacs, micro and others), and needs
sops 3.8 or later.
## Links ## Links
Links name the repo by its git remote, so they open in a colleague's clone Links name the repo by its git remote, so they open in a colleague's clone
+15
View File
@@ -169,3 +169,18 @@ type Current interface {
type Locator interface { type Locator interface {
Locate(ctx context.Context, address string) (sourceID string, ok bool, err error) Locate(ctx context.Context, address string) (sourceID string, ok bool, err error)
} }
// Files is implemented by providers whose containers are files on disk
// (SOPS and .env files in repos), so they can be opened in an editor.
type Files interface {
// File is where c is, and for o (nil for the whole file) the line its
// key is on.
File(c Container, o *Object) (File, error)
}
// File is a container's file, to open.
type File struct {
Path string
Line int // 1-based; 0 for the top
Sops string // the sops command to edit it with; empty for a plain file
}
+37 -5
View File
@@ -412,6 +412,37 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
return provider.NewSecretValue(stdout.Bytes()), nil return provider.NewSecretValue(stdout.Bytes()), nil
} }
// File is the file c is, and the line o's key is on.
func (p *Provider) File(c provider.Container, o *provider.Object) (provider.File, error) {
path := c.ID
if path == "" { // made from a link
path = filepath.Join(c.Source.ID, filepath.FromSlash(c.Name))
}
f := provider.File{Path: path}
if _, isSops := readMeta(path); isSops {
f.Sops = p.s.Sops
}
if o == nil {
return f, nil
}
b, err := os.ReadFile(path)
if err != nil {
return f, err
}
keys, err := parseKeys(path, b)
if err != nil {
return f, nil // still worth opening, at the top
}
_, extract, _ := strings.Cut(o.ID, "\x00")
for _, k := range keys {
if k.extract == extract || (extract == "" && k.name == o.Name) {
f.Line = k.line
break
}
}
return f, nil
}
// sopsError is sops' own message, whole: which keys it tried and why // sopsError is sops' own message, whole: which keys it tried and why
// each failed can all matter. // each failed can all matter.
func sopsError(stderr string, err error) string { func sopsError(stderr string, err error) string {
@@ -871,6 +902,7 @@ type key struct {
name string // db.password, list[0] name string // db.password, list[0]
extract string // sops --extract: ["db"]["password"] extract string // sops --extract: ["db"]["password"]
encrypted bool encrypted bool
line int // 1-based
} }
// parseKeys lists a sops file's keys without decrypting anything. // parseKeys lists a sops file's keys without decrypting anything.
@@ -889,18 +921,18 @@ func parseKeys(file string, b []byte) ([]key, error) {
return out, nil return out, nil
case "dotenv": case "dotenv":
var out []key var out []key
for _, line := range strings.Split(string(b), "\n") { for i, line := range strings.Split(string(b), "\n") {
k, v, ok := envLine(line) k, v, ok := envLine(line)
if !ok || strings.HasPrefix(k, "sops_") { if !ok || strings.HasPrefix(k, "sops_") {
continue continue
} }
out = append(out, key{name: k, extract: quote(k), encrypted: strings.HasPrefix(v, "ENC[")}) out = append(out, key{name: k, extract: quote(k), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1})
} }
return out, nil return out, nil
case "ini": case "ini":
var out []key var out []key
section := "" section := ""
for _, line := range strings.Split(string(b), "\n") { for i, line := range strings.Split(string(b), "\n") {
line = strings.TrimSpace(line) line = strings.TrimSpace(line)
switch { switch {
case line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";"): case line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";"):
@@ -912,7 +944,7 @@ func parseKeys(file string, b []byte) ([]key, error) {
continue continue
} }
k, v = strings.TrimSpace(k), strings.TrimSpace(v) k, v = strings.TrimSpace(k), strings.TrimSpace(v)
out = append(out, key{name: section + "." + k, extract: quote(section) + quote(k), encrypted: strings.HasPrefix(v, "ENC[")}) out = append(out, key{name: section + "." + k, extract: quote(section) + quote(k), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1})
} }
} }
return out, nil return out, nil
@@ -944,7 +976,7 @@ func walk(n *yaml.Node, name, extract string, top bool, out *[]key) {
walk(n.Alias, name, extract, false, out) walk(n.Alias, name, extract, false, out)
} }
default: default:
*out = append(*out, key{name: name, extract: extract, encrypted: strings.HasPrefix(n.Value, "ENC[")}) *out = append(*out, key{name: name, extract: extract, encrypted: strings.HasPrefix(n.Value, "ENC["), line: n.Line})
} }
} }
+42
View File
@@ -3,6 +3,7 @@ package repo
import ( import (
"context" "context"
"errors" "errors"
"maps"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -435,3 +436,44 @@ func TestEnvFilesSkipPackages(t *testing.T) {
t.Errorf("files %v, want %v (nothing from node_modules or vendor)", got, want) t.Errorf("files %v, want %v (nothing from node_modules or vendor)", got, want)
} }
} }
func TestFile(t *testing.T) {
root := gitRepo(t, map[string]string{
".gitignore": ".env\n",
"secrets/app.yaml": sopsYAML,
})
os.WriteFile(filepath.Join(root, ".env"), []byte("# local\nTOKEN=x\n\nURL=y\n"), 0o600)
p := New(Settings{Dir: root, Sops: "/opt/sops"})
ctx := context.Background()
srcs, _ := p.Sources(ctx)
files, err := p.Containers(ctx, srcs[0])
if err != nil {
t.Fatal(err)
}
lines := map[string]int{}
for _, c := range files {
f, err := p.File(c, nil)
if err != nil || f.Path != filepath.Join(root, c.Name) || f.Line != 0 {
t.Errorf("%s: %+v, %v", c.Name, f, err)
}
if sops := f.Sops != ""; sops != (c.Name == "secrets/app.yaml") || (sops && f.Sops != "/opt/sops") {
t.Errorf("%s: sops %q", c.Name, f.Sops)
}
keys, _ := p.Objects(ctx, c, provider.TypeSecret)
for _, k := range keys {
f, err := p.File(c, &k)
if err != nil {
t.Fatal(err)
}
lines[c.Name+" "+k.Name] = f.Line
}
}
want := map[string]int{
".env TOKEN": 2, ".env URL": 4,
"secrets/app.yaml db.password": 2, "secrets/app.yaml db.port": 3,
"secrets/app.yaml list[0]": 5, "secrets/app.yaml public_unencrypted": 6,
}
if !maps.Equal(lines, want) {
t.Errorf("lines %v\nwant %v", lines, want)
}
}
+11 -1
View File
@@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"image/color" "image/color"
"math" "math"
"os"
"os/exec" "os/exec"
"strings" "strings"
"time" "time"
@@ -162,6 +163,9 @@ type Model struct {
clip Clipboard clip Clipboard
clipClear time.Duration clipClear time.Duration
openURL func(string) error openURL func(string) error
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 // The cache file, whether the cache changed since it was saved, and
// when each provider's Sources list was loaded (persist.go). // when each provider's Sources list was loaded (persist.go).
cachePath string cachePath string
@@ -169,7 +173,6 @@ type Model struct {
sourcesAt map[string]time.Time sourcesAt map[string]time.Time
placeDir string placeDir string
savedPlace string // the link last saved as the place savedPlace string // the link last saved as the place
now func() time.Time
// A pending clear of a copied value, counted down in the footer. // A pending clear of a copied value, counted down in the footer.
// clipToken identifies the latest copy so older ticks are ignored. // clipToken identifies the latest copy so older ticks are ignored.
@@ -350,6 +353,8 @@ func New(providers []provider.Provider, opts Options) Model {
clip: opts.Clipboard, clip: opts.Clipboard,
clipClear: opts.ClipboardClear, clipClear: opts.ClipboardClear,
now: time.Now, now: time.Now,
getenv: os.Getenv,
nvimOpen: nvimOpen,
cache: newCache(), cache: newCache(),
} }
if m.clip == nil { if m.clip == nil {
@@ -549,6 +554,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case commandDoneMsg: case commandDoneMsg:
m.handleCommandDone(msg) m.handleCommandDone(msg)
case editDoneMsg:
cmds = append(cmds, m.handleEditDone(msg))
case persistTickMsg: case persistTickMsg:
cmds = append(cmds, m.persistTick()) cmds = append(cmds, m.persistTick())
@@ -870,6 +878,8 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
} }
case key.Matches(msg, k.Open): case key.Matches(msg, k.Open):
return m.openSelected(), false return m.openSelected(), false
case key.Matches(msg, k.Edit):
return m.edit(), false
case key.Matches(msg, k.Login): case key.Matches(msg, k.Login):
if m.auth != nil && len(m.auth.Login) > 0 { if m.auth != nil && len(m.auth.Login) > 0 {
c := exec.Command(m.auth.Login[0], m.auth.Login[1:]...) c := exec.Command(m.auth.Login[0], m.auth.Login[1:]...)
+5 -5
View File
@@ -973,16 +973,16 @@ func TestSourcesFavouritesTab(t *testing.T) {
func TestRebindingShowsEverywhere(t *testing.T) { func TestRebindingShowsEverywhere(t *testing.T) {
t.Parallel() t.Parallel()
km := keys.Default() km := keys.Default()
if err := km.Apply(map[string]map[string][]string{"lists": {"reveal": {"e"}, "versions": {}}}); err != nil { if err := km.Apply(map[string]map[string][]string{"lists": {"reveal": {"z"}, "versions": {}}}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
d := newDriverOpts(t, Options{Keys: &km}) d := newDriverOpts(t, Options{Keys: &km})
d.keys("3") d.keys("3")
scr := d.screen() scr := d.screen()
if !strings.Contains(scr, "(e to reveal)") || strings.Contains(scr, "space to reveal") { if !strings.Contains(scr, "(z to reveal)") || strings.Contains(scr, "space to reveal") {
t.Errorf("detail hint doesn't follow the binding:\n%s", scr) t.Errorf("detail hint doesn't follow the binding:\n%s", scr)
} }
if !strings.Contains(lastLine(scr), "e reveal/hide") { if !strings.Contains(lastLine(scr), "z reveal/hide") {
t.Errorf("help bar: %q", lastLine(scr)) t.Errorf("help bar: %q", lastLine(scr))
} }
if strings.Contains(scr, "to browse") { if strings.Contains(scr, "to browse") {
@@ -992,9 +992,9 @@ func TestRebindingShowsEverywhere(t *testing.T) {
if strings.Contains(d.screen(), "correct-horse") { if strings.Contains(d.screen(), "correct-horse") {
t.Error("space still reveals") t.Error("space still reveals")
} }
d.keys("e") d.keys("z")
if !strings.Contains(d.screen(), "correct-horse-battery-staple") { if !strings.Contains(d.screen(), "correct-horse-battery-staple") {
t.Errorf("e doesn't reveal:\n%s", d.screen()) t.Errorf("z doesn't reveal:\n%s", d.screen())
} }
} }
+208
View File
@@ -0,0 +1,208 @@
package app
import (
"cmp"
"errors"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/browser"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// Opening a repo's file (e): SOPS files with sops edit, which decrypts
// them for the editor and encrypts them again after. Other files, inside
// nvim: in that nvim, once lazysecrets has quit; else in $VISUAL or
// $EDITOR, coming back after; else in the system's default app. On a
// secret, editors that can open at a line start on its key.
type editDoneMsg struct {
c provider.Container
name string
err error
}
// sopsUnchanged is sops edit's exit code when the file wasn't changed.
const sopsUnchanged = 200
// edit opens the selected file, or the file of the selected secret.
func (m *Model) edit() tea.Cmd {
var c provider.Container
var o *provider.Object
switch m.focus {
case paneSources:
m.flash = warning("select a file to open it")
return nil
case paneContainers:
sel, ok := m.containers.Selected()
if !ok {
return nil
}
c = sel
default:
sel, ok := m.detail.Secret()
if !ok {
return nil
}
c, o = sel.Container, &sel
}
files, ok := m.providerFor(c.Source).(provider.Files)
if !ok {
m.flash = warning("only SOPS and .env files in repos can be opened")
return nil
}
f, err := files.File(c, o)
if err != nil {
m.flash = flash{text: "open: " + err.Error(), isErr: true}
return nil
}
if o != nil {
m.usedObject(*o)
} else {
m.usedContainer(c)
}
name := filepath.Base(f.Path)
finished := func(err error) tea.Msg { return editDoneMsg{c: c, name: name, err: err} }
if f.Sops != "" {
cmd := exec.Command(f.Sops, "edit", f.Path)
cmd.Dir = filepath.Dir(f.Path) // sops looks for .sops.yaml from here
// sops starts SOPS_EDITOR (else EDITOR, else vim) on the decrypted
// copy, which keeps the file's lines: start it on the key.
editor := cmp.Or(m.getenv("SOPS_EDITOR"), m.getenv("EDITOR"), "vim")
if at, ok := editorAtLine(editor, f.Line); ok {
cmd.Env = append(os.Environ(), "SOPS_EDITOR="+at)
}
m.flash = flash{}
return tea.ExecProcess(cmd, finished)
}
if server := m.getenv("NVIM"); server != "" {
open := m.nvimOpen
return func() tea.Msg {
if err := open(server, f.Path, f.Line); err != nil {
return finished(err)
}
return tea.QuitMsg{}
}
}
if editor := cmpOr(m.getenv("VISUAL"), m.getenv("EDITOR")); editor != "" {
// Through the shell, so EDITOR can carry arguments ("code -w").
args := append([]string{"-c", editor + ` "$@"`, "lazysecrets"}, editorArgs(editor, f)...)
m.flash = flash{}
return tea.ExecProcess(exec.Command("/bin/sh", args...), finished)
}
switch err := m.openURL(f.Path); {
case errors.Is(err, browser.ErrRemote):
m.flash = warning("set $EDITOR to open files over SSH")
case err != nil:
m.flash = flash{text: "open: " + err.Error(), isErr: true}
default:
m.flash = done("opened " + name)
}
return nil
}
// plusLine are editors that open at a line with +N before the file.
var plusLine = map[string]bool{
"vi": true, "vim": true, "nvim": true, "nano": true, "emacs": true, "emacsclient": true,
"micro": true, "kak": true, "mg": true, "ne": true, "joe": true, "vis": true,
}
// editorAtLine is editor starting at line, for editors that take +N:
// sops adds the file after it.
func editorAtLine(editor string, line int) (string, bool) {
fields := strings.Fields(editor)
if line == 0 || len(fields) == 0 || !plusLine[filepath.Base(fields[0])] {
return "", false
}
return editor + " +" + strconv.Itoa(line), true
}
// editorArgs opens f at its line, for editors known to take one.
func editorArgs(editor string, f provider.File) []string {
fields := strings.Fields(editor)
if f.Line == 0 || len(fields) == 0 {
return []string{f.Path}
}
at := f.Path + ":" + strconv.Itoa(f.Line)
switch base := filepath.Base(fields[0]); {
case plusLine[base]:
return []string{"+" + strconv.Itoa(f.Line), f.Path}
case base == "hx" || base == "helix" || base == "subl" || base == "zed":
return []string{at}
case base == "code" || base == "codium" || base == "cursor" || base == "windsurf":
return []string{"-g", at}
}
return []string{f.Path}
}
// handleEditDone reports how editing went and reloads the file's secrets:
// their names, lines or values may have changed.
func (m *Model) handleEditDone(msg editDoneMsg) tea.Cmd {
var exit *exec.ExitError
switch {
case errors.As(msg.err, &exit) && exit.ExitCode() == sopsUnchanged:
m.flash = flash{text: msg.name + " unchanged"}
return nil
case errors.Is(msg.err, exec.ErrNotFound):
m.flash = flash{text: "open " + msg.name + ": " + msg.err.Error() + " (is sops installed?)", isErr: true}
return nil
case msg.err != nil:
m.flash = flash{text: "open " + msg.name + ": " + msg.err.Error(), isErr: true}
return nil
}
m.flash = done("closed " + msg.name)
if c, ok := m.containers.Selected(); ok && containerKey(c) == containerKey(msg.c) {
m.hideValue()
m.secrets.Refresh()
return m.loadObjects(c, m.objType, m.selContainer)
}
return nil
}
// nvimLua opens a file in the nvim lazysecrets runs in. If lazysecrets is
// that terminal's own process (the nvim plugin, or :terminal lazysecrets),
// it waits for the terminal to close, so the file opens in the window
// below rather than in one about to close. Otherwise (a shell in a
// terminal) it opens it in the previous window now. The autocmd isn't
// the buffer's own: snacks wipes the buffer when the terminal closes,
// which would delete it before it runs.
const nvimLua = `local path, line, pid = ...
local function open()
local ok, err = pcall(vim.cmd, "edit " .. vim.fn.fnameescape(path))
if not ok then vim.notify("lazysecrets: " .. err, vim.log.levels.ERROR) return end
if line > 0 then pcall(vim.api.nvim_win_set_cursor, 0, { line, 0 }) vim.cmd("normal! zz") end
end
for _, b in ipairs(vim.api.nvim_list_bufs()) do
if vim.bo[b].buftype == "terminal" and vim.b[b].terminal_job_pid == pid then
vim.api.nvim_create_autocmd("TermClose", { callback = function(ev)
if ev.buf ~= b then return end
vim.defer_fn(open, 50)
return true
end })
return
end
end
pcall(vim.cmd, "wincmd p")
open()`
// nvimOpen asks the nvim at server to open path at line.
func nvimOpen(server, path string, line int) error {
q := func(s string) string { return "'" + strings.ReplaceAll(s, "'", "''") + "'" }
lua := strings.ReplaceAll(nvimLua, "\n", " ")
expr := "luaeval('load(_A[1])(_A[2], _A[3], _A[4])', [" + q(lua) + ", " + q(path) + ", " +
strconv.Itoa(line) + ", " + strconv.Itoa(os.Getpid()) + "])"
out, err := exec.Command("nvim", "--server", server, "--remote-expr", expr).CombinedOutput()
if err != nil {
if msg := strings.TrimSpace(string(out)); msg != "" {
return errors.New(msg)
}
return err
}
return nil
}
+104
View File
@@ -3,6 +3,7 @@ package app
import ( import (
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"context" "context"
"fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -238,3 +239,106 @@ func TestSearchAllRepos(t *testing.T) {
t.Errorf("title:\n%s", d.screen()) t.Errorf("title:\n%s", d.screen())
} }
} }
func TestEditFile(t *testing.T) {
// Not parallel: git, run by the repo provider, is slow under load.
if _, err := exec.LookPath("git"); err != nil {
t.Skip("no git")
}
root := gitRepoWith(t, "https://git.example.com/team/x.git")
envPath := filepath.Join(root, "secrets", ".env")
os.WriteFile(envPath, []byte("# local\nTOKEN=x\n"), 0o600)
var opened []string
d := newDriverOpts(t, Options{OpenURL: func(u string) error { opened = append(opened, u); return nil }},
fake.Azure(0), repo.New(repo.Settings{Dir: root}))
env := map[string]string{"NVIM": "/tmp/nvim.sock"}
d.m.getenv = func(k string) string { return env[k] }
var nvim []string
d.m.nvimOpen = func(server, path string, line int) error {
nvim = append(nvim, fmt.Sprintf("%s %s:%d", server, path, line))
return nil
}
// Inside nvim: the file, then a secret at its line, and lazysecrets quits.
d.keys("2")
if _, c, _ := d.selected(); c != "secrets/.env" {
t.Fatalf("selected %q\n%s", c, d.screen())
}
d.keys("e")
d.keys("3", "e")
want := []string{"/tmp/nvim.sock " + envPath + ":0", "/tmp/nvim.sock " + envPath + ":2"}
if !slices.Equal(nvim, want) {
t.Errorf("nvim got %q\nwant %q", nvim, want)
}
if msg := d.m.edit()(); msg != (tea.QuitMsg{}) {
t.Errorf("after opening in nvim: %#v, want quit", msg)
}
// SOPS files go to sops edit, in the terminal, even inside nvim.
nvim = nil
d.keys("2", "j")
if _, c, _ := d.selected(); c != "secrets/app.yaml" {
t.Fatalf("selected %q", c)
}
if cmd := d.m.edit(); cmd == nil || len(nvim) > 0 {
t.Errorf("sops file: cmd %v, nvim %q", cmd, nvim)
}
// Neither nvim nor an editor: the system's default app.
delete(env, "NVIM")
d.keys("k", "e")
if !slices.Equal(opened, []string{envPath}) || !strings.Contains(d.screen(), "opened .env") {
t.Errorf("opened %q\n%s", opened, d.screen())
}
// Done editing: the file's secrets reload.
d.send(editDoneMsg{c: provider.Container{Source: provider.Source{Provider: "Repos"}, ID: envPath}, name: ".env"})
if !strings.Contains(d.screen(), "closed .env") {
t.Errorf("after editing:\n%s", d.screen())
}
// Other providers have no files.
az := newDriver(t, fake.Azure(0))
az.keys("2", "e")
if !strings.Contains(az.screen(), "only SOPS and .env files") {
t.Errorf("azure:\n%s", az.screen())
}
}
func TestEditorArgs(t *testing.T) {
t.Parallel()
f := provider.File{Path: "/r/.env", Line: 7}
for editor, want := range map[string][]string{
"nvim": {"+7", "/r/.env"},
"/usr/bin/vim -p": {"+7", "/r/.env"},
"hx": {"/r/.env:7"},
"code --wait": {"-g", "/r/.env:7"},
"open -a TextEdit": {"/r/.env"},
} {
if got := editorArgs(editor, f); !slices.Equal(got, want) {
t.Errorf("%s: %q, want %q", editor, got, want)
}
}
if got := editorArgs("nvim", provider.File{Path: "/r/.env"}); !slices.Equal(got, []string{"/r/.env"}) {
t.Errorf("no line: %q", got)
}
}
func TestEditorAtLine(t *testing.T) {
t.Parallel()
for _, c := range []struct {
editor string
line int
want string
ok bool
}{
{"nvim", 4, "nvim +4", true},
{"/usr/bin/vim -p", 4, "/usr/bin/vim -p +4", true},
{"code --wait", 4, "", false}, // sops adds the file last: no -g file:N
{"nvim", 0, "", false},
} {
if got, ok := editorAtLine(c.editor, c.line); got != c.want || ok != c.ok {
t.Errorf("%q at %d: %q %v", c.editor, c.line, got, ok)
}
}
}
+3 -2
View File
@@ -40,7 +40,7 @@ type Map struct {
Pane1, Pane2, Pane3, Pane4 key.Binding Pane1, Pane2, Pane3, Pane4 key.Binding
NextTab, PrevTab key.Binding NextTab, PrevTab key.Binding
Enter, Back, Filter key.Binding Enter, Back, Filter key.Binding
Reveal, Copy, CopyRef, Open key.Binding Reveal, Copy, CopyRef, Open, Edit key.Binding
Versions, Favourite, Favourites key.Binding Versions, Favourite, Favourites key.Binding
Recent key.Binding Recent key.Binding
Note, Share, ShareMenu, OpenRef key.Binding Note, Share, ShareMenu, OpenRef key.Binding
@@ -111,6 +111,7 @@ func (m *Map) Actions() []Action {
a(Lists, "copyReference", &m.CopyRef, "copy reference", "secrets", "Copy the reference to use in config, e.g. @Microsoft.KeyVault(SecretUri=…) or {{secrets/scope/key}}."), a(Lists, "copyReference", &m.CopyRef, "copy reference", "secrets", "Copy the reference to use in config, e.g. @Microsoft.KeyVault(SecretUri=…) or {{secrets/scope/key}}."),
a(Lists, "versions", &m.Versions, "versions", "secrets", "List the versions of the selected item (Azure). Reveal, copy and share then act on the selected version."), a(Lists, "versions", &m.Versions, "versions", "secrets", "List the versions of the selected item (Azure). Reveal, copy and share then act on the selected version."),
a(Lists, "openInBrowser", &m.Open, "open in browser", "secrets", "Open the selected subscription, vault or item in the browser (Azure portal or Databricks workspace)."), a(Lists, "openInBrowser", &m.Open, "open in browser", "secrets", "Open the selected subscription, vault or item in the browser (Azure portal or Databricks workspace)."),
a(Lists, "edit", &m.Edit, "open file", "secrets", "Open the selected SOPS or .env file, or the file of the selected secret at its line. SOPS files open with sops edit. Others open in the nvim lazysecrets runs in (quitting lazysecrets), else in $VISUAL or $EDITOR, else in the system's default app."),
a(Lists, "share", &m.Share, "copy link", "share", "Copy a lazysecrets:// link to the selected subscription, workspace, vault, scope or secret, for sharing. From the versions view the link is pinned to that version."), a(Lists, "share", &m.Share, "copy link", "share", "Copy a lazysecrets:// link to the selected subscription, workspace, vault, scope or secret, for sharing. From the versions view the link is pinned to that version."),
a(Lists, "shareMenu", &m.ShareMenu, "share…", "share", "Choose what to share: the lazysecrets link, the plain Key Vault URL, the config reference, or the web page."), a(Lists, "shareMenu", &m.ShareMenu, "share…", "share", "Choose what to share: the lazysecrets link, the plain Key Vault URL, the config reference, or the web page."),
@@ -183,7 +184,7 @@ var defaults = map[string][]string{
"lists.star": {"f"}, "lists.favourites": {"F"}, "lists.recent": {"R"}, "lists.search": {"ctrl+s"}, "lists.note": {"n"}, "lists.star": {"f"}, "lists.favourites": {"F"}, "lists.recent": {"R"}, "lists.search": {"ctrl+s"}, "lists.note": {"n"},
"lists.commands": {"x"}, "lists.commands": {"x"},
"lists.reveal": {"space"}, "lists.copyValue": {"y"}, "lists.copyReference": {"Y"}, "lists.reveal": {"space"}, "lists.copyValue": {"y"}, "lists.copyReference": {"Y"},
"lists.versions": {"v"}, "lists.openInBrowser": {"o"}, "lists.versions": {"v"}, "lists.openInBrowser": {"o"}, "lists.edit": {"e"},
"lists.share": {"s"}, "lists.shareMenu": {"S"}, "lists.openLink": {"ctrl+o"}, "lists.share": {"s"}, "lists.shareMenu": {"S"}, "lists.openLink": {"ctrl+o"},
"lists.dividerLeft": {"ctrl+left", "<"}, "lists.dividerRight": {"ctrl+right", ">"}, "lists.dividerLeft": {"ctrl+left", "<"}, "lists.dividerRight": {"ctrl+right", ">"},
"lists.taller": {"ctrl+down", "}"}, "lists.shorter": {"ctrl+up", "{"}, "lists.taller": {"ctrl+down", "}"}, "lists.shorter": {"ctrl+up", "{"},