34 Commits
Author SHA1 Message Date
keb 5946f855bd feat: lazysecrets list marks entries with a note (✎) 2026-10-14 21:24:01 +02:00
keb 824f7249c3 feat: lazysecrets list prints notes, and --demo has a couple 2026-10-14 21:24:01 +02:00
keb 2c9c37f809 feat: lazysecrets list merges error and expiry into one status field 2026-10-14 21:24:01 +02:00
keb 37164158ca feat: lazysecrets list marks what's wrong (✗) or due (⚠) by the icon, and writes expired or expires in 4d 2026-10-14 21:24:01 +02:00
keb 4473e037f8 test: --demo's emergency-admin expires soon 2026-10-14 21:24:01 +02:00
keb 2db3d28d6d feat: lazysecrets list says expired or how soon, as the TUI does, in a field of its own 2026-10-14 21:24:01 +02:00
keb e49a2d8b53 test: --demo's legacy-token has expired too 2026-10-14 21:24:01 +02:00
keb 94aa74b05d feat: lazysecrets list right-aligns the marks by the icon, so names line up 2026-10-14 21:24:01 +02:00
keb 419a5f3047 feat: lazysecrets list's marks take space only when present 2026-10-14 21:24:01 +02:00
keb c548688715 feat: --demo has expired, disabled and expiring secrets 2026-10-14 21:24:01 +02:00
keb b4e4c1515b feat: lazysecrets list marks expiring and expired secrets, and its JSON has expires and enabled 2026-10-14 21:24:01 +02:00
keb 33fa064104 feat: ui.recentIcon marks items used lately with ◷, and lazysecrets list --recent 2026-10-14 21:24:01 +02:00
keb 33a9b0864d feat: lazysecrets list's JSON says when an entry was last used, and how often 2026-10-14 21:24:01 +02:00
keb 0e4580f245 feat: --demo also stars a subscription and a repo file 2026-10-14 21:24:01 +02:00
keb 46af233bd3 feat: lazysecrets list puts the star and icon in a field of their own, so ^ searches match names 2026-10-14 21:24:01 +02:00
keb 3bc0fc8fd6 feat: --demo starts with a few starred and recently used secrets 2026-10-14 21:24:01 +02:00
keb 4c822e42f1 feat: lazysecrets list's --starred-icon is --stars 2026-10-14 21:24:01 +02:00
keb 4cb40a1598 feat: lazysecrets list shows stars by default 2026-10-14 21:24:01 +02:00
keb 77e3fcd551 feat: lazysecrets list marks starred and recent entries, and --starred-icon shows the stars 2026-10-14 21:24:01 +02:00
keb 50156d706e feat: lazysecrets list puts why a list failed in a column of its own 2026-10-14 21:24:01 +02:00
keb b0ea217d87 docs: get and list move to their own page, integration.md 2026-10-14 21:24:01 +02:00
keb 470f0c11e4 docs: the fzf example copies on ctrl-y and copies and closes on ctrl-x 2026-10-14 21:24:01 +02:00
keb e8d3a3337a docs: the fzf example copies and closes on ctrl-y, copies and stays on alt-y 2026-10-14 21:24:01 +02:00
keb bdf949339b feat: lazysecrets open --action ends in the secrets list, like Go to 2026-10-14 21:24:01 +02:00
keb a4002a785c feat: lazysecrets open --action goes, copies or shows without asking 2026-10-14 21:24:01 +02:00
keb b05d19209c feat: lazysecrets list prints the account, and open and get take it (--account) 2026-10-14 21:24:01 +02:00
keb 1f330bc619 feat: lazysecrets list's text lines start with the kind 2026-10-14 21:24:01 +02:00
keb 572cea8351 docs: the fzf example for lazysecrets list hides the link and binds open and copy 2026-10-14 21:24:01 +02:00
keb 3734989b15 fix: lazysecrets list keeps the provider icon on error lines 2026-10-14 21:24:01 +02:00
keb 24b38d7321 fix: lazysecrets list keeps each entry on one line, errors to their first 2026-10-14 21:24:01 +02:00
keb 7b377fd468 feat: lazysecrets list shows unreadable lists as red ✗ lines, and filters by kind 2026-10-14 21:24:01 +02:00
keb baee1dfe61 fix: lazysecrets list puts the name first, and keeps errors out of fzf 2026-10-14 21:24:01 +02:00
keb e99462bd21 feat: lazysecrets list colours names from the theme, with icons 2026-10-14 21:24:01 +02:00
keb b1455451e6 feat: lazysecrets list prints every source, vault and secret, for fzf and editor pickers 2026-10-14 21:24:01 +02:00
38 changed files with 218 additions and 511 deletions

No files matched your search

