feat: keep what was loaded between runs, and start where you left off #2
No files matched your search
@@ -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
|
||||
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
|
||||
CLI, `sops`, `git`). No config file needed.
|
||||
- **Search everything**: `ctrl+s` searches every source at once, with
|
||||
|
||||
@@ -52,6 +52,7 @@ match case exactly.
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `ctrl+o` | open a link or reference (or just paste one) |
|
||||
| `x` | [custom commands](custom-commands.md) |
|
||||
|
||||
@@ -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
|
||||
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 name the repo by its git remote, so they open in a colleague's clone
|
||||
|
||||
@@ -169,3 +169,18 @@ type Current interface {
|
||||
type Locator interface {
|
||||
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
|
||||
}
|
||||
@@ -412,6 +412,37 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
|
||||
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
|
||||
// each failed can all matter.
|
||||
func sopsError(stderr string, err error) string {
|
||||
@@ -871,6 +902,7 @@ type key struct {
|
||||
name string // db.password, list[0]
|
||||
extract string // sops --extract: ["db"]["password"]
|
||||
encrypted bool
|
||||
line int // 1-based
|
||||
}
|
||||
|
||||
// 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
|
||||
case "dotenv":
|
||||
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)
|
||||
if !ok || strings.HasPrefix(k, "sops_") {
|
||||
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
|
||||
case "ini":
|
||||
var out []key
|
||||
section := ""
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
for i, line := range strings.Split(string(b), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
switch {
|
||||
case line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";"):
|
||||
@@ -912,7 +944,7 @@ func parseKeys(file string, b []byte) ([]key, error) {
|
||||
continue
|
||||
}
|
||||
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
|
||||
@@ -944,7 +976,7 @@ func walk(n *yaml.Node, name, extract string, top bool, out *[]key) {
|
||||
walk(n.Alias, name, extract, false, out)
|
||||
}
|
||||
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})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package repo
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"maps"
|
||||
"os"
|
||||
"os/exec"
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
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
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -162,6 +163,9 @@ type Model struct {
|
||||
clip Clipboard
|
||||
clipClear time.Duration
|
||||
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
|
||||
// when each provider's Sources list was loaded (persist.go).
|
||||
cachePath string
|
||||
@@ -169,7 +173,6 @@ type Model struct {
|
||||
sourcesAt map[string]time.Time
|
||||
placeDir string
|
||||
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.
|
||||
// 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,
|
||||
clipClear: opts.ClipboardClear,
|
||||
now: time.Now,
|
||||
getenv: os.Getenv,
|
||||
nvimOpen: nvimOpen,
|
||||
cache: newCache(),
|
||||
}
|
||||
if m.clip == nil {
|
||||
@@ -549,6 +554,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case commandDoneMsg:
|
||||
m.handleCommandDone(msg)
|
||||
|
||||
case editDoneMsg:
|
||||
cmds = append(cmds, m.handleEditDone(msg))
|
||||
|
||||
case persistTickMsg:
|
||||
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):
|
||||
return m.openSelected(), false
|
||||
case key.Matches(msg, k.Edit):
|
||||
return m.edit(), false
|
||||
case key.Matches(msg, k.Login):
|
||||
if m.auth != nil && len(m.auth.Login) > 0 {
|
||||
c := exec.Command(m.auth.Login[0], m.auth.Login[1:]...)
|
||||
|
||||
@@ -973,16 +973,16 @@ func TestSourcesFavouritesTab(t *testing.T) {
|
||||
func TestRebindingShowsEverywhere(t *testing.T) {
|
||||
t.Parallel()
|
||||
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)
|
||||
}
|
||||
d := newDriverOpts(t, Options{Keys: &km})
|
||||
d.keys("3")
|
||||
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)
|
||||
}
|
||||
if !strings.Contains(lastLine(scr), "e reveal/hide") {
|
||||
if !strings.Contains(lastLine(scr), "z reveal/hide") {
|
||||
t.Errorf("help bar: %q", lastLine(scr))
|
||||
}
|
||||
if strings.Contains(scr, "to browse") {
|
||||
@@ -992,9 +992,9 @@ func TestRebindingShowsEverywhere(t *testing.T) {
|
||||
if strings.Contains(d.screen(), "correct-horse") {
|
||||
t.Error("space still reveals")
|
||||
}
|
||||
d.keys("e")
|
||||
d.keys("z")
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package app
|
||||
import (
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -238,3 +239,106 @@ func TestSearchAllRepos(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ type Map struct {
|
||||
Pane1, Pane2, Pane3, Pane4 key.Binding
|
||||
NextTab, PrevTab 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
|
||||
Recent 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, "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, "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, "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.commands": {"x"},
|
||||
"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.dividerLeft": {"ctrl+left", "<"}, "lists.dividerRight": {"ctrl+right", ">"},
|
||||
"lists.taller": {"ctrl+down", "}"}, "lists.shorter": {"ctrl+up", "{"},
|
||||
|
||||
Reference in new issue
Block a user