-12
View File
@@ -2,18 +2,6 @@
Every release of lazysecrets, newest first.
## v0.2.0 – 2026-10-14
### Added
- Lazysecrets list prints everything lazysecrets can find, for fzf and editor pickers ([#70](https://git.kebcloud.no/keb/lazysecrets/pulls/70)) ([39454d4](https://git.kebcloud.no/keb/lazysecrets/commit/39454d47a864c8628c50b023aee9539d33be2f44))
### Fixed
- Lazysecrets starts from fzf's become, on a non-blocking terminal ([#67](https://git.kebcloud.no/keb/lazysecrets/pulls/67)) ([4480e8f](https://git.kebcloud.no/keb/lazysecrets/commit/4480e8fed00024691091eb7096abb9dc83b8044d))
- Local builds are dev-<commit> again, not a Go pseudo-version ([#68](https://git.kebcloud.no/keb/lazysecrets/pulls/68)) ([e825a51](https://git.kebcloud.no/keb/lazysecrets/commit/e825a51b30261b55c536af18fefde79dc6308377))
- A wrapped value in the details keeps its colour on every line ([#69](https://git.kebcloud.no/keb/lazysecrets/pulls/69)) ([dbb0eb2](https://git.kebcloud.no/keb/lazysecrets/commit/dbb0eb2bbdf6d695cf0d9ac15a1b513af8ba431d))
## v0.1.2 – 2026-10-13
### Added
+10 -22
View File
@@ -12,12 +12,16 @@ A terminal UI for finding, reading and sharing secrets. Inspired by the fantasti
CLI, `sops`, `git`). No config file needed.
- **Search everything**: `ctrl+s` searches every source at once, with
fzf-style matching.
- **Share links**: Share `lazysecrets://` links with colleagues.
- **Share links**: Share `lazysecrets://` links with colleagues. A colleague opens it
with their own access, from the terminal or with a click in the browser
or chat. `.env` files can be shared too, as a link encrypted with a
secret your team can read.
- **Neovim integration**: via [lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim)
- **Careful with values**: values stay hidden until you reveal them, and
copied values are cleared from the clipboard after 30 seconds.
- **Scriptable**: `lazysecrets get <link>` prints a value for scripts and
pipes.
pipes, and `lazysecrets list` everything it can find, for fzf and editor
pickers ([integration](docs/integration.md)).
- **Favourites**: star the secrets you use most for easy access.
- **Expiry warnings**: secrets, keys and certificates that expire soon are
marked, listed together in the status pane.
@@ -77,32 +81,16 @@ use most:
| `?` | every key, searchable |
| `q` | quit |
## Integration
Lazysecret let you integrate it into your workflows by `lazysecrets` `list`/`get`/`open`
Here is an example of implementing a picker in `fzf`:
```sh
lazysecrets list | fzf --ansi --delimiter='\t' --with-nth='2..-3' --nth='2..' --accept-nth=-1 \
--header 'enter: link · ctrl-o: open · ctrl-y: copy value · ctrl-x: copy and close' \
--bind 'ctrl-o:become(lazysecrets open --action go --account {-2} {-1})' \
--bind 'ctrl-y:execute-silent(lazysecrets get --account {-2} {-1} | pbcopy)+change-header(Copied the value of {3})' \
--bind 'ctrl-x:become(lazysecrets get --account {-2} {-1} | pbcopy)'
```
![fzf-example](docs/assets/fzf-example.png)
More defails in the [Integration](docs/integration.md) docs
## Docs
- [Keys](docs/keys.md): every key, Go to, the layout, and rebinding keys
- [Azure Key Vault](docs/azure.md): accounts, permissions, pinned vaults
- [Databricks](docs/databricks.md): profiles and Key Vault-backed scopes
- [SOPS and .env files](docs/repos.md): which repos and files, decrypting
- [Sharing](docs/sharing.md): links to secrets, encrypted env files, and
opening links, also from the browser and chat
- [Integration](docs/integration.md): how lazysecrets can integrate into other systems
- [Links](docs/links.md): sharing, opening, and opening links from the
browser and chat
- [Integration](docs/integration.md): `lazysecrets get` in scripts, and
`lazysecrets list` for fzf and editor pickers
- [Custom commands](docs/custom-commands.md): run your own commands on a
secret
- [Config](docs/config.md): every option, themes, layered configs, the
+31 -42
View File
@@ -1,7 +1,6 @@
package main
import (
"cmp"
"context"
"encoding/json"
"errors"
@@ -18,7 +17,6 @@ import (
"charm.land/lipgloss/v2"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
"git.kebcloud.no/keb/lazysecrets/internal/listing"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/store"
@@ -53,7 +51,7 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t
iconsOn := fs.Bool("icons", withIcons, "put the provider's Nerd Font icon before names (text)")
stars := fs.Bool("stars", true, "put ★ before starred names, in the theme's favouriteColor (text)")
recent := fs.Bool("recent", withRecent, "put ◷ before names used lately, in mutedColor (text; default ui.recentIcon)")
notes := fs.Bool("notes", true, "start a note with a note icon (✎ without --icons) (text; the note is printed either way)")
notes := fs.Bool("notes", true, "put ✎ before names with a note, in mutedColor (text; the note is printed either way)")
expiry := fs.Bool("expiry", true, "mark and say which have expired (✗ expired) or expire within ui.expiryWarningDays (⚠ expires in 12d) (text)")
kinds := fs.String("kind", "", "only these kinds, comma-separated: "+strings.Join(listing.Kinds, ", ")+" (vault: also scopes and repo files)")
showErrors := fs.Bool("errors", true, "list the lists that can't be read, with why (text: ✗ lines; json: \"error\")")
@@ -77,7 +75,7 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t
return fmt.Errorf("--color: %q is not always or never", *color)
}
now := time.Now()
m := marks{theme: theme, icons: icons.None, stars: *stars, recent: *recent, expiry: *expiry,
m := marks{theme: theme, icons: icons.None, stars: *stars, recent: *recent, notes: *notes, expiry: *expiry,
color: *color == "always", warn: expiryWarn, now: now}
if *iconsOn {
m.icons = icons.NerdFont
@@ -95,7 +93,7 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t
if status == "" && *expiry {
status = expiryOf(e, theme, expiryWarn, now, colored)
}
note := noteOf(e, theme, m.icons, *notes, colored)
note := renderer(colored)(theme.Muted, e.Note)
// A tab or line break in a name would break the line up.
_, err := fmt.Fprintf(out, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", e.Kind, mark, oneLine.Replace(name), oneLine.Replace(status),
oneLine.Replace(note), e.Account, e.Link)
@@ -128,18 +126,20 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t
if cachePath != "" {
f = cachefile.Read(cachePath)
}
f, loaded := listing.Run(context.Background(), listing.Options{
f = listing.Run(context.Background(), listing.Options{
Providers: providers, Cache: f, MaxAge: *maxAge, Cached: *cached, Kinds: only, Timeout: timeout,
Mark: func(link, account string) listing.Marks {
Mark: func(link, account string) (bool, time.Time, int, string) {
n := st.Node(link)
return listing.Marks{Starred: n.StarredFor(account), Recent: n.UsedLately(now),
LastUsed: n.LastUsed, Uses: n.Uses, Note: n.Note}
return n.StarredFor(account), n.LastUsed, n.Uses, n.Note
},
}, emit)
// Only what was loaded: list never moves a cursor, so the TUI's stay.
if cachePath != "" && loaded {
f.Cursor = nil
if err := cachefile.Save(cachePath, f, cachefile.Keep(providers), time.Now()); err != nil {
if cachePath != "" && !*cached {
byID := map[string]provider.Provider{}
for _, p := range providers {
byID[p.ID()] = p
}
keep := func(id string) bool { p, ok := byID[id]; return ok && p.Kind() != provider.KindRepo }
if err := cachefile.Save(cachePath, f, keep, time.Now()); err != nil {
return err
}
}
@@ -151,31 +151,21 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t
var oneLine = strings.NewReplacer("\t", " ", "\r\n", " ", "\n", " ", "\r", " ")
// noteOf is the entry's note, muted, after a note icon (✎ without Nerd
// Font icons) with icon; or "".
func noteOf(e listing.Entry, theme styles.Styles, iconSet icons.Set, icon, color bool) string {
if e.Note == "" {
return ""
}
note := e.Note
if icon {
note = cmp.Or(iconSet.Note, "✎") + " " + note
}
return renderer(color)(theme.Muted, note)
}
// expiryOf says that a secret, key or certificate has expired, in red,
// or when it will if that's within warn (expires in 12d), in yellow, or
// is "". Its mark (✗, ⚠) is before the name.
func expiryOf(e listing.Entry, theme styles.Styles, warn time.Duration, now time.Time, color bool) string {
render := renderer(color)
switch state, left := expiry.Of(e.Expires, now, warn); state {
case expiry.Expired:
return render(theme.Error, "expired")
case expiry.Soon:
return render(theme.Warning, "expires in "+expiry.Left(left))
}
left := e.Expires.Sub(now)
switch {
case e.Expires.IsZero() || left > warn:
return ""
case left <= 0:
return render(theme.Error, "expired")
case left < 24*time.Hour:
return render(theme.Warning, fmt.Sprintf("expires in %dh", max(1, int(left.Hours()))))
}
return render(theme.Warning, fmt.Sprintf("expires in %dd", int(left.Hours()/24)))
}
// errorOf is why a list couldn't be read, in red, or "". Its mark (✗)
@@ -204,13 +194,13 @@ func nameOf(e listing.Entry, theme styles.Styles, color bool) string {
// marks says what goes before the name, in a field of its own so a
// search leaves it out: ✗ in red when something's wrong (it couldn't be
// read, or has expired) or ⚠ when it expires within warn; ★ for a starred
// entry and ◷ for one used lately, as the TUI marks them; then the
// provider's icon. The marks are right-aligned in room for every kind
// entry and ◷ for one used lately, as the TUI marks them; ✎ for one with a
// note; then the provider's icon. The marks are right-aligned in room for every kind
// turned on, so they sit by the icon and names line up.
type marks struct {
theme styles.Styles
icons icons.Set
stars, recent, expiry, color bool
stars, recent, notes, expiry, color bool
warn time.Duration // ui.expiryWarningDays
now time.Time
}
@@ -226,14 +216,10 @@ func (m marks) of(e listing.Entry) string {
}
}
// What's wrong, then what to watch: room for one of them.
state := expiry.None
if m.expiry {
state, _ = expiry.Of(e.Expires, m.now, m.warn)
}
switch {
case e.Error != "" || state == expiry.Expired:
switch left := e.Expires.Sub(m.now); {
case e.Error != "" || (m.expiry && !e.Expires.IsZero() && left <= 0):
mark(true, m.theme.Error, "✗")
case state == expiry.Soon:
case m.expiry && !e.Expires.IsZero() && left <= m.warn:
mark(true, m.theme.Warning, "⚠")
default:
mark(false, m.theme.Error, "")
@@ -244,6 +230,9 @@ func (m marks) of(e listing.Entry) string {
if m.recent {
mark(e.Recent, m.theme.Muted, "◷")
}
if m.notes {
mark(e.Note != "", m.theme.Muted, "✎")
}
pad := strings.Repeat(" ", room-len(parts)) // each mark and its space
kind := provider.Kind("")
for _, k := range []provider.Kind{provider.KindAzure, provider.KindDatabricks, provider.KindRepo} {
+13 -12
View File
@@ -87,19 +87,20 @@ func TestListExpiry(t *testing.T) {
}
}
func TestListNotes(t *testing.T) {
e := listing.Entry{Note: "rotate in May"}
if got := noteOf(e, styles.Default(), icons.NerdFont, true, false); got != icons.NerdFont.Note+" rotate in May" {
t.Errorf("icons: %q", got)
func TestListNoteMark(t *testing.T) {
az := icons.NerdFont.Azure
m := marks{icons: icons.NerdFont, stars: true, notes: true}
for _, c := range []struct {
e listing.Entry
want string
}{
{listing.Entry{Provider: "azure", Note: "rotate in May"}, " ✎ " + az},
{listing.Entry{Provider: "azure", Note: "x", Starred: true}, " ★ ✎ " + az},
{listing.Entry{Provider: "azure"}, " " + az},
} {
if got := m.of(c.e); got != c.want {
t.Errorf("%+v: %q, want %q", c.e, got, c.want)
}
if got := noteOf(e, styles.Default(), icons.None, true, false); got != "✎ rotate in May" {
t.Errorf("no icons: %q", got)
}
if got := noteOf(e, styles.Default(), icons.NerdFont, false, false); got != "rotate in May" {
t.Errorf("--notes=false: %q", got)
}
if got := noteOf(listing.Entry{}, styles.Default(), icons.NerdFont, true, true); got != "" {
t.Errorf("no note: %q", got)
}
}
+1 -4
View File
@@ -322,11 +322,8 @@ Flags:
// Two Azure accounts, like a personal and an admin login in az.
alice, admin := "Azure:alice@example.com/demo", "Azure:admin.alice@example.com/demo"
fake.Seed(st, alice, admin)
az := fake.Azure(400*time.Millisecond).As(alice, "Azure · alice")
// Due soon whenever the demo runs (the tests' dates are fixed).
az.Expire("kv-app-prod", "api-key", time.Now().AddDate(0, 0, 9).Truncate(24*time.Hour))
providers = []provider.Provider{
az,
fake.Azure(400*time.Millisecond).As(alice, "Azure · alice"),
fake.AzureOps(700*time.Millisecond).As(admin, "Azure · admin.alice"),
fake.Databricks(600 * time.Millisecond),
fake.Repos(),
Binary file not shown.

Before

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

After

Width:  |  Height:  |  Size: 232 KiB

+2 -2
View File
@@ -54,8 +54,8 @@ sharing: # sharing env files as encrypted links
```
More, each on its own page: [repos](repos.md) (`providers.repos`),
[sharing env files](sharing.md#sharing-env-files) (`sharing`), the
[link handler](sharing.md#from-the-browser-and-chat) (`linkHandler`),
[sharing env files](links.md#sharing-env-files) (`sharing`), the
[link handler](links.md#from-the-browser-and-chat) (`linkHandler`),
[keybindings](keys.md#rebinding-keys) (`keybinding`) and
[custom commands](custom-commands.md) (`customCommands`).
+57 -54
View File
@@ -2,7 +2,7 @@
lazysecrets works outside its own window too: `lazysecrets get` reads a
value for scripts, and `lazysecrets list` lists everything for fzf, editor
pickers and your own tools. Both take the [links](sharing.md) lazysecrets
pickers and your own tools. Both take the [links](links.md) lazysecrets
copies and shares. For Neovim, there's
[lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim).
@@ -19,8 +19,7 @@ It reads the value directly, with your az accounts (the one that last read
it first), the Databricks profile for the workspace, or sops in your clone
of the repo; a version in the link reads that version. `--account` tries
that account first, as [`list`](#listing-for-fzf-and-pickers) prints it.
The value ends with a newline only on a terminal. When it can't read the
value, it says why on stderr and exits with status 1.
The value ends with a newline only on a terminal.
## Listing, for fzf and pickers
@@ -30,65 +29,69 @@ tab-separated:
| Field | |
|---|---|
| kind | `source`, `vault`, `secret`, `key` or `certificate` |
| mark | ✗ when something's wrong (expired, or couldn't be read) or ⚠ when it expires soon; ★ when starred; ◷ when used lately (with `--recent`); and the provider's icon |
| name | `platform-prod / kv-app-prod / db-password` |
| status | why a vault or source couldn't be read, or that it has `expired` or `expires in 12d` (within `ui.expiryWarningDays`), or empty |
| note | your note on it (`n` in lazysecrets), after a note icon (✎ without icons), or empty |
| account | what it's listed through: an az account (`Azure:<user>/<tenant>`), `Databricks` or `Repos` |
| link | its `lazysecrets://` link |
What's cached prints at once, then what loads follows as it arrives: read
it as a stream. Values are never listed. It stops quietly when the reader
goes, like fzf after a pick. Kind stays the first field, and account and
link the last two; new fields only ever go between. The mark has a field of
its own so a search can leave it out: in fzf, `--nth` searches the name,
so `^prod` finds names starting with prod.
It's what Go to searches, so fzf, or an editor's picker, can search it
instead, showing the names and acting on the links:
| Flag | |
|---|---|
| `--format json` | an object per line instead, for editor plugins (below) |
| `--kind secret,vault` | only these kinds: `source`, `vault`, `secret`, `key`, `certificate`; lists only other kinds need aren't loaded |
| `--cached` | only what's cached, at once: nothing is loaded from Azure or Databricks |
| `--max-age 2m` | load cached lists older than this again |
| `--errors=false` | leave out the vaults and sources it couldn't read |
| `--color never` | no colours, for scripts |
| `--icons=false` | no provider icons |
| `--stars=false` | no ★ before starred names (in `favouriteColor`) |
| `--notes=false` | no note icon before a note |
| `--recent` | ◷ before names used lately, in `mutedColor`; default `ui.recentIcon` |
| `--expiry=false` | no expiry: not in the status, and no ✗ or ⚠ for it |
With `--format json`, each line is an object with these fields; new ones
may be added:
| Field | |
|---|---|
| `link`, `kind`, `account` | as above |
| `error` | why it couldn't be read, only then |
| `provider` | `azure`, `databricks` or `repo` |
| `path` | the names: `["platform-prod", "kv-app-prod", "db-password"]` |
| `accountLabel` | the account's short name, when several az accounts are signed in |
| `starred` | starred for this account |
| `note` | your note on it, if any |
| `reference` | how config refers to a secret: `@Microsoft.KeyVault(SecretUri=…)` or `{{secrets/scope/key}}` (`Y` in lazysecrets) |
| `url` | its web page, in the Azure portal or the Databricks workspace (`o` in lazysecrets) |
| `recent` | used lately, as Recent (`R`) lists: in the last 90 days |
| `lastUsed`, `uses` | when it was last used, and how often; only when `recent`, to sort by |
| `expires` | when a secret, key or certificate expires, if it does (Azure) |
| `enabled` | `false` for a disabled one; only where the provider says (Azure) |
`lazysecrets open --action go --account <account> <link>` opens one in
lazysecrets without asking what to do or which account, ending in the
secrets list, like Go to.
### Example usage with fzf
```sh
lazysecrets list | fzf --ansi --delimiter='\t' --with-nth='2..-3' --nth='2..' --accept-nth=-1 \
lazysecrets list | fzf --ansi --with-nth='2..-3' --accept-nth=-1 \
--header 'enter: link · ctrl-o: open · ctrl-y: copy value · ctrl-x: copy and close' \
--bind 'ctrl-o:become(lazysecrets open --action go --account {-2} {-1})' \
--bind 'ctrl-y:execute-silent(lazysecrets get --account {-2} {-1} | pbcopy)+change-header(Copied the value of {3})' \
--bind 'ctrl-y:execute-silent(lazysecrets get --account {-2} {-1} | pbcopy)+change-header(Copied the value of {2..-3})' \
--bind 'ctrl-x:become(lazysecrets get --account {-2} {-1} | pbcopy)'
```
![fzf-example](assets/fzf-example.png)
`--with-nth='2..-3'` shows and searches only the names; `{-1}` and
`--accept-nth=-1` are the link, `{-2}` the account. All but the name are
one word, so this works without `--delimiter`. To filter by kind
yourself: `awk -F'\t' '$1 == "secret"'`.
`enter` prints the link (`lazysecrets get $(…)`), `ctrl-o` opens it in
lazysecrets, `ctrl-y` copies the value, saying so in the header, and
`ctrl-x` copies it and closes fzf (`pbcopy` on macOS; `wl-copy` or `xclip`
on Linux). A value copied this way isn't cleared from the
clipboard after `values.clipboardClear`, as one copied in lazysecrets is.
`--account` makes `open` and `get` use the account it was listed through,
when several of yours can see it: without, `open` asks which, and `get`
tries the one that last read it first. The link stays as it is, to copy
and share; the account isn't part of it. `--action` tells `open` what to
do rather than asking, as it does for a link clicked elsewhere: `go` to
it, `copy` its value too, or `show` it. It ends in the secrets list, like
Go to, rather than on the details as a clicked link does.
What's [cached](config.md#cache) prints at once. Then, like Go to, it loads
what's missing or older than `--max-age` (2m), and what's new in it follows
as it arrives: fzf shows it while you type. `--cached` loads nothing from
Azure or Databricks, for an answer at once. Repos are always read, from
disk. What it loads is cached for the TUI too. It stops when the reader
goes, after a pick in fzf.
`--kind` lists only some kinds, comma-separated: `source`, `vault` (also
Databricks scopes and repo files), `secret`, `key`, `certificate`. Lists
only other kinds need aren't loaded: `--kind source,vault` asks for no
secrets. Keys and certificates load only when asked for this way; else,
like Go to, they're listed as far as you opened them in lazysecrets.
A vault or source it can't read is a line too, as it fails: its name,
then `✗` and why in red (`errorColor`), with its link, so opening it
shows more. They're in the list, not on stderr, which would draw over fzf.
`--errors=false` leaves them out.
The name is coloured from your [theme](config.md#theme), with the
provider's icon when [icons](config.md#theme) are on (`ui.icons`); fzf
shows the colours with `--ansi`. `--color never` leaves them out, for
scripts, and `--icons=false` the icons. The link is never coloured.
`--format json` prints an object per line instead, for editor plugins,
without colours or icons, and with `error` on a list it couldn't read:
```json
{"link":"lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password","kind":"secret","provider":"azure","path":["platform-prod","kv-app-prod","db-password"],"account":"Azure:alice@example.com/<tenant>","accountLabel":"alice"}
```
`accountLabel`, the account's short name, is there when several az
accounts are signed in. Values are never listed.
+1 -1
View File
@@ -54,7 +54,7 @@ match case exactly.
| `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](sharing.md) to the selected item / share menu (also env files, [encrypted](sharing.md#sharing-env-files)) |
| `s` / `S` | copy a [link](links.md) to the selected item / share menu (also env files, [encrypted](links.md#sharing-env-files)) |
| `ctrl+o` | open a link or reference (or just paste one) |
| `x` | [custom commands](custom-commands.md) |
| `L` | sign in again (shown when a login has expired) |
+5 -5
View File
@@ -1,7 +1,7 @@
# Sharing
# Links
Subscriptions, workspaces, vaults, scopes, repos, files and secrets are
shared as a `lazysecrets://` link, in chat or a ticket. Whoever opens
Subscriptions, workspaces, vaults, scopes, repos, files and secrets can
be shared as a `lazysecrets://` link, in chat or a ticket. Whoever opens
it lands on that item, reading it with their own account and
permissions: the link names the item, never its value. Env files can
also be [shared encrypted](#sharing-env-files), in the link itself.
@@ -15,7 +15,7 @@ lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage
lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password
```
## Sharing a link
## Sharing
`s` copies a link to the selected item (from the versions view, pinned to
that version). `S` opens the share menu:
@@ -99,7 +99,7 @@ the value breaks every old link. Whoever has read the secret's value
before can decrypt links made with it without asking the vault, so rotate
it now and then: new links use the newest version.
## Opening a link
## Opening
- Paste it into lazysecrets, or press `ctrl+o`, which reads the clipboard
and fills in any reference it finds there. Key Vault URLs,
+3 -3
View File
@@ -58,7 +58,7 @@ providers:
```
A repo's `.lazysecrets.yaml` can also name the secret its env files are
[shared](sharing.md#sharing-env-files) with:
[shared](links.md#sharing-env-files) with:
```yaml
sharing:
@@ -100,7 +100,7 @@ sops 3.8 or later.
## Links
`S` → `f` shares env files as an [encrypted link](sharing.md#sharing-env-files).
`S` → `f` shares env files as an [encrypted link](links.md#sharing-env-files).
Links name the repo by its git remote, so they open in a colleague's clone
wherever it is:
@@ -109,4 +109,4 @@ wherever it is:
lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password
```
See [sharing](sharing.md).
See [links](links.md).
+14 -40
View File
@@ -32,10 +32,6 @@ type File struct {
Objects map[string]Stamped[[]provider.Object] `json:"objects,omitempty"`
Details map[string]Stamped[provider.Object] `json:"details,omitempty"`
Cursor map[string]string `json:"cursor,omitempty"`
// Dropped are keys deleted since the file was read (a list that
// failed to load): Save deletes them from the file too, rather than
// merging the old entry back. Not saved.
Dropped map[string]bool `json:"-"`
}
// Stamped is a list or detail with when it was loaded.
@@ -73,16 +69,6 @@ func ProviderOf(key string) string {
return id
}
// Keep is Save's keep for these providers: their lists are kept, but not
// repos', whose files are read from disk anyway, nor other accounts'.
func Keep(providers []provider.Provider) func(providerID string) bool {
keep := map[string]bool{}
for _, p := range providers {
keep[p.ID()] = p.Kind() != provider.KindRepo
}
return func(id string) bool { return keep[id] }
}
// Read reads path; a missing, unreadable or other version's file is an
// empty cache.
func Read(path string) File {
@@ -104,27 +90,17 @@ func Read(path string) File {
}
// Save writes f to path, merged with the file there: of a list in both,
// the one loaded last wins, and f's cursors win (a nil Cursor keeps the
// file's). Entries older than MaxAge, those of providers keep says no to
// (accounts signed out of), and f.Dropped are dropped, from the file's
// side as well. Saves from several lazysecrets take turns (a lock file),
// so none undoes another's.
// the one loaded last wins, and f's cursors win. Entries older than
// MaxAge, and those of providers keep says no to (accounts signed out
// of), are dropped, from the file's side as well.
func Save(path string, f File, keep func(providerID string) bool, now time.Time) error {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
unlock, err := lock(path + ".lock")
if err != nil {
return err
}
defer unlock()
out := New()
ok := func(key string, at time.Time) bool { return keep(ProviderOf(key)) && now.Sub(at) < MaxAge }
disk := Read(path)
merge(out.Sources, disk.Sources, f.Sources, ok, f.Dropped)
merge(out.Containers, disk.Containers, f.Containers, ok, f.Dropped)
merge(out.Objects, disk.Objects, f.Objects, ok, f.Dropped)
merge(out.Details, disk.Details, f.Details, ok, f.Dropped)
merge(out.Sources, disk.Sources, f.Sources, ok)
merge(out.Containers, disk.Containers, f.Containers, ok)
merge(out.Objects, disk.Objects, f.Objects, ok)
merge(out.Details, disk.Details, f.Details, ok)
for _, m := range []map[string]string{disk.Cursor, f.Cursor} {
for k, v := range m {
if keep(ProviderOf(k)) {
@@ -135,19 +111,14 @@ func Save(path string, f File, keep func(providerID string) bool, now time.Time)
return write(path, out)
}
// merge puts disk's entries in out, but not those dropped, then ours
// where they're newer.
func merge[T any](out, disk, ours map[string]Stamped[T], ok func(string, time.Time) bool, dropped map[string]bool) {
for k, e := range disk {
if ok(k, e.At) && !dropped[k] {
out[k] = e
}
}
for k, e := range ours {
func merge[T any](out, disk, ours map[string]Stamped[T], ok func(string, time.Time) bool) {
for _, m := range []map[string]Stamped[T]{disk, ours} {
for k, e := range m {
if old, seen := out[k]; ok(k, e.At) && (!seen || !e.At.Before(old.At)) {
out[k] = e
}
}
}
}
// write writes f atomically, readable by the user only.
@@ -156,6 +127,9 @@ func write(path string, f File) error {
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(path), ".cache-*.json")
if err != nil {
return err
-31
View File
@@ -89,34 +89,3 @@ func TestReadOtherVersion(t *testing.T) {
t.Errorf("read another version's file: %+v", got)
}
}
func TestSaveDroppedAndCursors(t *testing.T) {
path := filepath.Join(t.TempDir(), "cache.json")
now := time.Now()
all := func(string) bool { return true }
f := New()
f.Containers["az|bad"] = list(now, "kv-old")
f.Cursor["az|x"] = "kv-tui"
if err := Save(path, f, all, now); err != nil {
t.Fatal(err)
}
// The TUI fails to load it again and drops it: it stays gone.
f = New()
f.Dropped = map[string]bool{"az|bad": true}
if err := Save(path, f, all, now); err != nil {
t.Fatal(err)
}
if _, ok := Read(path).Containers["az|bad"]; ok {
t.Error("a dropped list came back")
}
// lazysecrets list saves without cursors: the TUI's stay.
listed := New()
listed.Cursor = nil
listed.Containers["az|y"] = list(now, "kv-y")
if err := Save(path, listed, all, now); err != nil {
t.Fatal(err)
}
if Read(path).Cursor["az|x"] != "kv-tui" {
t.Error("the TUI's cursor was lost")
}
}
-7
View File
@@ -1,7 +0,0 @@
//go:build !unix
package cachefile
// lock does nothing here: saves can then race, losing lists that load
// again.
func lock(string) (func(), error) { return func() {}, nil }
-25
View File
@@ -1,25 +0,0 @@
//go:build unix
package cachefile
import (
"os"
"syscall"
)
// lock takes path as a lock file, waiting while another lazysecrets has
// it, and returns how to let go.
func lock(path string) (unlock func(), err error) {
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return nil, err
}
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
f.Close()
return nil, err
}
return func() {
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
f.Close()
}, nil
}
-41
View File
@@ -1,41 +0,0 @@
// Package expiry says whether a secret, key or certificate has expired
// or will soon, and how long it has left, the same way everywhere: the
// TUI's lists and status pane, and lazysecrets list.
package expiry
import (
"fmt"
"time"
)
type State int
const (
None State = iota // no expiry, or not within the warning period
Soon // within the warning period
Expired // already
)
// Of tells whether something expiring at expires (zero: never) has
// expired by now or will within warn, and how long it has left.
func Of(expires, now time.Time, warn time.Duration) (State, time.Duration) {
if expires.IsZero() {
return None, 0
}
left := expires.Sub(now)
switch {
case left <= 0:
return Expired, 0
case left <= warn:
return Soon, left
}
return None, left
}
// Left is a short "12d", or "5h" on the last day.
func Left(d time.Duration) string {
if d < 24*time.Hour {
return fmt.Sprintf("%dh", max(1, int(d.Hours())))
}
return fmt.Sprintf("%dd", int(d.Hours()/24))
}
-27
View File
@@ -1,27 +0,0 @@
package expiry
import (
"testing"
"time"
)
func TestOf(t *testing.T) {
now := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC)
warn := 30 * 24 * time.Hour
for _, c := range []struct {
expires time.Time
want State
left string
}{
{time.Time{}, None, ""},
{now.Add(-time.Hour), Expired, ""},
{now.Add(12*24*time.Hour + time.Hour), Soon, "12d"},
{now.Add(5 * time.Hour), Soon, "5h"},
{now.Add(60 * 24 * time.Hour), None, ""},
} {
state, left := Of(c.expires, now, warn)
if state != c.want || (c.left != "" && Left(left) != c.left) {
t.Errorf("%v: %v %s, want %v %s", c.expires, state, Left(left), c.want, c.left)
}
}
}
+15 -39
View File
@@ -51,11 +51,6 @@ type Entry struct {
Enabled *bool `json:"enabled,omitempty"`
// Note is the user's note on it, if any.
Note string `json:"note,omitempty"`
// Reference is how config refers to a secret, where the provider has
// a way (@Microsoft.KeyVault(...), {{secrets/scope/key}}); URL its
// web page (portal, workspace), if any.
Reference string `json:"reference,omitempty"`
URL string `json:"url,omitempty"`
// Error says why a source's vaults or a vault's secrets couldn't be
// listed. The entry is then that source or vault, again, or with an
// account's sources unreadable, the account without a link.
@@ -74,37 +69,25 @@ type Options struct {
// Lists only needed for other kinds aren't loaded: sources and vaults
// alone don't load any secrets. Lists that fail are always listed.
Kinds []string
// Mark tells what's remembered about the entry with link, seen
// through account; nil marks nothing.
Mark func(link, account string) Marks
Timeout func(provider.Kind) time.Duration // nil: 30s
// Mark tells whether the entry with link is starred for account,
// when it was last used and how often (zero: not lately), and its
// note; nil marks nothing.
Mark func(link, account string) (starred bool, lastUsed time.Time, uses int, note string)
Timeout func(provider.Kind) time.Duration
Now func() time.Time
}
// Marks are what's remembered about an entry: starred for the account,
// used lately (Recent) and when and how often, and its note.
type Marks struct {
Starred, Recent bool
LastUsed time.Time
Uses int
Note string
}
// Run lists everything, calling emit for each entry once, from this
// goroutine, and for each list that can't be loaded, as it fails. An
// error from emit (the reader went away) stops it. It returns the cache
// with what was loaded, and whether anything was.
func Run(ctx context.Context, o Options, emit func(Entry) error) (cachefile.File, bool) {
// with what was loaded.
func Run(ctx context.Context, o Options, emit func(Entry) error) cachefile.File {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
l := &lister{o: o, emit: emit, seen: map[string]bool{}, pending: map[string]bool{}, failed: map[string]bool{},
results: make(chan result)}
l := &lister{o: o, emit: emit, seen: map[string]bool{}, pending: map[string]bool{}, results: make(chan result)}
if l.o.Now == nil {
l.o.Now = time.Now
}
if l.o.Timeout == nil {
l.o.Timeout = func(provider.Kind) time.Duration { return 30 * time.Second }
}
for _, p := range o.Providers {
if l.stopped != nil {
break
@@ -123,7 +106,7 @@ func Run(ctx context.Context, o Options, emit func(Entry) error) (cachefile.File
cancel()
}
}
return l.o.Cache, l.loaded
return l.o.Cache
}
type job struct {
@@ -147,8 +130,6 @@ type lister struct {
emit func(Entry) error
seen map[string]bool // provider ID + link of entries emitted
pending map[string]bool // cache keys queued or loading
failed map[string]bool // cache keys that couldn't be loaded: not again
loaded bool // something was cached
queue []job
active int
results chan result
@@ -226,12 +207,12 @@ func (l *lister) objects(p provider.Provider, c provider.Container, objs []provi
}
l.put(p, Entry{Link: r.String(), Kind: strings.ToLower(string(o.Type)), Provider: kindOf(c.Source.Kind),
Path: []string{c.Source.Name, c.Name, o.Name}, AccountLabel: accountOf(c.Source),
Expires: o.Expires, Enabled: o.Enabled, Reference: o.Reference, URL: o.URL})
Expires: o.Expires, Enabled: o.Enabled})
}
}
func sourceEntry(src provider.Source) Entry {
e := Entry{Kind: "source", Provider: kindOf(src.Kind), Path: []string{src.Name}, AccountLabel: accountOf(src), URL: src.URL}
e := Entry{Kind: "source", Provider: kindOf(src.Kind), Path: []string{src.Name}, AccountLabel: accountOf(src)}
if r, err := ref.ForSource(src); err == nil {
e.Link = r.String()
}
@@ -239,7 +220,7 @@ func sourceEntry(src provider.Source) Entry {
}
func containerEntry(c provider.Container) Entry {
e := Entry{Kind: "vault", Provider: kindOf(c.Source.Kind), Path: []string{c.Source.Name, c.Name}, AccountLabel: accountOf(c.Source), URL: c.URL}
e := Entry{Kind: "vault", Provider: kindOf(c.Source.Kind), Path: []string{c.Source.Name, c.Name}, AccountLabel: accountOf(c.Source)}
if r, err := ref.ForContainer(c, provider.TypeSecret); err == nil {
e.Link = r.String()
}
@@ -254,11 +235,8 @@ func (l *lister) put(p provider.Provider, e Entry) {
}
e.Account = p.ID()
if l.o.Mark != nil && e.Link != "" {
m := l.o.Mark(e.Link, e.Account)
e.Starred, e.Recent, e.Note = m.Starred, m.Recent, m.Note
if m.Recent {
e.LastUsed, e.Uses = m.LastUsed, m.Uses
}
e.Starred, e.LastUsed, e.Uses, e.Note = l.o.Mark(e.Link, e.Account)
e.Recent = !e.LastUsed.IsZero()
}
id := p.ID() + "\x00" + e.Link + "\x00" + e.Error
if l.stopped != nil || l.seen[id] {
@@ -282,7 +260,7 @@ func (l *lister) wants(kinds ...string) bool {
}
func (l *lister) enqueue(j job) {
if l.pending[j.key] || l.failed[j.key] {
if l.pending[j.key] {
return
}
l.pending[j.key] = true
@@ -318,11 +296,9 @@ func (l *lister) handle(r result) {
delete(l.pending, r.key)
now := l.o.Now()
if r.err != nil {
l.failed[r.key] = true
l.put(r.p, r.failed())
return
}
l.loaded = l.loaded || l.cacheable(r.p)
cache := l.cacheable(r.p)
switch {
case r.c != nil:
+4 -37
View File
@@ -42,7 +42,7 @@ func run(t *testing.T, o Options) ([]Entry, cachefile.File, []Entry) {
}
o.Timeout = func(provider.Kind) time.Duration { return 5 * time.Second }
var got, failed []Entry
f, _ := Run(context.Background(), o, func(e Entry) error {
f := Run(context.Background(), o, func(e Entry) error {
if e.Error != "" {
failed = append(failed, e)
} else {
@@ -87,11 +87,6 @@ func TestListLoadsAndCaches(t *testing.T) {
t.Errorf("provider %q", e.Provider)
}
}
for _, e := range got {
if e.Kind == "secret" && e.Provider == "azure" && (!strings.HasPrefix(e.Reference, "@Microsoft.KeyVault(") || e.URL == "") {
t.Errorf("%s: reference %q, url %q", e.Link, e.Reference, e.URL)
}
}
if len(f.Sources) != 1 || len(f.Containers) == 0 || len(f.Objects) == 0 {
t.Errorf("not cached: %d sources, %d containers, %d objects", len(f.Sources), len(f.Containers), len(f.Objects))
}
@@ -234,17 +229,14 @@ func TestListMarks(t *testing.T) {
az := fake.Azure(0)
var starredLink string
got, _, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute, Kinds: []string{"secret"},
Mark: func(link, account string) Marks {
Mark: func(link, account string) (bool, time.Time, int, string) {
if starredLink == "" && strings.HasSuffix(link, "/db-password") {
starredLink = link
}
if strings.HasSuffix(link, "/api-key") {
return Marks{Recent: true, LastUsed: time.Now(), Uses: 3, Note: "rotate in May"}
return false, time.Now(), 3, "rotate in May"
}
if strings.HasSuffix(link, "/legacy-token") { // used, but too long ago
return Marks{LastUsed: time.Now().AddDate(-2, 0, 0), Uses: 9}
}
return Marks{Starred: link == starredLink && account == az.ID()}
return link == starredLink && account == az.ID(), time.Time{}, 0, ""
}})
var starred, recent int
for _, e := range got {
@@ -270,28 +262,3 @@ func TestListMarks(t *testing.T) {
t.Errorf("%d starred, %d recent", starred, recent)
}
}
func TestFailedListNotRetried(t *testing.T) {
t.Parallel()
az := &counting{Provider: fake.Azure(0)}
_, f, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute})
// Everything stale: sources reload, and their vaults are queued again
// as each list arrives. kv-restricted fails once, and isn't asked again.
before := az.calls.Load()
_, _, failed := run(t, Options{Providers: []provider.Provider{az}, Cache: f, MaxAge: time.Minute,
Now: func() time.Time { return time.Now().Add(time.Hour) }})
if len(failed) != 1 {
t.Errorf("failed: %+v", failed)
}
fresh := &counting{Provider: fake.Azure(0)}
run(t, Options{Providers: []provider.Provider{fresh}, MaxAge: time.Minute})
if n := az.calls.Load() - before; n > fresh.calls.Load() {
t.Errorf("%d requests with a stale cache, more than %d from scratch: something was asked twice", n, fresh.calls.Load())
}
}
func TestRunWithoutTimeout(t *testing.T) {
t.Parallel()
Run(context.Background(), Options{Providers: []provider.Provider{fake.Repos()}, Cache: cachefile.New()},
func(Entry) error { return nil }) // no Timeout: a default, not a panic
}
+1 -18
View File
@@ -218,19 +218,6 @@ func (p *Provider) addContainer(src provider.Source, name, detail string, types
return c
}
// Expire sets when the object name in vault expires, for --demo: a date
// relative to today in the test data would change the tests' screenshots
// from day to day.
func (p *Provider) Expire(vault, name string, at time.Time) {
for _, objs := range p.objects {
for i := range objs {
if objs[i].Container.Name == vault && objs[i].Name == name {
objs[i].Expires = at
}
}
}
}
func (p *Provider) add(c provider.Container, t provider.ObjectType, o provider.Object, value string) provider.Object {
o.Container = c
o.Type = t
@@ -300,13 +287,9 @@ func Azure(delay time.Duration) *Provider {
Reference: secretRef("kv-app-prod", "api-key"),
}, "sk_live_51HxDEMO0000000000000000")
p.add(kv, provider.TypeSecret, provider.Object{
Name: "legacy-token", Version: "0c11de", Enabled: &yes, Updated: base.AddDate(-1, 0, 0),
Name: "legacy-token", Version: "0c11de", Enabled: &no, Updated: base.AddDate(-1, 0, 0),
Expires: base.AddDate(0, -3, 0), Reference: secretRef("kv-app-prod", "legacy-token"),
}, "deprecated")
p.add(kv, provider.TypeSecret, provider.Object{
Name: "smtp-password", Version: "7d2e90", Enabled: &no, Updated: base.AddDate(0, -2, 0),
Reference: secretRef("kv-app-prod", "smtp-password"),
}, "smtp-demo-password")
p.add(kv, provider.TypeKey, provider.Object{
Name: "signing-key", Version: "5e21b7", Enabled: &yes, Updated: base.AddDate(0, -3, 0),
+1 -5
View File
@@ -32,13 +32,9 @@ func Seed(st *store.Store, alice, admin string) {
path: []string{"platform-prod", "kv-breakglass"}, star: true, note: "incidents only: tell #ops when used"},
{link: azure + "kv-shared-prod.vault.azure.net/secrets/smtp-password", account: alice, source: "sub-prod",
path: []string{"platform-prod", "kv-shared-prod"}, uses: 2, ago: 26 * time.Hour},
{link: azure + "kv-app-prod.vault.azure.net/secrets/smtp-password", account: alice, source: "sub-prod",
path: []string{"platform-prod", "kv-app-prod"}, note: "migrated to Microsoft Graph sendMail; delete after Q4"},
{link: dbx + "app/storage-key", account: "Databricks", source: "dbx-prod",
path: []string{"prod", "app"}, star: true, uses: 3, ago: 5 * time.Hour},
// A subscription, for both accounts, and a repo's file.
{link: azure + "subscriptions/sub-prod", account: alice, source: "sub-prod",
name: "platform-prod", star: true},
// A subscription, and a repo's file.
{link: azure + "subscriptions/sub-prod", account: admin, source: "sub-prod",
name: "platform-prod", star: true},
{link: "lazysecrets://repo/github.com%2Facme%2Flazysecrets-demo/deploy%2Fprod%2Fsecrets.yaml", account: "Repos",
-7
View File
@@ -43,13 +43,6 @@ type Node struct {
// alone isn't.
func (n Node) keep() bool { return len(n.StarredBy) > 0 || n.Note != "" || n.Uses > 0 }
// UsedLately reports whether the item was used within the last 90 days
// (what Recent lists). Starred and noted records are kept longer, so a
// use alone doesn't say.
func (n Node) UsedLately(now time.Time) bool {
return !n.LastUsed.IsZero() && now.Sub(n.LastUsed) <= usageMaxAge
}
// StarredFor reports whether the item is starred for account.
func (n Node) StarredFor(account string) bool { return slices.Contains(n.StarredBy, account) }
-12
View File
@@ -119,15 +119,3 @@ func TestStarsPerAccount(t *testing.T) {
t.Errorf("unstar alice: %v", n.StarredBy)
}
}
func TestUsedLately(t *testing.T) {
now := time.Now()
for ago, want := range map[time.Duration]bool{time.Hour: true, 89 * 24 * time.Hour: true, 2 * 365 * 24 * time.Hour: false} {
if got := (Node{LastUsed: now.Add(-ago)}).UsedLately(now); got != want {
t.Errorf("used %v ago: %v", ago, got)
}
}
if (Node{}).UsedLately(now) {
t.Error("never used is lately")
}
}
+2 -5
View File
@@ -213,9 +213,6 @@ type Model struct {
statusW int // the status pane's width: the left column's, zoomed or not
changelog []panes.Field
cacheDirty bool
// cacheDropped are cache keys deleted since the last save, so saving
// deletes them from the file too.
cacheDropped map[string]bool
sourcesAt map[string]time.Time
placeDir string
savedPlace string // the link last saved as the place
@@ -691,7 +688,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if src, ok := m.selectedSource(); ok {
m.logErrOf(src.Name, msg.err)
}
uncache(&m, m.cache.containers, msg.key)
delete(m.cache.containers, msg.key)
m.containers.SetError(msg.err)
} else {
m.cache.containers[msg.key] = entry[[]provider.Container]{msg.items, m.now()}
@@ -712,7 +709,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if c, ok := m.containers.Selected(); ok {
m.logErrOf(c.Name, msg.err)
}
uncache(&m, m.cache.objects, msg.key)
delete(m.cache.objects, msg.key)
m.secrets.SetError(msg.err)
} else {
if msg.bound != nil {
+7 -7
View File
@@ -686,7 +686,7 @@ func TestFilter(t *testing.T) {
d.keys("/")
d.typeText("api-k")
d.keys("enter", "esc") // apply, then esc clears the applied filter
if d.m.secrets.Len() != 4 {
if d.m.secrets.Len() != 3 {
t.Errorf("esc should clear the applied filter, visible = %d", d.m.secrets.Len())
}
if _, _, s := d.selected(); s != "api-key" {
@@ -698,13 +698,13 @@ func TestMoveWhileFiltering(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("3", "/")
d.typeText("a") // all four secrets contain an a
if d.m.secrets.Len() != 4 {
d.typeText("a") // all three secrets contain an a
if d.m.secrets.Len() != 3 {
t.Fatalf("visible = %d", d.m.secrets.Len())
}
_, _, initial := d.selected() // kept from before filtering: the middle row
var seen []string
for _, k := range []rune{'j', 'j', 'j', 'k'} {
for _, k := range []rune{'j', 'j', 'k'} {
d.send(tea.KeyPressMsg{Code: k, Mod: tea.ModCtrl})
_, _, s := d.selected()
seen = append(seen, s)
@@ -712,9 +712,9 @@ func TestMoveWhileFiltering(t *testing.T) {
if !d.m.secrets.Filtering() {
t.Fatal("ctrl+j/k left filter mode")
}
// down to the bottom, stay there, back up one
if seen[0] == initial || seen[2] != seen[1] || seen[3] != seen[0] {
t.Errorf("from %q, ctrl+j ×3, ctrl+k selected %v", initial, seen)
// down to the bottom, stay there, back up to where it started
if seen[0] == initial || seen[1] != seen[0] || seen[2] != initial {
t.Errorf("from %q, ctrl+j, ctrl+j, ctrl+k selected %v", initial, seen)
}
if !strings.Contains(d.screen(), "ctrl+j/n down") {
t.Errorf("filter help missing ctrl+j:\n%s", d.screen())
+1 -14
View File
@@ -67,23 +67,10 @@ func (m *Model) refreshShown(before []provider.Object) tea.Cmd {
return nil
}
m.detail.Refresh(s)
uncache(m, m.cache.details, detailKey(s))
delete(m.cache.details, detailKey(s))
return m.describe(s)
}
// uncache deletes key from a cache, and, at the next save, from the file.
func uncache[T any](m *Model, c map[string]entry[T], key string) {
if _, ok := c[key]; !ok {
return
}
delete(c, key)
if m.cacheDropped == nil {
m.cacheDropped = map[string]bool{}
}
m.cacheDropped[key] = true
m.cacheDirty = true
}
// sameListing reports whether a and b list the same: what a list
// says of an object, not what describing it adds.
func sameListing(a, b provider.Object) bool {
+1 -2
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"slices"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
@@ -29,7 +28,7 @@ func (m Model) expiringItems() []expiringItem {
row := sourceRowKey(p.ID(), src.ID)
for _, c := range m.cache.containers[row].value {
for _, t := range c.ObjectTypes() {
for _, o := range m.cache.objects[cachefile.ObjectsKey(c, t)].value {
for _, o := range m.cache.objects[containerKey(c)+"|"+string(t)].value {
if state, _ := m.expiry(o); state != notExpiring {
out = append(out, expiringItem{o, row})
}
+22 -8
View File
@@ -1,10 +1,9 @@
package app
import (
"fmt"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
@@ -12,18 +11,28 @@ import (
// list once they have expired (✗ expired) or will within the configured
// warning period (⚠ 12d), and typing "expir" in the filter finds them.
type expiryState = expiry.State
type expiryState int
const (
notExpiring = expiry.None
expiresSoon = expiry.Soon
expired = expiry.Expired
notExpiring expiryState = iota
expiresSoon
expired
)
// expiry tells whether o has expired or will within the warning period,
// and how long it has left.
func (m Model) expiry(o provider.Object) (expiryState, time.Duration) {
return expiry.Of(o.Expires, m.now(), m.expiryWarning)
if o.Expires.IsZero() {
return notExpiring, 0
}
left := o.Expires.Sub(m.now())
switch {
case left <= 0:
return expired, 0
case left <= m.expiryWarning:
return expiresSoon, left
}
return notExpiring, left
}
// expiryWord is matched by the filter: "expired" or "expiring".
@@ -38,4 +47,9 @@ func (m Model) expiryWord(o provider.Object) string {
}
// daysLeft is a short "12d", or "5h" on the last day.
func daysLeft(d time.Duration) string { return expiry.Left(d) }
func daysLeft(d time.Duration) string {
if d < 24*time.Hour {
return fmt.Sprintf("%dh", max(1, int(d.Hours())))
}
return fmt.Sprintf("%dd", int(d.Hours()/24))
}
+5 -7
View File
@@ -3,11 +3,9 @@ package app
import (
"slices"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store"
@@ -26,12 +24,12 @@ const recentMark = "◷ "
// marks is what goes before a list row's name: ★ when it's starred for
// account and, with recent, ◷ when it was used lately.
func marks(st styles.Styles, n store.Node, account string, recent bool, now time.Time) string {
func marks(st styles.Styles, n store.Node, account string, recent bool) string {
var s string
if n.StarredFor(account) {
s = st.Favourite.Render(star)
}
if recent && n.UsedLately(now) {
if recent && !n.LastUsed.IsZero() {
s += st.Muted.Render(recentMark)
}
return s
@@ -112,7 +110,7 @@ func (m Model) containerRender(withSource bool) func(styles.Styles, provider.Con
}
line += sty.Muted.Render(" " + c.Detail)
}
line = marks(sty, n, c.Source.Provider, m.recentIcon, m.now()) + line
line = marks(sty, n, c.Source.Provider, m.recentIcon) + line
if withSource {
src := c.Source.Name
if account, ok := strings.CutPrefix(c.Source.ProviderLabel, string(c.Source.Kind)+" · "); ok {
@@ -132,7 +130,7 @@ func (m Model) objectRender() func(styles.Styles, provider.Object, int) string {
if o.Enabled != nil && !*o.Enabled {
line = sty.Muted.Render(o.Name + " (disabled)")
}
line = marks(sty, n, o.Container.Source.Provider, m.recentIcon, m.now()) + line
line = marks(sty, n, o.Container.Source.Provider, m.recentIcon) + line
switch state, left := m.expiry(o); state {
case expired:
line += " " + sty.Error.Render("✗ expired")
@@ -181,7 +179,7 @@ func (m *Model) rebind(c provider.Container) string {
m.linked[i].Source = c.Source
}
}
key := cachefile.ObjectsKey(c, m.objType)
key := containerKey(c) + "|" + string(m.objType)
if m.onLinks() {
m.containers.SetItems(m.linked)
m.containers.Select(containerKey(c))
+1 -2
View File
@@ -8,7 +8,6 @@ import (
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
)
@@ -161,7 +160,7 @@ func (m Model) containerFields(c provider.Container) []panes.Field {
add("source", cmpOr(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
add("web", c.URL)
for _, t := range c.ObjectTypes() {
if e, ok := m.cache.objects[cachefile.ObjectsKey(c, t)]; ok {
if e, ok := m.cache.objects[containerKey(c)+"|"+string(t)]; ok {
add(strings.ToLower(string(t))+"s", fmt.Sprint(len(e.value)))
}
}
+1 -1
View File
@@ -107,7 +107,7 @@ func (m *Model) storeEntries() (favs, recent []option) {
if m.history {
var used []string
for _, k := range keys {
if nodes[k].UsedLately(m.now()) {
if !nodes[k].LastUsed.IsZero() {
used = append(used, k)
}
}
+9 -3
View File
@@ -33,7 +33,14 @@ type persistedMsg struct{ err error }
// isn't a repo: every key starts with the provider ID.
func (m *Model) keepCached(key string) bool { return m.keepProvider(cachefile.ProviderOf(key)) }
func (m *Model) keepProvider(id string) bool { return cachefile.Keep(m.providers)(id) }
func (m *Model) keepProvider(id string) bool { return keepIn(m.byID)(id) }
func keepIn(byID map[string]provider.Provider) func(id string) bool {
return func(id string) bool {
p, ok := byID[id]
return ok && p.Kind() != provider.KindRepo
}
}
// loadCacheFile fills the cache from the file, leaving out what's too
// old or belongs to accounts that aren't signed in any more.
@@ -72,7 +79,6 @@ func (m *Model) loadCacheFile() {
// cacheSnapshot is the cache as the file holds it.
func (m *Model) cacheSnapshot() cachefile.File {
f := cachefile.New()
f.Dropped, m.cacheDropped = m.cacheDropped, nil
for id, r := range m.sourceResults {
if r.err == nil && m.keepProvider(id) {
f.Sources[id] = cachefile.Stamped[[]provider.Source]{At: m.sourcesAt[id], Value: r.sources}
@@ -104,7 +110,7 @@ func (m *Model) cacheSnapshot() cachefile.File {
// saveCache saves f, merged with the file (lazysecrets list may have
// loaded more since), from any goroutine.
func (m *Model) saveCache(f cachefile.File) func() error {
path, keep, now := m.cachePath, cachefile.Keep(m.providers), m.now()
path, keep, now := m.cachePath, keepIn(m.byID), m.now()
return func() error { return cachefile.Save(path, f, keep, now) }
}
+6 -7
View File
@@ -10,7 +10,6 @@ import (
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
)
@@ -90,7 +89,7 @@ func (m *Model) searchEntries() (sources, items []option) {
return nil
}, reload)
for _, t := range []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} {
for _, o := range m.cache.objects[cachefile.ObjectsKey(c, t)].value {
for _, o := range m.cache.objects[containerKey(c)+"|"+string(t)].value {
r, err := ref.ForObject(o, "")
if err != nil {
continue
@@ -167,7 +166,7 @@ func (m *Model) coverageOf(in providerFilter) coverage {
}
for _, v := range e.value {
c.vaults++
key := cachefile.ObjectsKey(v, provider.TypeSecret)
key := containerKey(v) + "|" + string(provider.TypeSecret)
if objs, ok := m.cache.objects[key]; ok {
c.vaultsLoaded++
c.secrets += len(objs.value)
@@ -264,7 +263,7 @@ func (m *Model) queueSource(src provider.Source, force bool) {
// queueContainer queues loading c's secrets, unless loaded lately and not
// force.
func (m *Model) queueContainer(c provider.Container, row string, force bool) {
key := cachefile.ObjectsKey(c, provider.TypeSecret)
key := containerKey(c) + "|" + string(provider.TypeSecret)
if e, ok := m.cache.objects[key]; ok && e.fresh(m.now()) && !force {
return
}
@@ -328,7 +327,7 @@ func (m *Model) handleCrawl(msg crawlMsg) tea.Cmd {
m.crawl.active--
key := msg.job.row
if msg.job.container != nil {
key = cachefile.ObjectsKey(*msg.job.container, provider.TypeSecret)
key = containerKey(*msg.job.container) + "|" + string(provider.TypeSecret)
}
delete(m.crawl.pending, key)
switch {
@@ -366,7 +365,7 @@ func (m *Model) sourceMark(row string) string {
}
loaded, loading := 0, false
for _, c := range e.value {
key := cachefile.ObjectsKey(c, provider.TypeSecret)
key := containerKey(c) + "|" + string(provider.TypeSecret)
loading = loading || m.crawl.pending[key]
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
loaded++
@@ -387,7 +386,7 @@ func (m *Model) sourceMark(row string) string {
// (✗) or are loading (◌): whether a subscription is loaded says whether
// its vaults are searchable.
func (m *Model) vaultMark(c provider.Container) string {
key := cachefile.ObjectsKey(c, provider.TypeSecret)
key := containerKey(c) + "|" + string(provider.TypeSecret)
switch {
case m.crawl.pending[key]:
return m.st.Muted.Render("◌")
+1 -2
View File
@@ -12,7 +12,6 @@ import (
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/match"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
@@ -618,7 +617,7 @@ func (m Model) loadHint() string {
}
loaded := 0
for _, c := range e.value {
key := cachefile.ObjectsKey(c, provider.TypeSecret)
key := containerKey(c) + "|" + string(provider.TypeSecret)
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
loaded++
}
+1 -2
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"slices"
"strings"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
@@ -81,7 +80,7 @@ func newSourcesList(iconSet icons.Set, mem *store.Store, recent bool) panes.List
kind := st.Kind(r.group)
starred := ""
if r.kind == rowSource {
starred = marks(st, node, r.source.Provider, recent, time.Now())
starred = marks(st, node, r.source.Provider, recent)
}
switch {
case r.kind == rowLinks:
+3 -3
View File
@@ -20,8 +20,7 @@
╭─ [3] Secrets - Keys - Certs ──╮│ │
│ db-password ││ │
│ api-key ││ │
│ legacy-token ✗ expired ││ │
│ smtp-password (disabled) ││ │
│ legacy-token (disabled) ✗ exp…││ │
│ ││ │
│ ││ │
│ ││ │
@@ -29,5 +28,6 @@
│ ││ │
│ ││ │
│ ││ │
╰────────────────────── 1 of 4 ─╯╰──────────────────────────────────────────────────────── 1 of 4 ─╯
│ ││ │
╰────────────────────── 1 of 3 ─╯╰──────────────────────────────────────────────────────── 1 of 4 ─╯
enter open • ctrl+s search • s copy link • S share • f star • F favourites • … • ? help • q quit
-2
View File
@@ -11,7 +11,6 @@ type Set struct {
Repo string
Encrypted string // an encrypted file (SOPS)
PlainFile string // a plain text secrets file (.env)
Note string // has a note (lazysecrets list)
}
// None renders no icons.
@@ -25,7 +24,6 @@ var NerdFont = Set{
Repo: "\U000F02A2", // md-git
Encrypted: "\U000F033E", // md-lock
PlainFile: "\U000F0214", // md-file
Note: "\U000F09ED",
}
// Prefix returns the icon for kind followed by a space, or "" if the set has