4 Commits
Author SHA1 Message Date
keb 2d86af2c18 refactor: drop dead code and use the standard library where it already does the job
CI / Checks (pull_request) Successful in 37s
PR title / Conventional title (pull_request) Successful in 1s
No behaviour change.

- Deleted, unused or one-line wrappers: panes.RuneIndexes, Detail.Refresh (SetDescribed does it), SecretValue.Len, Query.Empty, Clipboard.Native, config.Load, get's value, Model.done, providerOf (cachefile.ProviderOf), contextWithTimeout, searchCoverage, startCrawl, showKeybindings, the expiry aliases and daysLeft.
- cmp.Or instead of the two cmpOr copies; slices.Sorted(maps.Keys(…)), maps.Copy, wg.Go, utf8.RuneCountInString and new(true) instead of loops and helpers that do the same.
- A container's pane title is set once, not twice; a redundant length check in share links and a no-op assignment in linkhandler.Launch are gone.
- Tests call ansi.Strip and lastLines directly; gofmt -s on links.go.
2026-10-15 10:38:57 +02:00
keb 1753630c73 chore: tidy comments that repeat the code or no longer match it (#72)
- Comments that only restate a name (sgr, fg, mdLink, the repo provider's starred and current-repo notes) are gone.
- Stale ones match the code again: the repo package covers plain env files too, Launch runs argv, closeVersions no longer claims to move the cursor, and Check's fallback says why rather than naming a state no release had.
- The doc for versionString was attached to pseudo; the backtick in SourcesView's doc was a string-concatenation leftover; openFailed's doc was garbled.
- recentReleases sits next to latest, its user.
- .gitignore drops the Go template's boilerplate and patterns for files this project doesn't make.

Reviewed-on: #72
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-15 10:38:56 +02:00
keb b773dfe296 docs: fix what no longer matches lazysecrets, and say each thing once (#74)
Fixes:
- A newer release shows in the status pane (`↑ v0.2.0`), not the footer.
- The account in `lazysecrets list` is plain `Azure` with one az account; `Azure:<user>/<tenant>` only with several.
- Sharing lists subscription, workspace and scope links, and opening takes Azure portal links.
- `--timeout` and `--clipboard-clear` are documented, next to their config keys.
- `--kind`'s description reads, and says `vault` covers scopes and repo files.
- `lazysecrets -h` lists `list`'s `--stars`, `--notes`, `--recent` and `--expiry`.
- README typos; "Supported platforms" is "Sources".

Trimmed:
- An option is documented on its topic's page; config.md's example keeps the general ones and links the rest.
- The fzf picker lives in docs/integration.md; the README keeps a link and the screenshot.
- The clipboard clear, reveal timeout, per-account stars, Azure's "no extra requests" and `open --action` are each said once.
- The status pane's contents are a list; build sizes and PR-title spellings are gone from development.md.

Reviewed-on: #74
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-15 10:38:14 +02:00
keb bb41767cba fix: a link opens lazysecrets with your cache and next to your running instances (#71)
The link handler's window starts with an app's minimal environment, and only PATH, the config locations and XDG_CONFIG_HOME/XDG_STATE_HOME were carried into it. With XDG_CACHE_HOME or XDG_RUNTIME_DIR set, lazysecrets opened from a link used another cache file and didn't see running instances' sockets; on Linux, without XDG_DATA_HOME it looked for its desktop entry in the wrong place. `handler install` now records those three too: run it again to pick them up.

Reviewed-on: #71
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-15 10:37:14 +02:00
56 changed files with 256 additions and 462 deletions

No files matched your search

+8 -28
View File
@@ -1,33 +1,13 @@
# ---> Go
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
go.work.sum
# env file
.env
# Build output # Build output
/lazysecrets /lazysecrets
# Release builds (GoReleaser, see .goreleaser.yaml) # Release builds (GoReleaser, see .goreleaser.yaml)
/dist/ /dist/
# go test -c, coverage profiles
*.test
*.out
go.work
go.work.sum
.env
+9 -21
View File
@@ -7,24 +7,23 @@ A terminal UI for finding, reading and sharing secrets. Inspired by the fantasti
## Main features ## Main features
- **Read-only**: lazysecrets never creates, changes or deletes a secret. - **Read-only**: lazysecrets never creates, changes or deletes a secret.
That's by design and may stay that way.
- **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
fzf-style matching. fzf-style matching.
- **Share links**: Share `lazysecrets://` links with colleagues. - **Share links**: Share `lazysecrets://` links with colleagues.
- **Neovim integration**: via [lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim)
- **Careful with values**: values stay hidden until you reveal them, and - **Careful with values**: values stay hidden until you reveal them, and
copied values are cleared from the clipboard after 30 seconds. copied values are cleared from the clipboard after 30 seconds.
- **Scriptable**: `lazysecrets get <link>` prints a value for scripts and - **Scriptable**: `get`, `list` and `open` for scripts, fzf and editor
pipes. pickers; [lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim)
for Neovim.
- **Favourites**: star the secrets you use most for easy access. - **Favourites**: star the secrets you use most for easy access.
- **Expiry warnings**: secrets, keys and certificates that expire soon are - **Expiry warnings**: secrets, keys and certificates that expire soon are
marked, listed together in the status pane. marked, listed together in the status pane.
- **Your way**: rebind any key, pick a theme, and add custom commands to run - **Your way**: rebind any key, pick a theme, and add custom commands to run
on a secret. on a secret.
- **Try it first**: `lazysecrets --demo` runs on built-in sample data. - **Try it first**: `lazysecrets --demo` runs on built-in sample data.
- **Supported platforms**: Azure Key Vault, Databricks secret scopes, and SOPS and `.env` files in your git repos. - **Sources**: Azure Key Vault, Databricks secret scopes, and SOPS and `.env` files in your git repos.
> **Early development:** expect rough edges. See [CHANGELOG.md](CHANGELOG.md) for what's new. > **Early development:** expect rough edges. See [CHANGELOG.md](CHANGELOG.md) for what's new.
@@ -45,9 +44,8 @@ Both approaches will install the latest release from [GitHub](https://github.com
`sh -s -- v0.2.0` installs a given version, `LAZYSECRETS_INSTALL_DIR` `sh -s -- v0.2.0` installs a given version, `LAZYSECRETS_INSTALL_DIR`
somewhere else. somewhere else.
lazysecrets looks for a newer release once a day: the footer then shows `U` (or `lazysecrets update`) updates in place when there's a newer
`↑ v0.2.0`, and `U` shows its changelog and updates in place (or run release.
`lazysecrets update`).
Or build it yourself: Or build it yourself:
`go install git.kebcloud.no/keb/lazysecrets/cmd/lazysecrets@latest`. `go install git.kebcloud.no/keb/lazysecrets/cmd/lazysecrets@latest`.
@@ -70,30 +68,20 @@ use most:
| `l` / `h` | the details of what's selected / back | | `l` / `h` | the details of what's selected / back |
| `/` | filter the list you're in | | `/` | filter the list you're in |
| `space` | reveal / hide the value | | `space` | reveal / hide the value |
| `y` | copy the value (cleared from the clipboard after 30s) | | `y` | copy the value |
| `ctrl+s` | Go to: search everything and jump there | | `ctrl+s` | Go to: search everything and jump there |
| `f` / `F` | star / your favourites | | `f` / `F` | star / your favourites |
| `s` | copy a link to share | | `s` | copy a link to share |
| `?` | every key, searchable | | `?` | every key, searchable |
| `q` | quit | | `q` | quit |
## Integration ## Integration
Lazysecret let you integrate it into your workflows by `lazysecrets` `list`/`get`/`open`
Here is an example of implementing a picker in `fzf`: `lazysecrets list`, `get` and `open` plug lazysecrets into fzf, editor
```sh pickers and scripts: see [Integration](docs/integration.md#an-fzf-picker).
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) ![fzf-example](docs/assets/fzf-example.png)
More defails in the [Integration](docs/integration.md) docs
## Docs ## Docs
- [Keys](docs/keys.md): every key, Go to, the layout, and rebinding keys - [Keys](docs/keys.md): every key, Go to, the layout, and rebinding keys
+4 -10
View File
@@ -18,7 +18,7 @@ import (
// It ends with a newline only when printing to a terminal, so command // It ends with a newline only when printing to a terminal, so command
// substitution and pipes get the value exactly. // substitution and pipes get the value exactly.
func get(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) error { func get(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) error {
v, err := value(ctx, providers, st, r) v, _, err := readKey(ctx, providers, st, r)
if err != nil { if err != nil {
return err return err
} }
@@ -30,15 +30,9 @@ func get(ctx context.Context, providers []provider.Provider, st *store.Store, r
return err return err
} }
// value reads what r points at (ref.Read), with the account that last // readKey reads what r points at (ref.Read), and the version read. The
// read it first. // account asked for (--account) goes first, else the one that last read
func value(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, error) { // it.
v, _, err := readKey(ctx, providers, st, r)
return v, err
}
// readKey is value, also returning the version read. The account asked
// for (--account) goes first, else the one that last read it.
func readKey(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, string, error) { func readKey(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, string, error) {
key := r key := r
key.Version = "" key.Version = ""
+2 -2
View File
@@ -20,7 +20,7 @@ func TestValue(t *testing.T) {
"lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage-key": "storage-demo-key", "lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage-key": "storage-demo-key",
} { } {
r, _ := ref.Parse(link) r, _ := ref.Parse(link)
v, err := value(context.Background(), providers, st, r) v, _, err := readKey(context.Background(), providers, st, r)
if err != nil || v.Reveal() != want { if err != nil || v.Reveal() != want {
t.Errorf("%s: %q, %v", link, v.Reveal(), err) t.Errorf("%s: %q, %v", link, v.Reveal(), err)
} }
@@ -31,7 +31,7 @@ func TestValue(t *testing.T) {
"{{secrets/app/storage-key}}": "names no workspace", "{{secrets/app/storage-key}}": "names no workspace",
} { } {
r, _ := ref.Parse(link) r, _ := ref.Parse(link)
if _, err := value(context.Background(), providers, st, r); err == nil || !strings.Contains(err.Error(), wantErr) { if _, _, err := readKey(context.Background(), providers, st, r); err == nil || !strings.Contains(err.Error(), wantErr) {
t.Errorf("%s: %v, want %q", link, err, wantErr) t.Errorf("%s: %v, want %q", link, err, wantErr)
} }
} }
+3 -9
View File
@@ -68,27 +68,20 @@ func checkCommandKeys(km *keys.Map, cmds []commands.Command) error {
if c.Key == "" { if c.Key == "" {
continue continue
} }
name := fmt.Sprintf("custom command %q", cmpOr(c.Description, c.Command)) name := fmt.Sprintf("custom command %q", cmp.Or(c.Description, c.Command))
if err := km.CheckExtra(c.Key, name); err != nil { if err := km.CheckExtra(c.Key, name); err != nil {
return err return err
} }
for _, d := range cmds[:i] { for _, d := range cmds[:i] {
overlap := c.Context == d.Context || c.Context == commands.Global || d.Context == commands.Global overlap := c.Context == d.Context || c.Context == commands.Global || d.Context == commands.Global
if d.Key == c.Key && overlap { if d.Key == c.Key && overlap {
return fmt.Errorf("%s: %q is also the key of custom command %q", name, c.Key, cmpOr(d.Description, d.Command)) return fmt.Errorf("%s: %q is also the key of custom command %q", name, c.Key, cmp.Or(d.Description, d.Command))
} }
} }
} }
return nil return nil
} }
func cmpOr(a, b string) string {
if a != "" {
return a
}
return b
}
// refArg parses get's and open's arguments: a reference, and optionally // refArg parses get's and open's arguments: a reference, and optionally
// the account to use when several could (lazysecrets list's column) and, // the account to use when several could (lazysecrets list's column) and,
// for open, what to do with it rather than asking (--action). // for open, what to do with it rather than asking (--action).
@@ -150,6 +143,7 @@ func run() error {
for scripts: VAR=$(lazysecrets get <ref>) for scripts: VAR=$(lazysecrets get <ref>)
lazysecrets [flags] list [--kind secret,…] [--cached] [--max-age 2m] [--format text|json] lazysecrets [flags] list [--kind secret,…] [--cached] [--max-age 2m] [--format text|json]
[--color always|never] [--icons=false] [--errors=false] [--color always|never] [--icons=false] [--errors=false]
[--stars=false] [--notes=false] [--recent] [--expiry=false]
print every source, vault and secret it can print every source, vault and secret it can
find, one per line, for fzf and pickers: find, one per line, for fzf and pickers:
what's cached at once, then what loads what's cached at once, then what loads
+3 -3
View File
@@ -11,13 +11,13 @@ import (
// go build -ldflags "-X main.version=1.2.3" ./cmd/lazysecrets // go build -ldflags "-X main.version=1.2.3" ./cmd/lazysecrets
var version string var version string
// versionString is the release version, or for other builds what Go
// recorded: the module version (go install …@v1.2.3), else the commit
// (dev-c1a4f90, -dirty with changes not committed).
// pseudo matches a Go pseudo-version (v0.1.3-0.20261014174204-1c31a75d0507, // pseudo matches a Go pseudo-version (v0.1.3-0.20261014174204-1c31a75d0507,
// maybe +dirty): its commit. // maybe +dirty): its commit.
var pseudo = regexp.MustCompile(`[-.]\d{14}-([0-9a-f]{12})(\+.*)?$`) var pseudo = regexp.MustCompile(`[-.]\d{14}-([0-9a-f]{12})(\+.*)?$`)
// versionString is the release version, or for other builds what Go
// recorded: the module version (go install …@v1.2.3), else the commit
// (dev-c1a4f90, -dirty with changes not committed).
func versionString() string { func versionString() string {
if version != "" { if version != "" {
return version return version
+3 -4
View File
@@ -16,7 +16,7 @@ A service principal is used instead when `AZURE_CLIENT_ID`,
On an account's group row, the fourth pane shows who it signs in as: name, On an account's group row, the fourth pane shows who it signs in as: name,
user, tenant, object ID, whether the session used MFA, directory roles user, tenant, object ID, whether the session used MFA, directory roles
(built-in ones by name) and its subscriptions, read from az's account list (built-in ones by name) and its subscriptions, read from az's account list
and the token lazysecrets already has: no requests of their own. `y` on and the token lazysecrets already has. `y` on
the row copies the object ID, the one access policies and role the row copies the object ID, the one access policies and role
assignments ask for; `l` goes into the pane, where `j`/`k` pick any assignments ask for; `l` goes into the pane, where `j`/`k` pick any
field, `/` filters them (`/tenant`) and `y` copies the selected one. field, `/` filters them (`/tenant`) and `y` copies the selected one.
@@ -67,9 +67,8 @@ fetched. `v` lists an item's versions. Items expiring within
The status pane (`0`) lists them across every vault loaded, opened or The status pane (`0`) lists them across every vault loaded, opened or
cached, soonest first, and its line counts them (`⚠ 3 expiring`); `enter` cached, soonest first, and its line counts them (`⚠ 3 expiring`); `enter`
on one goes to it. That makes no requests of its own: expiry dates come on one goes to it. Expiry dates come with the lists. It says how many
with the lists. It says how many vaults it covers; `ctrl+a` in search vaults it covers; `ctrl+a` in search loads the rest.
loads the rest.
On a vault, the right pane shows its settings: resource group, location, On a vault, the right pane shows its settings: resource group, location,
SKU, whether access is by Azure roles or access policies, soft delete and SKU, whether access is by Azure roles or access policies, soft delete and
+21 -38
View File
@@ -1,10 +1,11 @@
# Config # Config
Optional, at `~/.config/lazysecrets/config.yaml` (or Optional, at `~/.config/lazysecrets/config.yaml` (or
`$XDG_CONFIG_HOME/lazysecrets/config.yaml`, or `--config`). Flags override `$XDG_CONFIG_HOME/lazysecrets/config.yaml`, or `--config`). Flags (noted
it. Unknown keys are an error, so typos don't go unnoticed. `e` on the below) override it. Unknown keys are an error, so typos don't go
status pane (`0`) opens it in your editor, as in lazygit (making it if unnoticed. `e` on the status pane (`0`) opens it in your editor, as in
it isn't there yet); changes apply when lazysecrets starts again. lazygit (making it if it isn't there yet); changes apply when lazysecrets
starts again.
```yaml ```yaml
ui: ui:
@@ -17,7 +18,7 @@ ui:
values: values:
revealTimeout: 60s # hide a revealed value after this long; 0s keeps it shown revealTimeout: 60s # hide a revealed value after this long; 0s keeps it shown
hideOnBlur: true # hide a revealed value when the terminal loses focus (tmux: focus-events on) hideOnBlur: true # hide a revealed value when the terminal loses focus (tmux: focus-events on)
clipboardClear: 30s # clear copied values from the clipboard after this long; 0s disables clipboardClear: 30s # clear copied values from the clipboard after this long; 0s disables; also --clipboard-clear
storage: storage:
cache: true # keep what was loaded (never values) for the next start cache: true # keep what was loaded (never values) for the next start
history: true # count uses of items for Recent (R) history: true # count uses of items for Recent (R)
@@ -28,32 +29,15 @@ updates:
- https://github.com/karleinarb/lazysecrets - https://github.com/karleinarb/lazysecrets
- https://git.kebcloud.no/keb/lazysecrets - https://git.kebcloud.no/keb/lazysecrets
providers: providers:
timeout: 30s # per backend request; a provider's own timeout overrides it timeout: 30s # per backend request; a provider's own timeout overrides it; also --timeout
azure: # applies to every az account azure: # likewise databricks and repos; azure covers every az account
enabled: true # default enabled: true # default
timeout: 30s timeout: 30s
hideSubscriptions: [platform-mgmt] # by name or ID
vaults: # listed under "Pinned vaults", for vaults
- https://kv-foo.vault.azure.net/ # you can read but not discover
databricks:
enabled: true
timeout: 30s
hideProfiles: [test]
repos:
enabled: true
timeout: 30s
roots: [~/git]
paths: [~/work/infra]
include: ["secrets/**"]
exclude: ["**/testdata/**"]
sharing: # sharing env files as encrypted links
key: https://kv-team.vault.azure.net/secrets/env-share # or its lazysecrets:// link; for repos naming none
keyRules: # what a key's value must be
minLength: 32 # characters, 16 at least
minClasses: 2 # kinds: lowercase, uppercase, digits, symbols
``` ```
More, each on its own page: [repos](repos.md) (`providers.repos`), More, each on its own page: [Azure](azure.md#vaults-you-cant-discover)
(`providers.azure`), [Databricks](databricks.md) (`providers.databricks`),
[repos](repos.md#which-files) (`providers.repos`),
[sharing env files](sharing.md#sharing-env-files) (`sharing`), the [sharing env files](sharing.md#sharing-env-files) (`sharing`), the
[link handler](sharing.md#from-the-browser-and-chat) (`linkHandler`), [link handler](sharing.md#from-the-browser-and-chat) (`linkHandler`),
[keybindings](keys.md#rebinding-keys) (`keybinding`) and [keybindings](keys.md#rebinding-keys) (`keybinding`) and
@@ -102,9 +86,8 @@ as boxes, turn them off with `ui.icons: false`.
Several config files can be layered, later ones winning (theme roles and Several config files can be layered, later ones winning (theme roles and
keybindings merge one by one), with a comma-separated `--config` or keybindings merge one by one), with a comma-separated `--config` or
`LAZYSECRETS_CONFIG_FILE`, like lazygit's `LG_CONFIG_FILE`. An editor `LAZYSECRETS_CONFIG_FILE`, like lazygit's `LG_CONFIG_FILE`; e.g. your
plugin can so add a generated theme file to your own config, the way config plus an editor-generated theme:
snacks.nvim themes lazygit from Neovim's highlight groups:
```sh ```sh
LAZYSECRETS_CONFIG_FILE=~/.config/lazysecrets/config.yaml,~/.cache/nvim/lazysecrets-theme.yml lazysecrets LAZYSECRETS_CONFIG_FILE=~/.config/lazysecrets/config.yaml,~/.cache/nvim/lazysecrets-theme.yml lazysecrets
@@ -117,11 +100,10 @@ Stars, notes, usage and the layout are kept in
by you: one record per source, vault or secret, keyed by its by you: one record per source, vault or secret, keyed by its
`lazysecrets://` link, never with values. A star belongs to the account you `lazysecrets://` link, never with values. A star belongs to the account you
starred the item under (a vault two of your accounts can see can be starred starred the item under (a vault two of your accounts can see can be starred
for one); a note describes the item, so it shows under every account. It's for one); a note describes the item, so it shows under every account.
JSON so an editor plugin can read it with a built-in parser; several Several lazysecrets windows can save to it without losing each other's
lazysecrets windows can save to it without losing each other's changes. changes. One written by a newer lazysecrets is read but not saved to,
One written by a newer lazysecrets is read but not saved to, with a with a warning, until you update.
warning, until you update.
Revealing, copying, opening in the browser, sharing and jumping to an item Revealing, copying, opening in the browser, sharing and jumping to an item
count as using it: its record keeps when it was last used and how often, count as using it: its record keeps when it was last used and how often,
@@ -157,8 +139,9 @@ until then it reuses the answer from the state file. Releases are
published on GitHub and on git.kebcloud.no, and it asks them in that published on GitHub and on git.kebcloud.no, and it asks them in that
order, the next when one doesn't answer within 5 seconds; order, the next when one doesn't answer within 5 seconds;
`updates.sources` lists others, such as a mirror of your own. Offline, or with `updates.sources` lists others, such as a mirror of your own. Offline, or with
every server down, it says nothing. A newer release shows in the footer, `v0.1.1 ↑ v0.2.0`; `U` (or every server down, it says nothing. A newer release shows in the status
`lazysecrets update --check`) shows what changed since your version, across the last 10 releases. pane, `↑ v0.2.0`; `U` (or `lazysecrets update --check`) shows what
changed since your version, across the last 10 releases.
Updating (`u` there, or `lazysecrets update`) downloads the release for Updating (`u` there, or `lazysecrets update`) downloads the release for
your OS and CPU from the first server that has it, checks it against your OS and CPU from the first server that has it, checks it against
+3 -5
View File
@@ -12,7 +12,7 @@ repo, pick `kv-shared-prod / env-share` (the second account's) as the
key, then paste the link back. The demo repos aren't on disk, so opening key, then paste the link back. The demo repos aren't on disk, so opening
asks where to save, as for a repo you haven't cloned. asks where to save, as for a repo you haven't cloned.
A stripped binary is about 21 MB instead of 30 MB: A stripped binary is a third smaller:
```sh ```sh
go build -trimpath -ldflags "-s -w" -o lazysecrets ./cmd/lazysecrets go build -trimpath -ldflags "-s -w" -o lazysecrets ./cmd/lazysecrets
@@ -35,10 +35,8 @@ subject, checked by `pr-title.yml`:
| `fix` | a bug fix | Fixed | | `fix` | a bug fix | Fixed |
| `refactor`, `docs`, `test`, `ci`, `chore`, `build`, `style`, `revert` | everything users don't need to hear about | no | | `refactor`, `docs`, `test`, `ci`, `chore`, `build`, `style`, `revert` | everything users don't need to hear about | no |
`feat!:` marks a breaking change, `feat(scope):` names a part. Brackets `feat!:` marks a breaking change, `feat(scope):` names a part.
and a missing space work too: `[feat] x`, `[feat]: x` and `feat:x` all `[skip changelog]` in the description leaves a pull request out.
become `feat: x` in the changelog. `[skip changelog]` in the description
leaves a pull request out.
CI (`ci.yml`) runs gofmt, vet, staticcheck, the tests and a build for each CI (`ci.yml`) runs gofmt, vet, staticcheck, the tests and a build for each
platform on pull requests into `main`. `main` is protected: a pull request platform on pull requests into `main`. `main` is protected: a pull request
+6 -6
View File
@@ -34,7 +34,7 @@ tab-separated:
| name | `platform-prod / kv-app-prod / db-password` | | 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 | | 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 | | 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` | | account | what it's listed through: `Azure` (`Azure:<user>/<tenant>` with several az accounts), `Databricks` or `Repos` |
| link | its `lazysecrets://` link | | link | its `lazysecrets://` link |
What's cached prints at once, then what loads follows as it arrives: read What's cached prints at once, then what loads follows as it arrives: read
@@ -47,7 +47,7 @@ so `^prod` finds names starting with prod.
| Flag | | | Flag | |
|---|---| |---|---|
| `--format json` | an object per line instead, for editor plugins (below) | | `--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 | | `--kind secret,vault` | only these kinds: `source`, `vault` (also scopes and repo files), `secret`, `key`, `certificate`; lists they don't need aren't loaded |
| `--cached` | only what's cached, at once: nothing is loaded from Azure or Databricks | | `--cached` | only what's cached, at once: nothing is loaded from Azure or Databricks |
| `--max-age 2m` | load cached lists older than this again | | `--max-age 2m` | load cached lists older than this again |
| `--errors=false` | leave out the vaults and sources it couldn't read | | `--errors=false` | leave out the vaults and sources it couldn't read |
@@ -77,12 +77,11 @@ may be added:
| `expires` | when a secret, key or certificate expires, if it does (Azure) | | `expires` | when a secret, key or certificate expires, if it does (Azure) |
| `enabled` | `false` for a disabled one; only where the provider says (Azure) | | `enabled` | `false` for a disabled one; only where the provider says (Azure) |
`lazysecrets open --action go --account <account> <link>` opens one in [`lazysecrets open --action go --account <account> <link>`](sharing.md#opening-a-link)
lazysecrets without asking what to do or which account, ending in the opens one in lazysecrets without asking what to do or which account.
secrets list, like Go to.
### An fzf picker
### Example usage with fzf
```sh ```sh
lazysecrets list | fzf --ansi --delimiter='\t' --with-nth='2..-3' --nth='2..' --accept-nth=-1 \ 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' \ --header 'enter: link · ctrl-o: open · ctrl-y: copy value · ctrl-x: copy and close' \
@@ -90,5 +89,6 @@ lazysecrets list | fzf --ansi --delimiter='\t' --with-nth='2..-3' --nth='2..' --
--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 {3})' \
--bind 'ctrl-x:become(lazysecrets get --account {-2} {-1} | pbcopy)' --bind 'ctrl-x:become(lazysecrets get --account {-2} {-1} | pbcopy)'
``` ```
![fzf-example](assets/fzf-example.png) ![fzf-example](assets/fzf-example.png)
+16 -19
View File
@@ -49,7 +49,7 @@ match case exactly.
| Key | Action | | Key | Action |
|---|---| |---|---|
| `space` | reveal / hide the selected value (public material for keys and certificates) | | `space` | reveal / hide the selected value (public material for keys and certificates) |
| `y` | copy the value without revealing it; cleared from the clipboard after 30s. On an Azure account's row: its object ID; in its pane, the selected field | | `y` | copy the value without revealing it. On an Azure account's row: its object ID; in its pane, the selected field |
| `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) |
@@ -63,10 +63,8 @@ match case exactly.
A revealed value is shown as it reads best: JSON indented, and PEM A revealed value is shown as it reads best: JSON indented, and PEM
certificates as their subject, issuer, dates, names and thumbprint, with certificates as their subject, issuer, dates, names and thumbprint, with
any other PEM block (a private key) named but not shown. `y` still copies any other PEM block (a private key) named but not shown. `y` still copies
it exactly as stored. it exactly as stored. It hides again after a while, and when the terminal
loses focus ([config](config.md)).
A revealed value hides again after `values.revealTimeout` (60s) and
when the terminal loses focus (`values.hideOnBlur`); see [config](config.md).
## Stars and notes ## Stars and notes
@@ -75,8 +73,7 @@ when the terminal loses focus (`values.hideOnBlur`); see [config](config.md).
| `f` | star the selected source, vault or secret: it gets a ★ and sorts first in its list | | `f` | star the selected source, vault or secret: it gets a ★ and sorts first in its list |
| `n` | write a short note on it, shown dimmed on its row and in the detail pane | | `n` | write a short note on it, shown dimmed on its row and in the detail pane |
A star belongs to the account you starred the item under; a note shows Both are kept in the [state file](config.md#state).
under every account. Both are kept in the [state file](config.md#state).
## Go to ## Go to
@@ -124,15 +121,16 @@ it's hidden.
On top, the status pane (`0`) shows the version, a newer release and how On top, the status pane (`0`) shows the version, a newer release and how
your accounts are doing; `e` there opens your [config](config.md) in your your accounts are doing; `e` there opens your [config](config.md) in your
editor, as in lazygit. Focused, the right pane lists more under the editor, as in lazygit. Focused, the right pane lists more under the
logo (when there's room): the version and updates (`U`), each account logo; `y` copies any row:
(`L` on one that needs it signs in), what's expiring (`enter` goes to
it), this session's errors, newest first (what a pane showed, or the - the version and updates (`U`)
footer flashed before you moved on; not kept after quitting), the config, - each account (`L` on one that needs it signs in)
state and cache files (`e` opens one in your editor), and links to the - what's expiring (`enter` goes to it)
docs, source, issues, releases and changelog (`o` opens one; on the - this session's errors, newest first (not kept after quitting)
version or update, its release), then the changelog of every release up - the config, state and cache files (`e` opens one in your editor)
to yours, built in so it needs no network: `/` filters it (`/fixed`, - links to the docs, source, issues, releases and changelog (`o` opens one)
`/search`). `y` copies any row. - the changelog of every release up to yours, built in: `/` filters it
(`/fixed`, `/search`)
| Key | Action | | Key | Action |
|---|---| |---|---|
@@ -141,9 +139,8 @@ to yours, built in so it needs no network: `/` filters it (`/fixed`,
| `+` / `_` | zoom the focused pane: normal → half → full screen, and back | | `+` / `_` | zoom the focused pane: normal → half → full screen, and back |
| `=` | reset the layout (divider, heights, zoom) | | `=` | reset the layout (divider, heights, zoom) |
The divider and pane heights are remembered (zoom isn't). macOS uses The divider and pane heights are remembered (zoom isn't). `<>{}` are for
ctrl+arrows for Mission Control and switching desktops by default, hence macOS, where ctrl+arrows switch desktops.
the `<` `>` `{` `}` alternatives.
## Rebinding keys ## Rebinding keys
+2 -10
View File
@@ -100,13 +100,5 @@ sops 3.8 or later.
## Links ## Links
`S` → `f` shares env files as an [encrypted link](sharing.md#sharing-env-files). Repo links name the repo by its git remote, so they open in a colleague's
clone wherever it is ([sharing](sharing.md)).
Links name the repo by its git remote, so they open in a colleague's clone
wherever it is:
```
lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password
```
See [sharing](sharing.md).
+9 -5
View File
@@ -7,10 +7,13 @@ permissions: the link names the item, never its value. Env files can
also be [shared encrypted](#sharing-env-files), in the link itself. also be [shared encrypted](#sharing-env-files), in the link itself.
``` ```
lazysecrets://azure/subscriptions/<subscription-id> subscription
lazysecrets://azure/kv-app-prod.vault.azure.net vault lazysecrets://azure/kv-app-prod.vault.azure.net vault
lazysecrets://azure/kv-app-prod.vault.azure.net/keys vault, Keys tab lazysecrets://azure/kv-app-prod.vault.azure.net/keys vault, Keys tab
lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password secret lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password secret
lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password/91d3e0 one version lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password/91d3e0 one version
lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net workspace
lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app scope
lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage-key lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage-key
lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password
``` ```
@@ -102,8 +105,8 @@ it now and then: new links use the newest version.
## Opening a link ## Opening a link
- Paste it into lazysecrets, or press `ctrl+o`, which reads the clipboard - Paste it into lazysecrets, or press `ctrl+o`, which reads the clipboard
and fills in any reference it finds there. Key Vault URLs, and fills in any reference it finds there. Key Vault URLs, Azure portal
`@Microsoft.KeyVault(...)` and `{{secrets/scope/key}}` work too. links, `@Microsoft.KeyVault(...)` and `{{secrets/scope/key}}` work too.
- `lazysecrets <link>` (or `lazysecrets open <link>`) in a terminal. - `lazysecrets <link>` (or `lazysecrets open <link>`) in a terminal.
- Click it anywhere, once the [link handler](#from-the-browser-and-chat) is - Click it anywhere, once the [link handler](#from-the-browser-and-chat) is
set up. set up.
@@ -143,9 +146,10 @@ turns the question off too.
link then opens a new window of the terminal you ran it in, with link then opens a new window of the terminal you ran it in, with
lazysecrets on that link. It recognises kitty, Ghostty, WezTerm, iTerm2, lazysecrets on that link. It recognises kitty, Ghostty, WezTerm, iTerm2,
Terminal.app, Alacritty, GNOME Terminal, Konsole and foot, also inside Terminal.app, Alacritty, GNOME Terminal, Konsole and foot, also inside
tmux; `--terminal NAME` picks another. It records your `PATH` and config tmux; `--terminal NAME` picks another. It records your `PATH`, config
locations, since apps start without your shell's environment (and so locations and XDG directories, since apps start without your shell's
without `az`): run it again after moving lazysecrets or changing those. environment (and so without `az`): run it again after moving lazysecrets
or changing those.
- macOS: a small background app, `~/Applications/Lazysecrets Links.app` - macOS: a small background app, `~/Applications/Lazysecrets Links.app`
- Linux: a desktop entry claiming `x-scheme-handler/lazysecrets`, set as - Linux: a desktop entry claiming `x-scheme-handler/lazysecrets`, set as
-3
View File
@@ -42,9 +42,6 @@ func New() Clipboard {
} }
} }
// Native reports whether the system clipboard is used rather than OSC52.
func (c Clipboard) Native() bool { return c.native }
// Write puts s on the clipboard. // Write puts s on the clipboard.
func (c Clipboard) Write(s string) tea.Cmd { func (c Clipboard) Write(s string) tea.Cmd {
if !c.native { if !c.native {
+2 -4
View File
@@ -16,6 +16,7 @@
package commands package commands
import ( import (
"cmp"
"fmt" "fmt"
"regexp" "regexp"
"strings" "strings"
@@ -60,10 +61,7 @@ var envName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
// Validate checks a command from the config. // Validate checks a command from the config.
func (c Command) Validate() error { func (c Command) Validate() error {
name := c.Description name := cmp.Or(c.Description, c.Command)
if name == "" {
name = c.Command
}
switch { switch {
case c.Command == "": case c.Command == "":
return fmt.Errorf("custom command %q has no command", c.Description) return fmt.Errorf("custom command %q has no command", c.Description)
+6 -11
View File
@@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"maps"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -47,7 +48,8 @@ type UI struct {
// RecentIcon marks items used lately (Recent, R) with ◷, in the lists // RecentIcon marks items used lately (Recent, R) with ◷, in the lists
// and in lazysecrets list. Defaults to false. // and in lazysecrets list. Defaults to false.
RecentIcon *bool `yaml:"recentIcon"` RecentIcon *bool `yaml:"recentIcon"`
// SourcesView is "grouped" (default) or "flat"; ` + "`" + ` toggles it. // SourcesView is "grouped" (default) or "flat"; the backtick key
// toggles it.
SourcesView string `yaml:"sourcesView"` SourcesView string `yaml:"sourcesView"`
// CollapseGroups starts the Sources pane with its groups folded; // CollapseGroups starts the Sources pane with its groups folded;
// defaults to true. // defaults to true.
@@ -203,17 +205,12 @@ func xdgDir(env, fallback string) (string, error) {
return filepath.Join(base, "lazysecrets"), nil return filepath.Join(base, "lazysecrets"), nil
} }
// Load reads path. A missing file is an empty config. Unknown keys are an
// error, so typos don't silently do nothing.
func Load(path string) (Config, error) {
return LoadAll([]string{path})
}
// LoadAll reads several config files in order, each on top of the ones // LoadAll reads several config files in order, each on top of the ones
// before, like lazygit's LG_CONFIG_FILE: a later file overrides the // before, like lazygit's LG_CONFIG_FILE: a later file overrides the
// settings it sets, and theme roles merge one by one. That lets an editor // settings it sets, and theme roles merge one by one. That lets an editor
// plugin add a generated theme file to the user's own config. Missing // plugin add a generated theme file to the user's own config. Missing
// files are skipped. // files are skipped. Unknown keys are an error, so typos don't silently do
// nothing.
func LoadAll(paths []string) (Config, error) { func LoadAll(paths []string) (Config, error) {
var c Config var c Config
keybinding := map[string]map[string][]string{} keybinding := map[string]map[string][]string{}
@@ -228,9 +225,7 @@ func LoadAll(paths []string) (Config, error) {
if keybinding[ctx] == nil { if keybinding[ctx] == nil {
keybinding[ctx] = map[string][]string{} keybinding[ctx] = map[string][]string{}
} }
for name, keys := range actions { maps.Copy(keybinding[ctx], actions)
keybinding[ctx][name] = keys
}
} }
} }
c.Keybinding = keybinding c.Keybinding = keybinding
+7 -7
View File
@@ -18,7 +18,7 @@ func write(t *testing.T, content string) string {
} }
func TestLoad(t *testing.T) { func TestLoad(t *testing.T) {
c, err := Load(write(t, ` c, err := LoadAll([]string{write(t, `
ui: ui:
icons: false icons: false
sourcesView: flat sourcesView: flat
@@ -36,7 +36,7 @@ providers:
databricks: databricks:
enabled: false enabled: false
hideProfiles: [test] hideProfiles: [test]
`)) `)})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -61,7 +61,7 @@ func TestLoadMissingAndEmpty(t *testing.T) {
"missing": filepath.Join(t.TempDir(), "nope.yaml"), "missing": filepath.Join(t.TempDir(), "nope.yaml"),
"empty": write(t, ""), "empty": write(t, ""),
} { } {
c, err := Load(path) c, err := LoadAll([]string{path})
if err != nil || c.UI.Icons != nil || c.Values.ClipboardClear != nil { if err != nil || c.UI.Icons != nil || c.Values.ClipboardClear != nil {
t.Errorf("%s: %+v, %v", name, c, err) t.Errorf("%s: %+v, %v", name, c, err)
} }
@@ -85,7 +85,7 @@ func TestLoadRejectsMistakes(t *testing.T) {
"short keys": "sharing:\n keyRules:\n minLength: 8\n", "short keys": "sharing:\n keyRules:\n minLength: 8\n",
"classes": "sharing:\n keyRules:\n minClasses: 5\n", "classes": "sharing:\n keyRules:\n minClasses: 5\n",
} { } {
if _, err := Load(write(t, content)); err == nil { if _, err := LoadAll([]string{write(t, content)}); err == nil {
t.Errorf("%s: no error", name) t.Errorf("%s: no error", name)
} else if !strings.Contains(err.Error(), "config.yaml") { } else if !strings.Contains(err.Error(), "config.yaml") {
t.Errorf("%s: error doesn't name the file: %v", name, err) t.Errorf("%s: error doesn't name the file: %v", name, err)
@@ -132,18 +132,18 @@ theme:
} }
func TestUpdates(t *testing.T) { func TestUpdates(t *testing.T) {
c, err := Load(write(t, "updates:\n autoCheck: false\n autoCheckInterval: 168h\n sources: [https://git.example.com/team/lazysecrets]\n")) c, err := LoadAll([]string{write(t, "updates:\n autoCheck: false\n autoCheckInterval: 168h\n sources: [https://git.example.com/team/lazysecrets]\n")})
if err != nil || On(c.Updates.AutoCheck) || *c.Updates.AutoCheckInterval != 7*24*time.Hour || len(c.Updates.Sources) != 1 { if err != nil || On(c.Updates.AutoCheck) || *c.Updates.AutoCheckInterval != 7*24*time.Hour || len(c.Updates.Sources) != 1 {
t.Errorf("updates = %+v, %v", c.Updates, err) t.Errorf("updates = %+v, %v", c.Updates, err)
} }
} }
func TestLoadDelay(t *testing.T) { func TestLoadDelay(t *testing.T) {
c, err := Load(write(t, "ui:\n loadDelay: 300ms\n")) c, err := LoadAll([]string{write(t, "ui:\n loadDelay: 300ms\n")})
if err != nil || c.UI.LoadDelay == nil || *c.UI.LoadDelay != 300*time.Millisecond { if err != nil || c.UI.LoadDelay == nil || *c.UI.LoadDelay != 300*time.Millisecond {
t.Errorf("loadDelay = %v, %v", c.UI.LoadDelay, err) t.Errorf("loadDelay = %v, %v", c.UI.LoadDelay, err)
} }
if _, err := Load(write(t, "ui:\n loadDelay: 1m\n")); err == nil { if _, err := LoadAll([]string{write(t, "ui:\n loadDelay: 1m\n")}); err == nil {
t.Error("a minute's delay accepted") t.Error("a minute's delay accepted")
} }
} }
+1 -2
View File
@@ -9,7 +9,6 @@
package instance package instance
import ( import (
"bufio"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -114,7 +113,7 @@ func (s *Server) handle(c net.Conn) {
defer c.Close() defer c.Close()
_ = c.SetDeadline(time.Now().Add(5 * time.Second)) _ = c.SetDeadline(time.Now().Add(5 * time.Second))
var req request var req request
if err := json.NewDecoder(bufio.NewReader(c)).Decode(&req); err != nil { if err := json.NewDecoder(c).Decode(&req); err != nil {
return return
} }
var resp response var resp response
+3 -7
View File
@@ -114,19 +114,15 @@ func Check() string {
} }
out, err := exec.Command("plutil", "-extract", exeKey, "raw", filepath.Join(app, "Contents", "Info.plist")).Output() out, err := exec.Command("plutil", "-extract", exeKey, "raw", filepath.Join(app, "Contents", "Info.plist")).Output()
if err != nil { if err != nil {
return "" // installed before the key was recorded: assume it works return "" // can't tell: assume it works
} }
if exe := strings.TrimSpace(string(out)); !exists(exe) { exe := strings.TrimSpace(string(out))
if _, err := os.Stat(exe); err != nil {
return "lazysecrets:// links run " + exe + ", which no longer exists" return "lazysecrets:// links run " + exe + ", which no longer exists"
} }
return "" return ""
} }
func exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// Uninstall removes the applet and its registration. // Uninstall removes the applet and its registration.
func Uninstall() (string, error) { func Uninstall() (string, error) {
app, err := AppPath() app, err := AppPath()
+5 -4
View File
@@ -140,11 +140,10 @@ func appleString(s string) string {
return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"` return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"`
} }
// Launch opens a new terminal window running inner, without waiting for // Launch opens a new terminal window running argv, without waiting for
// it. // it.
func Launch(argv []string) error { func Launch(argv []string) error {
cmd := exec.Command(argv[0], argv[1:]...) cmd := exec.Command(argv[0], argv[1:]...)
cmd.Stdin, cmd.Stdout, cmd.Stderr = nil, nil, nil
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
return err return err
} }
@@ -166,9 +165,11 @@ func Executable() (string, error) {
// passEnv lists the variables carried from the install's environment to // passEnv lists the variables carried from the install's environment to
// lazysecrets in the new window: apps start with a minimal environment, // lazysecrets in the new window: apps start with a minimal environment,
// without Homebrew's PATH (so no az) or the user's config locations. // without Homebrew's PATH (so no az) or the user's XDG directories, so
// the window would use another cache and miss running instances' sockets.
var passEnv = []string{ var passEnv = []string{
"PATH", "LAZYSECRETS_CONFIG_FILE", "LAZYSECRETS_ICONS", "XDG_CONFIG_HOME", "XDG_STATE_HOME", "PATH", "LAZYSECRETS_CONFIG_FILE", "LAZYSECRETS_ICONS",
"XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME", "XDG_RUNTIME_DIR", "XDG_DATA_HOME",
"AZURE_CONFIG_DIR", "DATABRICKS_CONFIG_FILE", "AZURE_CONFIG_DIR", "DATABRICKS_CONFIG_FILE",
} }
-3
View File
@@ -107,9 +107,6 @@ func parseTerm(tok string) (term, bool) {
return t, true return t, true
} }
// Empty reports whether the query has no terms: everything matches.
func (q Query) Empty() bool { return len(q.groups) == 0 }
// Item is a row to match: Text is everything searched, and runes // Item is a row to match: Text is everything searched, and runes
// NameStart up to NameEnd of it are the row's name (the rest is context, // NameStart up to NameEnd of it are the row's name (the rest is context,
// like a path or a note). Without a name, nothing gets the name bonus. // like a path or a note). Without a name, nothing gets the name bonus.
-6
View File
@@ -99,9 +99,3 @@ func TestAnchorsOnTheName(t *testing.T) {
t.Errorf("password$: %v", got) t.Errorf("password$: %v", got)
} }
} }
func TestEmpty(t *testing.T) {
if !Parse(" | ").Empty() || Parse("x").Empty() {
t.Error("Empty")
}
}
+2 -6
View File
@@ -2,6 +2,7 @@ package azure
import ( import (
"fmt" "fmt"
"maps"
"slices" "slices"
"strings" "strings"
@@ -134,12 +135,7 @@ func (v vaultInfo) attrs() []provider.Attr {
if v.endpoints > 0 { if v.endpoints > 0 {
add("private", fmt.Sprintf("%d endpoint%s", v.endpoints, map[bool]string{true: "s"}[v.endpoints > 1])) add("private", fmt.Sprintf("%d endpoint%s", v.endpoints, map[bool]string{true: "s"}[v.endpoints > 1]))
} }
keys := make([]string, 0, len(v.tags)) for i, k := range slices.Sorted(maps.Keys(v.tags)) {
for k := range v.tags {
keys = append(keys, k)
}
slices.Sort(keys)
for i, k := range keys {
label := "" label := ""
if i == 0 { if i == 0 {
label = "tags" label = "tags"
+25 -40
View File
@@ -48,19 +48,8 @@ func newProvider(kind provider.Kind, delay time.Duration) *Provider {
func (p *Provider) Kind() provider.Kind { return p.kind } func (p *Provider) Kind() provider.Kind { return p.kind }
func (p *Provider) ID() string { func (p *Provider) ID() string { return cmp.Or(p.id, string(p.kind)) }
if p.id != "" { func (p *Provider) Label() string { return cmp.Or(p.label, string(p.kind)) }
return p.id
}
return string(p.kind)
}
func (p *Provider) Label() string {
if p.label != "" {
return p.label
}
return string(p.kind)
}
// As renames the provider, to simulate several accounts of one kind. // As renames the provider, to simulate several accounts of one kind.
func (p *Provider) As(id, label string) *Provider { func (p *Provider) As(id, label string) *Provider {
@@ -252,11 +241,7 @@ func (p *Provider) fail(c provider.Container, err error, types ...provider.Objec
} }
} }
var ( var base = time.Date(2026, 9, 1, 9, 30, 0, 0, time.UTC)
base = time.Date(2026, 9, 1, 9, 30, 0, 0, time.UTC)
yes = true
no = false
)
// ErrForbidden mimics a data-plane permission error from a backend. // ErrForbidden mimics a data-plane permission error from a backend.
var ErrForbidden = errors.New("403 Forbidden: caller lacks permission to list secrets") var ErrForbidden = errors.New("403 Forbidden: caller lacks permission to list secrets")
@@ -284,32 +269,32 @@ func Azure(delay time.Duration) *Provider {
prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com") prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com")
kv := p.addContainer(prod, "kv-app-prod", "https://kv-app-prod.vault.azure.net", allTypes...) kv := p.addContainer(prod, "kv-app-prod", "https://kv-app-prod.vault.azure.net", allTypes...)
dbPassword := p.add(kv, provider.TypeSecret, provider.Object{ dbPassword := p.add(kv, provider.TypeSecret, provider.Object{
Name: "db-password", Version: "3f9c2a", Enabled: &yes, Updated: base, Name: "db-password", Version: "3f9c2a", Enabled: new(true), Updated: base,
Expires: base.AddDate(1, 0, 0), ContentType: "text/plain", Expires: base.AddDate(1, 0, 0), ContentType: "text/plain",
Tags: map[string]string{"owner": "data", "env": "prod"}, Reference: secretRef("kv-app-prod", "db-password"), Tags: map[string]string{"owner": "data", "env": "prod"}, Reference: secretRef("kv-app-prod", "db-password"),
}, "correct-horse-battery-staple") }, "correct-horse-battery-staple")
p.versions[dbPassword.ID] = []provider.Object{ p.versions[dbPassword.ID] = []provider.Object{
{Version: "3f9c2a", Enabled: &yes, Updated: base, Expires: base.AddDate(1, 0, 0), ContentType: "text/plain"}, {Version: "3f9c2a", Enabled: new(true), Updated: base, Expires: base.AddDate(1, 0, 0), ContentType: "text/plain"},
{Version: "91d3e0", Enabled: &yes, Updated: base.AddDate(0, -6, 0), Expires: base.AddDate(0, -1, 0)}, {Version: "91d3e0", Enabled: new(true), Updated: base.AddDate(0, -6, 0), Expires: base.AddDate(0, -1, 0)},
{Version: "0a77c4", Enabled: &no, Updated: base.AddDate(-1, 0, 0)}, {Version: "0a77c4", Enabled: new(false), Updated: base.AddDate(-1, 0, 0)},
} }
p.values[dbPassword.ID+"@91d3e0"] = "previous-password" p.values[dbPassword.ID+"@91d3e0"] = "previous-password"
p.values[dbPassword.ID+"@0a77c4"] = "original-password" p.values[dbPassword.ID+"@0a77c4"] = "original-password"
p.add(kv, provider.TypeSecret, provider.Object{ p.add(kv, provider.TypeSecret, provider.Object{
Name: "api-key", Version: "a17b04", Enabled: &yes, Updated: base.AddDate(0, -2, 0), Name: "api-key", Version: "a17b04", Enabled: new(true), Updated: base.AddDate(0, -2, 0),
Reference: secretRef("kv-app-prod", "api-key"), Reference: secretRef("kv-app-prod", "api-key"),
}, "sk_live_51HxDEMO0000000000000000") }, "sk_live_51HxDEMO0000000000000000")
p.add(kv, provider.TypeSecret, provider.Object{ 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: new(true), Updated: base.AddDate(-1, 0, 0),
Expires: base.AddDate(0, -3, 0), Reference: secretRef("kv-app-prod", "legacy-token"), Expires: base.AddDate(0, -3, 0), Reference: secretRef("kv-app-prod", "legacy-token"),
}, "deprecated") }, "deprecated")
p.add(kv, provider.TypeSecret, provider.Object{ p.add(kv, provider.TypeSecret, provider.Object{
Name: "smtp-password", Version: "7d2e90", Enabled: &no, Updated: base.AddDate(0, -2, 0), Name: "smtp-password", Version: "7d2e90", Enabled: new(false), Updated: base.AddDate(0, -2, 0),
Reference: secretRef("kv-app-prod", "smtp-password"), Reference: secretRef("kv-app-prod", "smtp-password"),
}, "smtp-demo-password") }, "smtp-demo-password")
p.add(kv, provider.TypeKey, provider.Object{ p.add(kv, provider.TypeKey, provider.Object{
Name: "signing-key", Version: "5e21b7", Enabled: &yes, Updated: base.AddDate(0, -3, 0), Name: "signing-key", Version: "5e21b7", Enabled: new(true), Updated: base.AddDate(0, -3, 0),
Attrs: []provider.Attr{ Attrs: []provider.Attr{
{Label: "key type", Value: "RSA 2048"}, {Label: "key type", Value: "RSA 2048"},
{Label: "ops", Value: "sign, verify"}, {Label: "ops", Value: "sign, verify"},
@@ -317,7 +302,7 @@ func Azure(delay time.Duration) *Provider {
Reference: objectURL("kv-app-prod", "keys", "signing-key"), Reference: objectURL("kv-app-prod", "keys", "signing-key"),
}, demoPublicKey) }, demoPublicKey)
p.add(kv, provider.TypeKey, provider.Object{ p.add(kv, provider.TypeKey, provider.Object{
Name: "data-encryption", Version: "c40f19", Enabled: &yes, Updated: base.AddDate(0, -5, 0), Name: "data-encryption", Version: "c40f19", Enabled: new(true), Updated: base.AddDate(0, -5, 0),
Attrs: []provider.Attr{ Attrs: []provider.Attr{
{Label: "key type", Value: "EC P-256"}, {Label: "key type", Value: "EC P-256"},
{Label: "ops", Value: "wrapKey, unwrapKey"}, {Label: "ops", Value: "wrapKey, unwrapKey"},
@@ -326,7 +311,7 @@ func Azure(delay time.Duration) *Provider {
}, demoPublicKey) }, demoPublicKey)
p.add(kv, provider.TypeCertificate, provider.Object{ p.add(kv, provider.TypeCertificate, provider.Object{
Name: "app-tls", Version: "77e0aa", Enabled: &yes, Updated: base.AddDate(0, -1, 0), Name: "app-tls", Version: "77e0aa", Enabled: new(true), Updated: base.AddDate(0, -1, 0),
Expires: base.AddDate(0, 11, 0), ContentType: "application/x-pem-file", Expires: base.AddDate(0, 11, 0), ContentType: "application/x-pem-file",
Attrs: []provider.Attr{ Attrs: []provider.Attr{
{Label: "subject", Value: "CN=app.example.com"}, {Label: "subject", Value: "CN=app.example.com"},
@@ -338,7 +323,7 @@ func Azure(delay time.Duration) *Provider {
Reference: objectURL("kv-app-prod", "certificates", "app-tls"), Reference: objectURL("kv-app-prod", "certificates", "app-tls"),
}, demoCert) }, demoCert)
p.add(kv, provider.TypeCertificate, provider.Object{ p.add(kv, provider.TypeCertificate, provider.Object{
Name: "old-tls", Version: "19ab3c", Enabled: &yes, Updated: base.AddDate(-1, 0, 0), Name: "old-tls", Version: "19ab3c", Enabled: new(true), Updated: base.AddDate(-1, 0, 0),
Expires: base.AddDate(0, -6, 0), ContentType: "application/x-pkcs12", Expires: base.AddDate(0, -6, 0), ContentType: "application/x-pkcs12",
Attrs: []provider.Attr{ Attrs: []provider.Attr{
{Label: "subject", Value: "CN=old.example.com"}, {Label: "subject", Value: "CN=old.example.com"},
@@ -351,10 +336,10 @@ func Azure(delay time.Duration) *Provider {
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...) shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
// A key to share env files with (S → f): random, as sharing.keyRules wants. // A key to share env files with (S → f): random, as sharing.keyRules wants.
p.add(shared, provider.TypeSecret, provider.Object{ p.add(shared, provider.TypeSecret, provider.Object{
Name: "env-share", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "env-share"), Name: "env-share", Enabled: new(true), Updated: base, Reference: secretRef("kv-shared-prod", "env-share"),
}, "q8Zt3VwL1mXc7Rk2Pj9Ys4Hn6Bf0Ga5Ud+Te/OiWlE=") }, "q8Zt3VwL1mXc7Rk2Pj9Ys4Hn6Bf0Ga5Ud+Te/OiWlE=")
p.add(shared, provider.TypeSecret, provider.Object{ p.add(shared, provider.TypeSecret, provider.Object{
Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"), Name: "smtp-password", Enabled: new(true), Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
}, "smtp-demo-password") }, "smtp-demo-password")
p.fail(shared, ErrForbiddenKeys, provider.TypeKey) p.fail(shared, ErrForbiddenKeys, provider.TypeKey)
@@ -364,19 +349,19 @@ func Azure(delay time.Duration) *Provider {
dev := p.addSource("sub-dev", "platform-dev", "tenant: example.onmicrosoft.com") dev := p.addSource("sub-dev", "platform-dev", "tenant: example.onmicrosoft.com")
kvDev := p.addContainer(dev, "kv-app-dev", "https://kv-app-dev.vault.azure.net", allTypes...) kvDev := p.addContainer(dev, "kv-app-dev", "https://kv-app-dev.vault.azure.net", allTypes...)
p.add(kvDev, provider.TypeSecret, provider.Object{ p.add(kvDev, provider.TypeSecret, provider.Object{
Name: "db-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-app-dev", "db-password"), Name: "db-password", Enabled: new(true), Updated: base, Reference: secretRef("kv-app-dev", "db-password"),
}, "dev-password") }, "dev-password")
// Left behind, as dev vaults are: expired, disabled, or both. // Left behind, as dev vaults are: expired, disabled, or both.
p.add(kvDev, provider.TypeSecret, provider.Object{ p.add(kvDev, provider.TypeSecret, provider.Object{
Name: "stripe-webhook-secret", Enabled: &yes, Updated: base.AddDate(-1, 0, 0), Name: "stripe-webhook-secret", Enabled: new(true), Updated: base.AddDate(-1, 0, 0),
Expires: base.AddDate(0, -2, 0), Reference: secretRef("kv-app-dev", "stripe-webhook-secret"), Expires: base.AddDate(0, -2, 0), Reference: secretRef("kv-app-dev", "stripe-webhook-secret"),
}, "whsec_demo_expired") }, "whsec_demo_expired")
p.add(kvDev, provider.TypeSecret, provider.Object{ p.add(kvDev, provider.TypeSecret, provider.Object{
Name: "test-user-password", Enabled: &no, Updated: base.AddDate(0, -5, 0), Name: "test-user-password", Enabled: new(false), Updated: base.AddDate(0, -5, 0),
Reference: secretRef("kv-app-dev", "test-user-password"), Reference: secretRef("kv-app-dev", "test-user-password"),
}, "disabled-demo-password") }, "disabled-demo-password")
p.add(kvDev, provider.TypeSecret, provider.Object{ p.add(kvDev, provider.TypeSecret, provider.Object{
Name: "vendor-token", Enabled: &no, Updated: base.AddDate(-2, 0, 0), Name: "vendor-token", Enabled: new(false), Updated: base.AddDate(-2, 0, 0),
Expires: base.AddDate(-1, 0, 0), Reference: secretRef("kv-app-dev", "vendor-token"), Expires: base.AddDate(-1, 0, 0), Reference: secretRef("kv-app-dev", "vendor-token"),
}, "expired-and-disabled") }, "expired-and-disabled")
@@ -395,17 +380,17 @@ func AzureOps(delay time.Duration) *Provider {
prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com") prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com")
breakGlass := p.addContainer(prod, "kv-breakglass", "https://kv-breakglass.vault.azure.net", allTypes...) breakGlass := p.addContainer(prod, "kv-breakglass", "https://kv-breakglass.vault.azure.net", allTypes...)
p.add(breakGlass, provider.TypeSecret, provider.Object{ p.add(breakGlass, provider.TypeSecret, provider.Object{
Name: "emergency-admin", Enabled: &yes, Updated: base.AddDate(0, -4, 0), Name: "emergency-admin", Enabled: new(true), Updated: base.AddDate(0, -4, 0),
Expires: time.Now().AddDate(0, 0, 5).Truncate(24 * time.Hour), // soon, whenever the demo runs Expires: time.Now().AddDate(0, 0, 5).Truncate(24 * time.Hour), // soon, whenever the demo runs
Tags: map[string]string{"owner": "ops"}, Reference: secretRef("kv-breakglass", "emergency-admin"), Tags: map[string]string{"owner": "ops"}, Reference: secretRef("kv-breakglass", "emergency-admin"),
}, "break-glass-demo-password") }, "break-glass-demo-password")
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...) shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
p.add(shared, provider.TypeSecret, provider.Object{ p.add(shared, provider.TypeSecret, provider.Object{
Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"), Name: "smtp-password", Enabled: new(true), Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
}, "smtp-demo-password") }, "smtp-demo-password")
// Unlike the first account, ops may read keys here. // Unlike the first account, ops may read keys here.
p.add(shared, provider.TypeKey, provider.Object{ p.add(shared, provider.TypeKey, provider.Object{
Name: "backup-encryption", Version: "e9a102", Enabled: &yes, Updated: base.AddDate(0, -2, 0), Name: "backup-encryption", Version: "e9a102", Enabled: new(true), Updated: base.AddDate(0, -2, 0),
Attrs: []provider.Attr{{Label: "key type", Value: "RSA-HSM 3072"}, {Label: "ops", Value: "wrapKey, unwrapKey"}}, Attrs: []provider.Attr{{Label: "key type", Value: "RSA-HSM 3072"}, {Label: "ops", Value: "wrapKey, unwrapKey"}},
Reference: "https://kv-shared-prod.vault.azure.net/keys/backup-encryption", Reference: "https://kv-shared-prod.vault.azure.net/keys/backup-encryption",
}, demoPublicKey) }, demoPublicKey)
@@ -413,12 +398,12 @@ func AzureOps(delay time.Duration) *Provider {
sandbox := p.addSource("sub-ops", "ops-sandbox", "tenant: example.onmicrosoft.com") sandbox := p.addSource("sub-ops", "ops-sandbox", "tenant: example.onmicrosoft.com")
kv := p.addContainer(sandbox, "kv-ops-tools", "https://kv-ops-tools.vault.azure.net", allTypes...) kv := p.addContainer(sandbox, "kv-ops-tools", "https://kv-ops-tools.vault.azure.net", allTypes...)
p.add(kv, provider.TypeSecret, provider.Object{ p.add(kv, provider.TypeSecret, provider.Object{
Name: "pagerduty-token", Enabled: &yes, Updated: base.AddDate(0, -1, 0), Reference: secretRef("kv-ops-tools", "pagerduty-token"), Name: "pagerduty-token", Enabled: new(true), Updated: base.AddDate(0, -1, 0), Reference: secretRef("kv-ops-tools", "pagerduty-token"),
}, "pd-demo-token") }, "pd-demo-token")
// Expiring soon whenever the demo runs: the other dates are fixed, // Expiring soon whenever the demo runs: the other dates are fixed,
// and would have passed. Tests use the first account only. // and would have passed. Tests use the first account only.
p.add(kv, provider.TypeSecret, provider.Object{ p.add(kv, provider.TypeSecret, provider.Object{
Name: "grafana-api-key", Enabled: &yes, Updated: base.AddDate(0, -11, 0), Name: "grafana-api-key", Enabled: new(true), Updated: base.AddDate(0, -11, 0),
Expires: time.Now().AddDate(0, 0, 12).Truncate(24 * time.Hour), Reference: secretRef("kv-ops-tools", "grafana-api-key"), Expires: time.Now().AddDate(0, 0, 12).Truncate(24 * time.Hour), Reference: secretRef("kv-ops-tools", "grafana-api-key"),
}, "glsa_demo_expiring") }, "glsa_demo_expiring")
+3 -8
View File
@@ -1,6 +1,6 @@
// Package repo browses the encrypted secret files in git repositories: // Package repo browses the encrypted secret files in git repositories:
// a repo is a source, its secret files the containers (vault level), and // a repo is a source, its secret files the containers (vault level), and
// their keys the secrets. SOPS files are the one type so far. // their keys the secrets. Secret files are SOPS files and plain env files.
// //
// Listing never decrypts: SOPS encrypts values only, so a file's keys and // Listing never decrypts: SOPS encrypts values only, so a file's keys and
// metadata are read directly. A value is decrypted when it's revealed or // metadata are read directly. A value is decrypted when it's revealed or
@@ -106,8 +106,6 @@ func (p *Provider) load() {
roots = append(roots, root) roots = append(roots, root)
} }
} }
// The repo lazysecrets started in, if any: marked, so the UI can
// put it on top, with its files under Dir first.
here, inRepo := p.current() here, inRepo := p.current()
if inRepo { if inRepo {
seen[here.root] = true seen[here.root] = true
@@ -137,7 +135,6 @@ func (p *Provider) load() {
add(root) add(root)
} }
} }
// Repos you starred something in, wherever they are.
for _, dir := range p.s.Starred { for _, dir := range p.s.Starred {
if root, err := gitRoot(dir); err == nil { if root, err := gitRoot(dir); err == nil {
add(root) add(root)
@@ -206,13 +203,11 @@ func parallel(n int, f func(i int)) {
var wg sync.WaitGroup var wg sync.WaitGroup
sem := make(chan struct{}, max(8, runtime.NumCPU())) sem := make(chan struct{}, max(8, runtime.NumCPU()))
for i := range n { for i := range n {
wg.Add(1) wg.Go(func() {
go func() {
defer wg.Done()
sem <- struct{}{} sem <- struct{}{}
defer func() { <-sem }() defer func() { <-sem }()
f(i) f(i)
}() })
} }
wg.Wait() wg.Wait()
} }
-2
View File
@@ -21,8 +21,6 @@ func NewSecretValue(b []byte) SecretValue {
// Reveal returns the plaintext. Call it only to display or copy the value. // Reveal returns the plaintext. Call it only to display or copy the value.
func (v SecretValue) Reveal() string { return string(v.b) } func (v SecretValue) Reveal() string { return string(v.b) }
func (v SecretValue) Len() int { return len(v.b) }
func (v SecretValue) String() string { return masked } func (v SecretValue) String() string { return masked }
func (v SecretValue) GoString() string { return masked } func (v SecretValue) GoString() string { return masked }
+2 -1
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"strings" "strings"
"unicode" "unicode"
"unicode/utf8"
) )
// Rules are what a key's value must be to share with it: random enough // Rules are what a key's value must be to share with it: random enough
@@ -40,7 +41,7 @@ func (r Rules) Check(key []byte) error {
kinds++ kinds++
} }
} }
n := len([]rune(s)) n := utf8.RuneCountInString(s)
var has, needs []string var has, needs []string
if n < r.MinLength { if n < r.MinLength {
has = append(has, fmt.Sprintf("%d characters", n)) has = append(has, fmt.Sprintf("%d characters", n))
+1 -1
View File
@@ -174,7 +174,7 @@ func Read(r ref.Ref) (Sealed, error) {
return Sealed{}, errors.New("share link is from a newer lazysecrets: update it (lazysecrets update)") return Sealed{}, errors.New("share link is from a newer lazysecrets: update it (lazysecrets update)")
} }
n, size := binary.Uvarint(b[1:]) n, size := binary.Uvarint(b[1:])
if size <= 0 || n > uint64(len(b)) { if size <= 0 {
return Sealed{}, bad return Sealed{}, bad
} }
rest := b[1+size:] rest := b[1+size:]
+11 -17
View File
@@ -3,6 +3,7 @@
package app package app
import ( import (
"cmp"
"context" "context"
"errors" "errors"
"fmt" "fmt"
@@ -654,7 +655,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case detailsMsg: case detailsMsg:
if msg.req == m.reqID[slotDetails] { if msg.req == m.reqID[slotDetails] {
m.done(slotDetails) m.cancelReq(slotDetails)
} }
if errors.Is(msg.err, context.Canceled) || errors.Is(msg.err, context.DeadlineExceeded) { if errors.Is(msg.err, context.Canceled) || errors.Is(msg.err, context.DeadlineExceeded) {
delete(m.details, msg.key) // tried again when shown again delete(m.details, msg.key) // tried again when shown again
@@ -685,7 +686,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotContainers] { if msg.req != m.reqID[slotContainers] {
break break
} }
m.done(slotContainers) m.cancelReq(slotContainers)
if msg.err != nil { if msg.err != nil {
m.noteErr(msg.err) m.noteErr(msg.err)
if src, ok := m.selectedSource(); ok { if src, ok := m.selectedSource(); ok {
@@ -703,7 +704,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotObjects] { if msg.req != m.reqID[slotObjects] {
break break
} }
m.done(slotObjects) m.cancelReq(slotObjects)
if errors.Is(msg.err, provider.ErrUnsupported) && m.versionsOf != nil { if errors.Is(msg.err, provider.ErrUnsupported) && m.versionsOf != nil {
m.flash = warning(fmt.Sprintf("versions aren't supported for %s", m.versionsOf.Container.Source.Kind)) m.flash = warning(fmt.Sprintf("versions aren't supported for %s", m.versionsOf.Container.Source.Kind))
m.closeVersions() m.closeVersions()
@@ -733,7 +734,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotValue] { if msg.req != m.reqID[slotValue] {
break break
} }
m.done(slotValue) m.cancelReq(slotValue)
if msg.err != nil { if msg.err != nil {
m.noteErr(msg.err) m.noteErr(msg.err)
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err) m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
@@ -748,7 +749,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotVersions] { if msg.req != m.reqID[slotVersions] {
break break
} }
m.done(slotVersions) m.cancelReq(slotVersions)
// Errors and unsupported providers just leave the count out. // Errors and unsupported providers just leave the count out.
if msg.err == nil { if msg.err == nil {
m.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()} m.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()}
@@ -760,7 +761,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotDescribe] { if msg.req != m.reqID[slotDescribe] {
break break
} }
m.done(slotDescribe) m.cancelReq(slotDescribe)
if msg.err != nil { if msg.err != nil {
m.noteErr(msg.err) m.noteErr(msg.err)
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err) m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
@@ -775,7 +776,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotCopy] { if msg.req != m.reqID[slotCopy] {
break break
} }
m.done(slotCopy) m.cancelReq(slotCopy)
if msg.err != nil { if msg.err != nil {
m.noteErr(msg.err) m.noteErr(msg.err)
m.flash = flash{text: "copy failed: " + msg.err.Error(), isErr: true} m.flash = flash{text: "copy failed: " + msg.err.Error(), isErr: true}
@@ -887,7 +888,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
case key.Matches(msg, k.Quit): case key.Matches(msg, k.Quit):
return nil, true return nil, true
case key.Matches(msg, k.Help): case key.Matches(msg, k.Help):
return m.showKeybindings(), false return m.showKeybindingsFor(""), false
case key.Matches(msg, k.Up): case key.Matches(msg, k.Up):
m.move(-1) m.move(-1)
case key.Matches(msg, k.Down): case key.Matches(msg, k.Down):
@@ -1167,14 +1168,11 @@ func (m *Model) syncSelection() tea.Cmd {
if m.onLinks() { if m.onLinks() {
m.showLinkedContainers() m.showLinkedContainers()
} else if src, ok := m.selectedSource(); ok { } else if src, ok := m.selectedSource(); ok {
m.containers.Title = "[2] Vaults"
m.containers.Empty = "no vaults found" m.containers.Empty = "no vaults found"
switch src.Kind { switch src.Kind {
case provider.KindDatabricks: case provider.KindDatabricks:
m.containers.Title = "[2] Scopes"
m.containers.Empty = "no secret scopes" m.containers.Empty = "no secret scopes"
case provider.KindRepo: case provider.KindRepo:
m.containers.Title = "[2] Files"
m.containers.Empty = "no SOPS or env files here (see docs/repos.md)" m.containers.Empty = "no SOPS or env files here (see docs/repos.md)"
} }
cmds = append(cmds, m.showContainers(src, id)) cmds = append(cmds, m.showContainers(src, id))
@@ -1319,8 +1317,7 @@ func (m *Model) countVersions(o provider.Object) tea.Cmd {
}) })
} }
// closeVersions returns the Secrets pane to the object list, with the // closeVersions returns the Secrets pane to the object list.
// cursor on the object whose versions were shown.
func (m *Model) closeVersions() { func (m *Model) closeVersions() {
m.versionsOf = nil m.versionsOf = nil
} }
@@ -1517,9 +1514,6 @@ func (m *Model) settled(ctx context.Context, load tea.Cmd) tea.Cmd {
} }
} }
// done releases the context of a completed request.
func (m *Model) done(p slot) { m.cancelReq(p) }
// loadSources starts a new generation of source loads, superseding any in // loadSources starts a new generation of source loads, superseding any in
// flight. // flight.
func (m *Model) loadSources() tea.Cmd { func (m *Model) loadSources() tea.Cmd {
@@ -1639,7 +1633,7 @@ func (m *Model) describe(o provider.Object) tea.Cmd {
func (m *Model) copyValue() tea.Cmd { func (m *Model) copyValue() tea.Cmd {
if m.focus == paneDetail && m.fields.Shown() { if m.focus == paneDetail && m.fields.Shown() {
if f, ok := m.fields.Selected(); ok { if f, ok := m.fields.Selected(); ok {
return m.copyText(fieldName(f), cmpOr(f.URL, f.Value), false) return m.copyText(fieldName(f), cmp.Or(f.URL, f.Value), false)
} }
return nil return nil
} }
+4 -4
View File
@@ -991,11 +991,11 @@ func TestRebindingShowsEverywhere(t *testing.T) {
if !strings.Contains(scr, "z 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), "z show") { if !strings.Contains(lastLines(scr, 1), "z show") {
t.Errorf("help bar: %q", lastLine(scr)) t.Errorf("help bar: %q", lastLines(scr, 1))
} }
d.keys("z") d.keys("z")
if bar := lastLine(d.screen()); !strings.Contains(bar, "z hide") { if bar := lastLines(d.screen(), 1); !strings.Contains(bar, "z hide") {
t.Errorf("help bar with the value shown: %q", bar) t.Errorf("help bar with the value shown: %q", bar)
} }
d.keys("z") d.keys("z")
@@ -1125,7 +1125,7 @@ func TestAccountPane(t *testing.T) {
if !strings.Contains(scr, "Alice Example") { if !strings.Contains(scr, "Alice Example") {
t.Errorf("account not shown:\n%s", scr) t.Errorf("account not shown:\n%s", scr)
} }
bar := lastLine(scr) bar := lastLines(scr, 1)
if !strings.Contains(bar, "copy") || strings.Contains(bar, "reveal") || strings.Contains(bar, "versions") { if !strings.Contains(bar, "copy") || strings.Contains(bar, "reveal") || strings.Contains(bar, "versions") {
t.Errorf("help bar: %q", bar) t.Errorf("help bar: %q", bar)
} }
+1 -1
View File
@@ -66,7 +66,7 @@ func (m *Model) refreshShown(before []provider.Object) tea.Cmd {
if i < 0 || sameListing(before[i], s) { if i < 0 || sameListing(before[i], s) {
return nil return nil
} }
m.detail.Refresh(s) m.detail.SetDescribed(s)
uncache(m, m.cache.details, detailKey(s)) uncache(m, m.cache.details, detailKey(s))
return m.describe(s) return m.describe(s)
} }
+8 -21
View File
@@ -1,6 +1,7 @@
package app package app
import ( import (
"cmp"
"context" "context"
"fmt" "fmt"
"os" "os"
@@ -84,10 +85,7 @@ func (m *Model) showCommands() tea.Cmd {
} }
var opts []option var opts []option
for _, c := range cmds { for _, c := range cmds {
label := c.Description label := cmp.Or(c.Description, c.Command)
if label == "" {
label = c.Command
}
if c.Key != "" { if c.Key != "" {
label = m.st.Label.Render(fmt.Sprintf("%-8s", c.Key)) + label label = m.st.Label.Render(fmt.Sprintf("%-8s", c.Key)) + label
} }
@@ -122,7 +120,7 @@ func (m *Model) askPrompt(c commands.Command, it commands.Item, obj *provider.Ob
in.SetValue(p.Default) in.SetValue(p.Default)
in.CursorEnd() in.CursorEnd()
m.prompt = &modal{ m.prompt = &modal{
title: cmpOr(c.Description, "Command"), input: &in, title: cmp.Or(c.Description, "Command"), input: &in,
submit: func(m *Model, text string) tea.Cmd { submit: func(m *Model, text string) tea.Cmd {
it.Prompt[p.Name] = commands.Q(text) it.Prompt[p.Name] = commands.Q(text)
return m.askPrompt(c, it, obj, i+1) return m.askPrompt(c, it, obj, i+1)
@@ -151,7 +149,7 @@ func (m *Model) confirmCommand(c commands.Command, it commands.Item, obj *provid
case strings.HasPrefix(c.Value, "env:"): case strings.HasPrefix(c.Value, "env:"):
lines = append(lines, "", "It gets the value of "+string(it.Name)+" in $"+strings.TrimPrefix(c.Value, "env:")+".") lines = append(lines, "", "It gets the value of "+string(it.Name)+" in $"+strings.TrimPrefix(c.Value, "env:")+".")
} }
m.showMenu("Run "+cmpOr(c.Description, "command")+"?", lines, []option{ m.showMenu("Run "+cmp.Or(c.Description, "command")+"?", lines, []option{
{key: "y", label: "Run it", run: func(m *Model) tea.Cmd { return m.runCommand(c, rendered, obj) }}, {key: "y", label: "Run it", run: func(m *Model) tea.Cmd { return m.runCommand(c, rendered, obj) }},
}) })
return nil return nil
@@ -177,7 +175,7 @@ func (m *Model) runCommand(c commands.Command, rendered string, obj *provider.Ob
// execCommand runs the command as its output mode says. // execCommand runs the command as its output mode says.
func (m *Model) execCommand(c commands.Command, rendered string, value provider.SecretValue) tea.Cmd { func (m *Model) execCommand(c commands.Command, rendered string, value provider.SecretValue) tea.Cmd {
shell := cmpOr(os.Getenv("SHELL"), "/bin/sh") shell := cmp.Or(os.Getenv("SHELL"), "/bin/sh")
setValue := func(cmd *exec.Cmd) { setValue := func(cmd *exec.Cmd) {
switch { switch {
case c.Value == "stdin": case c.Value == "stdin":
@@ -195,7 +193,7 @@ func (m *Model) execCommand(c commands.Command, rendered string, value provider.
m.flash = flash{} m.flash = flash{}
return tea.ExecProcess(cmd, func(err error) tea.Msg { return commandDoneMsg{cmd: c, err: err} }) return tea.ExecProcess(cmd, func(err error) tea.Msg { return commandDoneMsg{cmd: c, err: err} })
} }
m.flash = flash{text: "running " + cmpOr(c.Description, "command") + "…"} m.flash = flash{text: "running " + cmp.Or(c.Description, "command") + "…"}
return func() tea.Msg { return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout) ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
defer cancel() defer cancel()
@@ -219,7 +217,7 @@ func (m *Model) handleCommandValue(msg commandValueMsg) tea.Cmd {
const maxOutputLines = 30 const maxOutputLines = 30
func (m *Model) handleCommandDone(msg commandDoneMsg) { func (m *Model) handleCommandDone(msg commandDoneMsg) {
name := cmpOr(msg.cmd.Description, "command") name := cmp.Or(msg.cmd.Description, "command")
if msg.cmd.Out() != commands.Popup || m.prompt != nil || m.search != nil { if msg.cmd.Out() != commands.Popup || m.prompt != nil || m.search != nil {
switch { switch {
case msg.err != nil: case msg.err != nil:
@@ -298,11 +296,7 @@ func (m *Model) commandItem(ctx string) (commands.Item, *provider.Object, bool)
if r, err := ref.ForObject(o, version); err == nil { if r, err := ref.ForObject(o, version); err == nil {
it.Link = commands.Q(r.String()) it.Link = commands.Q(r.String())
} }
if version != "" { o.Version = version // "": the current value, like copying it
o.Version = version
} else {
o.Version = "" // the current value, like copying it
}
return it, &o, true return it, &o, true
} }
@@ -312,10 +306,3 @@ func newTextInput(prompt string, width int) textinput.Model {
in.SetWidth(width) in.SetWidth(width)
return in return in
} }
func cmpOr(a, b string) string {
if a != "" {
return a
}
return b
}
+2 -1
View File
@@ -8,6 +8,7 @@ import (
"time" "time"
"git.kebcloud.no/keb/lazysecrets/internal/commands" "git.kebcloud.no/keb/lazysecrets/internal/commands"
"github.com/charmbracelet/x/ansi"
) )
func commandDriver(t *testing.T, cmds ...commands.Command) *driver { func commandDriver(t *testing.T, cmds ...commands.Command) *driver {
@@ -34,7 +35,7 @@ func TestCommandGetsValueOnInput(t *testing.T) {
// temporary path's length. // temporary path's length.
shown := "" shown := ""
if d.m.prompt != nil { if d.m.prompt != nil {
shown = ansiStrip(strings.Join(d.m.prompt.lines, "")) shown = ansi.Strip(strings.Join(d.m.prompt.lines, ""))
} }
if !strings.Contains(scr, "Run Save it?") || !strings.Contains(shown, "db-password 'team a'") || !strings.Contains(shown, "on its input") { if !strings.Contains(scr, "Run Save it?") || !strings.Contains(shown, "db-password 'team a'") || !strings.Contains(shown, "on its input") {
t.Fatalf("no confirmation with the command %q:\n%s", shown, scr) t.Fatalf("no confirmation with the command %q:\n%s", shown, scr)
+1 -1
View File
@@ -242,7 +242,7 @@ func (m *Model) openInEditor(f provider.File, finished func(error) tea.Msg) tea.
return tea.QuitMsg{} return tea.QuitMsg{}
} }
} }
if editor := cmpOr(m.getenv("VISUAL"), m.getenv("EDITOR")); editor != "" { if editor := cmp.Or(m.getenv("VISUAL"), m.getenv("EDITOR")); editor != "" {
// Through the shell, so EDITOR can carry arguments ("code -w"). // Through the shell, so EDITOR can carry arguments ("code -w").
args := append([]string{"-c", editor + ` "$@"`, "lazysecrets"}, editorArgs(editor, f)...) args := append([]string{"-c", editor + ` "$@"`, "lazysecrets"}, editorArgs(editor, f)...)
m.flash = flash{} m.flash = flash{}
+5 -4
View File
@@ -6,6 +6,7 @@ import (
"slices" "slices"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile" "git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref" "git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes" "git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
@@ -30,7 +31,7 @@ func (m Model) expiringItems() []expiringItem {
for _, c := range m.cache.containers[row].value { for _, c := range m.cache.containers[row].value {
for _, t := range c.ObjectTypes() { for _, t := range c.ObjectTypes() {
for _, o := range m.cache.objects[cachefile.ObjectsKey(c, t)].value { for _, o := range m.cache.objects[cachefile.ObjectsKey(c, t)].value {
if state, _ := m.expiry(o); state != notExpiring { if state, _ := m.expiry(o); state != expiry.None {
out = append(out, expiringItem{o, row}) out = append(out, expiringItem{o, row})
} }
} }
@@ -51,11 +52,11 @@ func (m Model) expiringFields() []panes.Field {
var out []panes.Field var out []panes.Field
for _, it := range items { for _, it := range items {
state, left := m.expiry(it.o) state, left := m.expiry(it.o)
mark := "⚠ " + daysLeft(left) mark := "⚠ " + expiry.Left(left)
if state == expired { if state == expiry.Expired {
mark = "✗ expired" mark = "✗ expired"
} }
f := panes.Field{Value: mark + " " + it.o.Name + " · " + it.o.Container.Name, Err: state == expired, Row: it.row} f := panes.Field{Value: mark + " " + it.o.Name + " · " + it.o.Container.Name, Err: state == expiry.Expired, Row: it.row}
if r, err := ref.ForObject(it.o, ""); err == nil { if r, err := ref.ForObject(it.o, ""); err == nil {
f.Link = r.String() f.Link = r.String()
} }
+3 -15
View File
@@ -4,7 +4,6 @@ import (
"time" "time"
"git.kebcloud.no/keb/lazysecrets/internal/expiry" "git.kebcloud.no/keb/lazysecrets/internal/expiry"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
) )
@@ -12,30 +11,19 @@ import (
// list once they have expired (✗ expired) or will within the configured // list once they have expired (✗ expired) or will within the configured
// warning period (⚠ 12d), and typing "expir" in the filter finds them. // warning period (⚠ 12d), and typing "expir" in the filter finds them.
type expiryState = expiry.State
const (
notExpiring = expiry.None
expiresSoon = expiry.Soon
expired = expiry.Expired
)
// expiry tells whether o has expired or will within the warning period, // expiry tells whether o has expired or will within the warning period,
// and how long it has left. // and how long it has left.
func (m Model) expiry(o provider.Object) (expiryState, time.Duration) { func (m Model) expiry(o provider.Object) (expiry.State, time.Duration) {
return expiry.Of(o.Expires, m.now(), m.expiryWarning) return expiry.Of(o.Expires, m.now(), m.expiryWarning)
} }
// expiryWord is matched by the filter: "expired" or "expiring". // expiryWord is matched by the filter: "expired" or "expiring".
func (m Model) expiryWord(o provider.Object) string { func (m Model) expiryWord(o provider.Object) string {
switch state, _ := m.expiry(o); state { switch state, _ := m.expiry(o); state {
case expired: case expiry.Expired:
return "expired" return "expired"
case expiresSoon: case expiry.Soon:
return "expiring" return "expiring"
} }
return "" return ""
} }
// daysLeft is a short "12d", or "5h" on the last day.
func daysLeft(d time.Duration) string { return expiry.Left(d) }
+4 -3
View File
@@ -8,6 +8,7 @@ import (
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile" "git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref" "git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store" "git.kebcloud.no/keb/lazysecrets/internal/store"
@@ -134,10 +135,10 @@ func (m Model) objectRender() func(styles.Styles, provider.Object, int) string {
} }
line = marks(sty, n, o.Container.Source.Provider, m.recentIcon, m.now()) + line line = marks(sty, n, o.Container.Source.Provider, m.recentIcon, m.now()) + line
switch state, left := m.expiry(o); state { switch state, left := m.expiry(o); state {
case expired: case expiry.Expired:
line += " " + sty.Error.Render("✗ expired") line += " " + sty.Error.Render("✗ expired")
case expiresSoon: case expiry.Soon:
line += " " + sty.Warning.Render("⚠ "+daysLeft(left)) line += " " + sty.Warning.Render("⚠ "+expiry.Left(left))
} }
return withNote(sty, line, n.Note, width) return withNote(sty, line, n.Note, width)
} }
+2 -1
View File
@@ -14,6 +14,7 @@ import (
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake" "git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
"git.kebcloud.no/keb/lazysecrets/internal/store" "git.kebcloud.no/keb/lazysecrets/internal/store"
"github.com/charmbracelet/x/ansi"
) )
// jump opens the favourites list, filters it and jumps to the first match. // jump opens the favourites list, filters it and jumps to the first match.
@@ -429,7 +430,7 @@ func TestStarIsPerAccount(t *testing.T) {
d.keys("F") d.keys("F")
var labels []string var labels []string
for _, o := range d.m.search.shown { for _, o := range d.m.search.shown {
labels = append(labels, ansiStrip(o.label)) labels = append(labels, ansi.Strip(o.label))
} }
if len(labels) != 1 || !strings.Contains(labels[0], "platform-prod · alice") { if len(labels) != 1 || !strings.Contains(labels[0], "platform-prod · alice") {
t.Errorf("favourites should list alice's star only: %q", labels) t.Errorf("favourites should list alice's star only: %q", labels)
+5 -4
View File
@@ -1,6 +1,7 @@
package app package app
import ( import (
"cmp"
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
@@ -61,7 +62,7 @@ func (m *Model) syncFields() tea.Cmd {
case !ok: case !ok:
m.fields.Show("", nil) m.fields.Show("", nil)
case m.selectedProblem() != "": case m.selectedProblem() != "":
m.fields.Show(cmpOr(r.label, r.source.Name), m.problemFields(m.selectedProblem())) m.fields.Show(cmp.Or(r.label, r.source.Name), m.problemFields(m.selectedProblem()))
case r.kind == rowGroup: case r.kind == rowGroup:
m.showAccount(r) m.showAccount(r)
case r.kind == rowSource: case r.kind == rowSource:
@@ -134,7 +135,7 @@ func (m Model) sourceFields(src provider.Source) []panes.Field {
add("web", src.URL) add("web", src.URL)
if e, ok := m.cache.containers[sourceRowKey(src.Provider, src.ID)]; ok { if e, ok := m.cache.containers[sourceRowKey(src.Provider, src.ID)]; ok {
label := map[provider.Kind]string{provider.KindDatabricks: "scopes", provider.KindRepo: "files"}[src.Kind] label := map[provider.Kind]string{provider.KindDatabricks: "scopes", provider.KindRepo: "files"}[src.Kind]
add(cmpOr(label, "vaults"), fmt.Sprint(len(e.value))) add(cmp.Or(label, "vaults"), fmt.Sprint(len(e.value)))
} }
return out return out
} }
@@ -158,7 +159,7 @@ func (m Model) containerFields(c provider.Container) []panes.Field {
case provider.KindRepo: case provider.KindRepo:
add("format", c.Detail) add("format", c.Detail)
} }
add("source", cmpOr(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name) add("source", cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
add("web", c.URL) add("web", c.URL)
for _, t := range c.ObjectTypes() { for _, t := range c.ObjectTypes() {
if e, ok := m.cache.objects[cachefile.ObjectsKey(c, t)]; ok { if e, ok := m.cache.objects[cachefile.ObjectsKey(c, t)]; ok {
@@ -221,5 +222,5 @@ func (m Model) problemFields(problem string) []panes.Field {
// fieldName names a copied field: its label, or a row of several under // fieldName names a copied field: its label, or a row of several under
// one label (a tag), its value. // one label (a tag), its value.
func fieldName(f panes.Field) string { func fieldName(f panes.Field) string {
return cmpOr(f.Label, f.Value) return cmp.Or(f.Label, f.Value)
} }
+3 -7
View File
@@ -1,6 +1,7 @@
package app package app
import ( import (
"cmp"
"fmt" "fmt"
"slices" "slices"
"strings" "strings"
@@ -16,7 +17,7 @@ import (
) )
// The help bar shows the keys that matter in the focused pane; ? lists all // The help bar shows the keys that matter in the focused pane; ? lists all
// of them in a menu (showKeybindings). // of them in a menu (showKeybindingsFor).
// newHelp styles the help bar to read clearly: keys like labels elsewhere, // newHelp styles the help bar to read clearly: keys like labels elsewhere,
// descriptions in the normal text colour, only the separators dimmed. The // descriptions in the normal text colour, only the separators dimmed. The
@@ -84,11 +85,6 @@ func (c contextHelp) FullHelp() [][]key.Binding { return [][]key.Binding{c.Short
var paneNames = [paneCount]string{"Sources", "Vaults", "Secrets", "Detail", "Status"} var paneNames = [paneCount]string{"Sources", "Vaults", "Secrets", "Detail", "Status"}
// showKeybindings opens the ? menu, like lazygit's: every binding with
// what it does, the focused pane's first, filterable by key, description
// and group. Enter runs the selected one.
func (m *Model) showKeybindings() tea.Cmd { return m.showKeybindingsFor("") }
// showKeybindingsFor opens the ? menu, like lazygit's: every binding with // showKeybindingsFor opens the ? menu, like lazygit's: every binding with
// what it does, filterable by key, description and group; enter runs the // what it does, filterable by key, description and group; enter runs the
// selected one. Opened from the panes, it lists the focused pane's keys // selected one. Opened from the panes, it lists the focused pane's keys
@@ -154,7 +150,7 @@ func (m *Model) showKeybindingsFor(ctx string) tea.Cmd {
if cmds := m.commandsHere(); ctx == "" && len(cmds) > 0 { if cmds := m.commandsHere(); ctx == "" && len(cmds) > 0 {
opts = append(opts, option{label: "Custom commands", header: true}) opts = append(opts, option{label: "Custom commands", header: true})
for _, c := range cmds { for _, c := range cmds {
label := m.st.Label.Render(fmt.Sprintf("%-14s", c.Key)) + fmt.Sprintf("%-26s", cmpOr(c.Description, "command")) + m.st.Muted.Render("custom") label := m.st.Label.Render(fmt.Sprintf("%-14s", c.Key)) + fmt.Sprintf("%-26s", cmp.Or(c.Description, "command")) + m.st.Muted.Render("custom")
opts = append(opts, option{label: label, tip: c.Command, run: func(m *Model) tea.Cmd { return m.startCommand(c) }}) opts = append(opts, option{label: label, tip: c.Command, run: func(m *Model) tea.Cmd { return m.startCommand(c) }})
} }
} }
+3 -8
View File
@@ -136,11 +136,11 @@ func TestLayoutIsSaved(t *testing.T) {
func TestContextHelpBar(t *testing.T) { func TestContextHelpBar(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t) d := newDriver(t)
if bar := lastLine(d.screen()); !strings.Contains(bar, "S share") || !strings.HasSuffix(strings.TrimSpace(bar), "? help • q quit") || strings.Contains(bar, "space show") { if bar := lastLines(d.screen(), 1); !strings.Contains(bar, "S share") || !strings.HasSuffix(strings.TrimSpace(bar), "? help • q quit") || strings.Contains(bar, "space show") {
t.Errorf("Sources help bar: %q", bar) t.Errorf("Sources help bar: %q", bar)
} }
d.keys("3") d.keys("3")
if bar := lastLine(d.screen()); !strings.Contains(bar, "s copy link") || !strings.Contains(bar, "S share") { if bar := lastLines(d.screen(), 1); !strings.Contains(bar, "s copy link") || !strings.Contains(bar, "S share") {
t.Errorf("Secrets help bar: %q", bar) t.Errorf("Secrets help bar: %q", bar)
} }
} }
@@ -225,11 +225,6 @@ func TestKeyPress(t *testing.T) {
} }
} }
func lastLine(s string) string {
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
return lines[len(lines)-1]
}
func TestNoHighlightWhileTerminalBlurred(t *testing.T) { func TestNoHighlightWhileTerminalBlurred(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t) d := newDriver(t)
@@ -264,7 +259,7 @@ func TestVersionInStatus(t *testing.T) {
if !strings.Contains(d.screen(), "[0] lazysecrets v0.1.0") { if !strings.Contains(d.screen(), "[0] lazysecrets v0.1.0") {
t.Errorf("no version in the status pane:\n%s", d.screen()) t.Errorf("no version in the status pane:\n%s", d.screen())
} }
if bar := lastLine(d.screen()); strings.Contains(bar, "v0.1.0") || !strings.Contains(bar, "? help • q quit") { if bar := lastLines(d.screen(), 1); strings.Contains(bar, "v0.1.0") || !strings.Contains(bar, "? help • q quit") {
t.Errorf("help line %q", bar) t.Errorf("help line %q", bar)
} }
} }
+4 -3
View File
@@ -11,6 +11,7 @@ import (
"charm.land/bubbles/v2/textinput" "charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2" 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/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref" "git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes" "git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
@@ -433,7 +434,7 @@ func (m *Model) handleResolved(msg resolvedMsg) {
// (ref.Ref.Account), if it's one of them. // (ref.Ref.Account), if it's one of them.
func preferredRow(r ref.Ref, choices []choice) (string, bool) { func preferredRow(r ref.Ref, choices []choice) (string, bool) {
for _, c := range choices { for _, c := range choices {
if r.Account != "" && providerOf(c.sourceRow) == r.Account { if r.Account != "" && cachefile.ProviderOf(c.sourceRow) == r.Account {
return c.sourceRow, true return c.sourceRow, true
} }
} }
@@ -686,7 +687,7 @@ func (m *Model) share(menu bool) tea.Cmd {
share := func(k string) func(*Model) tea.Cmd { share := func(k string) func(*Model) tea.Cmd {
return func(m *Model) tea.Cmd { return m.shareContainer(c, k) } return func(m *Model) tea.Cmd { return m.shareContainer(c, k) }
} }
opts := []option{option{key: "o", label: "Lazysecrets link", run: share("o")}} opts := []option{{key: "o", label: "Lazysecrets link", run: share("o")}}
if c.Source.Kind == provider.KindAzure && m.objType != provider.TypeSecret { if c.Source.Kind == provider.KindAzure && m.objType != provider.TypeSecret {
opts = append(opts, option{key: "t", label: "Lazysecrets link to the " + tabLabels[m.objType] + " tab", run: share("t")}) opts = append(opts, option{key: "t", label: "Lazysecrets link to the " + tabLabels[m.objType] + " tab", run: share("t")})
} }
@@ -714,7 +715,7 @@ func (m *Model) share(menu bool) tea.Cmd {
return func(m *Model) tea.Cmd { return m.shareObject(o, k) } return func(m *Model) tea.Cmd { return m.shareObject(o, k) }
} }
opts := []option{ opts := []option{
option{key: "o", label: "Lazysecrets link", run: share("o")}, {key: "o", label: "Lazysecrets link", run: share("o")},
} }
if o.Container.Source.Kind == provider.KindAzure { if o.Container.Source.Kind == provider.KindAzure {
opts = append(opts, option{key: "u", label: "Key Vault URL", run: share("u")}) opts = append(opts, option{key: "u", label: "Key Vault URL", run: share("u")})
+5 -7
View File
@@ -77,17 +77,15 @@ func TestNotesOnRows(t *testing.T) {
func TestWithNoteGivesWay(t *testing.T) { func TestWithNoteGivesWay(t *testing.T) {
t.Parallel() t.Parallel()
st := styles.Default() st := styles.Default()
if got := withNote(st, "db-password", "prod SQL admin", 30); len([]rune(stripANSI(got))) != 30 || if got := withNote(st, "db-password", "prod SQL admin", 30); len([]rune(ansi.Strip(got))) != 30 ||
!strings.HasSuffix(stripANSI(got), "prod SQL admin") { !strings.HasSuffix(ansi.Strip(got), "prod SQL admin") {
t.Errorf("fits: %q", stripANSI(got)) t.Errorf("fits: %q", ansi.Strip(got))
} }
if got := stripANSI(withNote(st, "db-password", "prod SQL admin", 20)); !strings.HasPrefix(got, "db-password") || if got := ansi.Strip(withNote(st, "db-password", "prod SQL admin", 20)); !strings.HasPrefix(got, "db-password") ||
!strings.HasSuffix(got, "…") { !strings.HasSuffix(got, "…") {
t.Errorf("tight: %q", got) t.Errorf("tight: %q", got)
} }
if got := stripANSI(withNote(st, "a-very-long-secret-name", "note", 20)); got != "a-very-long-secret-name" { if got := ansi.Strip(withNote(st, "a-very-long-secret-name", "note", 20)); got != "a-very-long-secret-name" {
t.Errorf("no room: %q", got) t.Errorf("no room: %q", got)
} }
} }
func stripANSI(s string) string { return ansi.Strip(s) }
+3 -2
View File
@@ -16,6 +16,7 @@ import (
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo" "git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
"git.kebcloud.no/keb/lazysecrets/internal/ref" "git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store" "git.kebcloud.no/keb/lazysecrets/internal/store"
"github.com/charmbracelet/x/ansi"
) )
const sopsFile = `db: const sopsFile = `db:
@@ -225,9 +226,9 @@ func TestSearchAllRepos(t *testing.T) {
var got []string var got []string
for _, o := range d.m.search.shown { for _, o := range d.m.search.shown {
if o.kind != provider.KindRepo { if o.kind != provider.KindRepo {
t.Errorf("not a repo: %s", ansiStrip(o.label)) t.Errorf("not a repo: %s", ansi.Strip(o.label))
} }
got = append(got, ansiStrip(o.label)) got = append(got, ansi.Strip(o.label))
} }
if len(got) != 2 { if len(got) != 2 {
t.Errorf("want db.password in both repos: %v", got) t.Errorf("want db.password in both repos: %v", got)
+7 -23
View File
@@ -6,7 +6,6 @@ import (
"fmt" "fmt"
"slices" "slices"
"strings" "strings"
"time"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
@@ -46,10 +45,6 @@ type crawlMsg struct {
err error err error
} }
func contextWithTimeout(d time.Duration) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), d)
}
// searchEntries lists everything loaded: the sources (with how much of // searchEntries lists everything loaded: the sources (with how much of
// each is loaded) and the vaults, scopes and secrets in them, each with // each is loaded) and the vaults, scopes and secrets in them, each with
// its source's row key as group. // its source's row key as group.
@@ -67,7 +62,7 @@ func (m *Model) searchEntries() (sources, items []option) {
sort := strings.ToLower(strings.Join(path, "/")) + " " + account sort := strings.ToLower(strings.Join(path, "/")) + " " + account
es = append(es, entry{depth, sort, es = append(es, entry{depth, sort,
option{label: m.pathLabel(kind, path, account), note: n.Note, star: key, option{label: m.pathLabel(kind, path, account), note: n.Note, star: key,
starAccount: providerOf(row), starred: n.StarredFor(providerOf(row)), starAccount: cachefile.ProviderOf(row), starred: n.StarredFor(cachefile.ProviderOf(row)),
mark: mark, run: run, reload: reload, id: sort, group: row, kind: kind}}) mark: mark, run: run, reload: reload, id: sort, group: row, kind: kind}})
return &es[len(es)-1].opt return &es[len(es)-1].opt
} }
@@ -134,8 +129,6 @@ func (c coverage) complete() bool {
return c.sourcesListed == c.sources && c.vaultsLoaded == c.vaults return c.sourcesListed == c.sources && c.vaultsLoaded == c.vaults
} }
func (m *Model) searchCoverage() coverage { return m.coverageOf(nil) }
// providerFilter picks the providers a search or a load covers; nil is // providerFilter picks the providers a search or a load covers; nil is
// all of them. // all of them.
type providerFilter func(provider.Provider) bool type providerFilter func(provider.Provider) bool
@@ -148,7 +141,7 @@ func ofProvider(id string) providerFilter {
return func(p provider.Provider) bool { return p.ID() == id } return func(p provider.Provider) bool { return p.ID() == id }
} }
// coverageOf is searchCoverage for the providers in. // coverageOf is the coverage of the providers in.
func (m *Model) coverageOf(in providerFilter) coverage { func (m *Model) coverageOf(in providerFilter) coverage {
var c coverage var c coverage
for _, p := range m.providers { for _, p := range m.providers {
@@ -183,7 +176,7 @@ func (m *Model) coverageOf(in providerFilter) coverage {
// searchLines says what the search covers. // searchLines says what the search covers.
func (m *Model) searchLines() []string { func (m *Model) searchLines() []string {
c := m.searchCoverage() c := m.coverageOf(nil)
switch { switch {
case m.crawl.running: case m.crawl.running:
return []string{m.st.Notice.Render(fmt.Sprintf("Loading… %d lists left; results appear as they arrive.", return []string{m.st.Notice.Render(fmt.Sprintf("Loading… %d lists left; results appear as they arrive.",
@@ -210,11 +203,8 @@ func plural(n int, word string) string {
return fmt.Sprintf("%d %ss", n, word) return fmt.Sprintf("%d %ss", n, word)
} }
// startCrawl loads every unlisted subscription's vaults and every vault's // startCrawlOf loads, for the providers in, every unlisted subscription's
// secrets not loaded yet. // vaults and every vault's secrets not loaded yet.
func (m *Model) startCrawl() tea.Cmd { return m.startCrawlOf(nil) }
// startCrawlOf is startCrawl for the providers in.
func (m *Model) startCrawlOf(in providerFilter) tea.Cmd { func (m *Model) startCrawlOf(in providerFilter) tea.Cmd {
for _, p := range m.providers { for _, p := range m.providers {
if in != nil && !in(p) { if in != nil && !in(p) {
@@ -301,7 +291,7 @@ func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
src := *job.src src := *job.src
timeout := m.timeout.of(src.Kind) timeout := m.timeout.of(src.Kind)
return func() tea.Msg { return func() tea.Msg {
ctx, cancel := contextWithTimeout(timeout) ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel() defer cancel()
items, err := p.Containers(ctx, src) items, err := p.Containers(ctx, src)
for i := range items { for i := range items {
@@ -314,7 +304,7 @@ func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
p := m.providerFor(c.Source) p := m.providerFor(c.Source)
timeout := m.timeout.of(c.Source.Kind) timeout := m.timeout.of(c.Source.Kind)
return func() tea.Msg { return func() tea.Msg {
ctx, cancel := contextWithTimeout(timeout) ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel() defer cancel()
items, err := p.Objects(ctx, c, provider.TypeSecret) items, err := p.Objects(ctx, c, provider.TypeSecret)
for i := range items { for i := range items {
@@ -396,9 +386,3 @@ func (m *Model) vaultMark(c provider.Container) string {
} }
return " " return " "
} }
// providerOf is the provider ID in a source row key.
func providerOf(row string) string {
p, _ := splitSourceRow(row)
return p
}
+19 -20
View File
@@ -6,6 +6,7 @@ import (
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake" "git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
"github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/ansi"
) )
@@ -115,19 +116,19 @@ func TestSearchSourceNarrowsAndLoads(t *testing.T) {
t.Fatal("platform-dev not in sources") t.Fatal("platform-dev not in sources")
} }
} }
if !strings.Contains(ansiStrip(d.m.search.scopes[d.m.search.src].mark), "○") { if !strings.Contains(ansi.Strip(d.m.search.scopes[d.m.search.src].mark), "○") {
t.Errorf("unloaded source mark %q", d.m.search.scopes[d.m.search.src].mark) t.Errorf("unloaded source mark %q", d.m.search.scopes[d.m.search.src].mark)
} }
if len(d.m.search.shown) != 0 { if len(d.m.search.shown) != 0 {
t.Errorf("results not narrowed to platform-dev: %d", len(d.m.search.shown)) t.Errorf("results not narrowed to platform-dev: %d", len(d.m.search.shown))
} }
d.send(ctrl('r')) d.send(ctrl('r'))
if !strings.Contains(ansiStrip(d.m.search.scopes[d.m.search.src].mark), "●") { if !strings.Contains(ansi.Strip(d.m.search.scopes[d.m.search.src].mark), "●") {
t.Errorf("loaded source mark %q", d.m.search.scopes[d.m.search.src].mark) t.Errorf("loaded source mark %q", d.m.search.scopes[d.m.search.src].mark)
} }
for _, o := range d.m.search.shown { for _, o := range d.m.search.shown {
if !strings.Contains(ansiStrip(o.label), "platform-dev") { if !strings.Contains(ansi.Strip(o.label), "platform-dev") {
t.Errorf("result from elsewhere: %q", ansiStrip(o.label)) t.Errorf("result from elsewhere: %q", ansi.Strip(o.label))
} }
} }
if len(d.m.search.shown) == 0 { if len(d.m.search.shown) == 0 {
@@ -177,19 +178,17 @@ func TestStarFromSearch(t *testing.T) {
} }
} }
func ansiStrip(s string) string { return ansi.Strip(s) }
func TestSearchStartsWhereYouAre(t *testing.T) { func TestSearchStartsWhereYouAre(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t) d := newDriver(t)
d.keys("2", "j") // platform-prod / kv-shared-prod d.keys("2", "j") // platform-prod / kv-shared-prod
d.openSearch() d.openSearch()
v := d.m.search v := d.m.search
scope := func() string { return ansiStrip(v.scopes[v.src].label) } scope := func() string { return ansi.Strip(v.scopes[v.src].label) }
if scope() != "All" { if scope() != "All" {
t.Errorf("opened on %q, want All", scope()) t.Errorf("opened on %q, want All", scope())
} }
if v.res >= len(v.shown) || !strings.Contains(ansiStrip(v.shown[v.res].label), "kv-shared-prod") { if v.res >= len(v.shown) || !strings.Contains(ansi.Strip(v.shown[v.res].label), "kv-shared-prod") {
t.Errorf("vault not under the cursor:\n%s", d.screen()) t.Errorf("vault not under the cursor:\n%s", d.screen())
} }
d.send(ctrl('s')) // narrows to the source, and back d.send(ctrl('s')) // narrows to the source, and back
@@ -233,13 +232,13 @@ func TestSearchNarrowsToAccount(t *testing.T) {
t.Errorf("no results in the account:\n%s", d.screen()) t.Errorf("no results in the account:\n%s", d.screen())
} }
for _, o := range v.shown { for _, o := range v.shown {
if providerOf(o.group) != header.provider { if cachefile.ProviderOf(o.group) != header.provider {
t.Errorf("result from another account: %q", ansiStrip(o.label)) t.Errorf("result from another account: %q", ansi.Strip(o.label))
} }
} }
// The account's sources are under it, indented. // The account's sources are under it, indented.
if next := v.scopes[v.src+1]; next.group == "" || providerOf(next.group) != header.provider || !strings.HasPrefix(ansiStrip(next.label), " ") { if next := v.scopes[v.src+1]; next.group == "" || cachefile.ProviderOf(next.group) != header.provider || !strings.HasPrefix(ansi.Strip(next.label), " ") {
t.Errorf("after the account: %q", ansiStrip(next.label)) t.Errorf("after the account: %q", ansi.Strip(next.label))
} }
} }
@@ -327,7 +326,7 @@ func TestSearchHighlightsMatches(t *testing.T) {
t.Fatal("no results") t.Fatal("no results")
} }
o := d.m.search.shown[0] o := d.m.search.shown[0]
plain := []rune(ansiStrip(o.label)) plain := []rune(ansi.Strip(o.label))
var got string var got string
for _, h := range o.hits { for _, h := range o.hits {
got += string(plain[h]) got += string(plain[h])
@@ -348,14 +347,14 @@ func TestHelpBarFollowsPopup(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t) d := newDriver(t)
d.keys("F") d.keys("F")
bar := lastLine(d.screen()) bar := lastLines(d.screen(), 1)
for _, want := range []string{"[ prev tab", "] next tab", "/ filter", "f star"} { for _, want := range []string{"[ prev tab", "] next tab", "/ filter", "f star"} {
if !strings.Contains(bar, want) { if !strings.Contains(bar, want) {
t.Errorf("browsing: help bar %q lacks %q", bar, want) t.Errorf("browsing: help bar %q lacks %q", bar, want)
} }
} }
d.keys("/") d.keys("/")
if bar := lastLine(d.screen()); !strings.Contains(bar, "ctrl+f star") || !strings.Contains(bar, "esc stop typing") { if bar := lastLines(d.screen(), 1); !strings.Contains(bar, "ctrl+f star") || !strings.Contains(bar, "esc stop typing") {
t.Errorf("typing: help bar %q", bar) t.Errorf("typing: help bar %q", bar)
} }
} }
@@ -375,8 +374,8 @@ func TestHelpFromPopups(t *testing.T) {
if o.header { if o.header {
sections = append(sections, o.label) sections = append(sections, o.label)
} }
if strings.Contains(ansiStrip(o.label), "copy value") { if strings.Contains(ansi.Strip(o.label), "copy value") {
t.Errorf("pane key listed in Go to's help: %q", ansiStrip(o.label)) t.Errorf("pane key listed in Go to's help: %q", ansi.Strip(o.label))
} }
} }
if strings.Join(sections, ",") != "Go to,While typing, after /" { if strings.Join(sections, ",") != "Go to,While typing, after /" {
@@ -428,7 +427,7 @@ func TestGoToHelpBarEndsWithHelp(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t) // 100 columns: Go to's keys don't all fit d := newDriver(t) // 100 columns: Go to's keys don't all fit
d.keys("ctrl+s", "esc") d.keys("ctrl+s", "esc")
bar := strings.TrimSpace(lastLine(d.screen())) bar := strings.TrimSpace(lastLines(d.screen(), 1))
if !strings.HasSuffix(bar, "? help") || !strings.Contains(bar, "…") { if !strings.HasSuffix(bar, "? help") || !strings.Contains(bar, "…") {
t.Errorf("help bar %q", bar) t.Errorf("help bar %q", bar)
} }
@@ -443,11 +442,11 @@ func TestLoadWithEmptyResults(t *testing.T) {
for d.m.search.src > 0 { for d.m.search.src > 0 {
d.send(tea.KeyPressMsg{Code: tea.KeyUp}) d.send(tea.KeyPressMsg{Code: tea.KeyUp})
} }
for i := 0; !strings.Contains(ansiStrip(d.m.search.scope().label), "platform-dev"); i++ { for i := 0; !strings.Contains(ansi.Strip(d.m.search.scope().label), "platform-dev"); i++ {
if i > len(d.m.search.scopes) { if i > len(d.m.search.scopes) {
var labels []string var labels []string
for _, o := range d.m.search.scopes { for _, o := range d.m.search.scopes {
labels = append(labels, ansiStrip(o.label)) labels = append(labels, ansi.Strip(o.label))
} }
t.Fatalf("no platform-dev scope: %q (at %d, results %v)", labels, d.m.search.src, d.m.search.results) t.Fatalf("no platform-dev scope: %q (at %d, results %v)", labels, d.m.search.src, d.m.search.results)
} }
+3 -3
View File
@@ -203,7 +203,7 @@ func (m *Model) fillSearch(keep bool) {
id: accountScope + p.ID(), kind: k, mark: m.coverageMark(ofProvider(p.ID())), id: accountScope + p.ID(), kind: k, mark: m.coverageMark(ofProvider(p.ID())),
}) })
for _, o := range sources { for _, o := range sources {
if providerOf(o.group) == p.ID() { if cachefile.ProviderOf(o.group) == p.ID() {
o.label = " " + o.label o.label = " " + o.label
scopes = append(scopes, o) scopes = append(scopes, o)
} }
@@ -267,7 +267,7 @@ func (v *searchView) match() []option {
default: default:
account, ofAccount := v.scopeAccount() account, ofAccount := v.scopeAccount()
for _, o := range v.items { for _, o := range v.items {
if sc.id == allSources || ofAccount && providerOf(o.group) == account || sc.group != "" && o.group == sc.group { if sc.id == allSources || ofAccount && cachefile.ProviderOf(o.group) == account || sc.group != "" && o.group == sc.group {
cand = append(cand, o) cand = append(cand, o)
} }
} }
@@ -523,7 +523,7 @@ func (m *Model) searchLoads() tea.Cmd {
return m.startCrawlOf(v.scopeFilter()) return m.startCrawlOf(v.scopeFilter())
} }
if !v.onSource() { if !v.onSource() {
return m.startCrawl() return m.startCrawlOf(nil)
} }
providerID, sourceID := splitSourceRow(sc.group) providerID, sourceID := splitSourceRow(sc.group)
for _, src := range m.sourceResults[providerID].sources { for _, src := range m.sourceResults[providerID].sources {
+1 -1
View File
@@ -304,7 +304,7 @@ func (m *Model) handleShareOpened(msg shareOpenedMsg) tea.Cmd {
} }
// openFailed says why a share link couldn't be decrypted, in a popup // openFailed says why a share link couldn't be decrypted, in a popup
// where its offer was: its first line (wrapped), or with whole all of it // where its offer was: its first line (wrapped), or, with whole, all of it
// (Key Vault's refusals say what to check on later lines). From there, // (Key Vault's refusals say what to check on later lines). From there,
// the key's secret can be gone to: its version, which may be disabled. // the key's secret can be gone to: its version, which may be disabled.
func (m *Model) openFailed(s share.Sealed, err error, whole bool) { func (m *Model) openFailed(s share.Sealed, err error, whole bool) {
+3 -2
View File
@@ -1,6 +1,7 @@
package app package app
import ( import (
"cmp"
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
@@ -83,7 +84,7 @@ func (m Model) statusFields() []panes.Field {
} }
} }
// A release's rows open its release page. // A release's rows open its release page.
version := panes.Field{Label: "version", Value: cmpOr(m.versionLabel(), "dev")} version := panes.Field{Label: "version", Value: cmp.Or(m.versionLabel(), "dev")}
if strings.HasPrefix(version.Value, "v") { if strings.HasPrefix(version.Value, "v") {
version.URL = update.DefaultRepo + "/releases/tag/" + version.Value version.URL = update.DefaultRepo + "/releases/tag/" + version.Value
} }
@@ -159,7 +160,7 @@ func (m Model) accountStatus(p provider.Provider) string {
return r.err.Error() return r.err.Error()
} }
noun := map[provider.Kind]string{provider.KindAzure: "subscription", provider.KindDatabricks: "workspace", provider.KindRepo: "repo"}[p.Kind()] noun := map[provider.Kind]string{provider.KindAzure: "subscription", provider.KindDatabricks: "workspace", provider.KindRepo: "repo"}[p.Kind()]
return plural(len(r.sources), cmpOr(noun, "source")) return plural(len(r.sources), cmp.Or(noun, "source"))
} }
// ago is a duration as people say it: "just now", "5m ago", "2h ago". // ago is a duration as people say it: "just now", "5m ago", "2h ago".
+3 -2
View File
@@ -5,6 +5,7 @@ import (
"testing" "testing"
"git.kebcloud.no/keb/lazysecrets/internal/store" "git.kebcloud.no/keb/lazysecrets/internal/store"
"github.com/charmbracelet/x/ansi"
) )
func TestRecentScope(t *testing.T) { func TestRecentScope(t *testing.T) {
@@ -21,7 +22,7 @@ func TestRecentScope(t *testing.T) {
d.keys("R") d.keys("R")
var recent []string var recent []string
for _, o := range d.m.search.shown { for _, o := range d.m.search.shown {
recent = append(recent, ansiStrip(o.label)) recent = append(recent, ansi.Strip(o.label))
} }
if len(recent) < 2 || !strings.Contains(recent[0], "legacy-token") || !strings.Contains(recent[1], "api-key") { if len(recent) < 2 || !strings.Contains(recent[0], "legacy-token") || !strings.Contains(recent[1], "api-key") {
t.Fatalf("want newest first: %q", recent) t.Fatalf("want newest first: %q", recent)
@@ -31,7 +32,7 @@ func TestRecentScope(t *testing.T) {
t.Errorf("jumped to %q", s) t.Errorf("jumped to %q", s)
} }
d.keys("F") d.keys("F")
if len(d.m.search.shown) != 1 || !strings.Contains(ansiStrip(d.m.search.shown[0].label), "kv-shared-prod") { if len(d.m.search.shown) != 1 || !strings.Contains(ansi.Strip(d.m.search.shown[0].label), "kv-shared-prod") {
t.Errorf("favourites: %d entries\n%s", len(d.m.search.shown), d.screen()) t.Errorf("favourites: %d entries\n%s", len(d.m.search.shown), d.screen())
} }
} }
+5 -14
View File
@@ -15,8 +15,8 @@ package keys
import ( import (
"fmt" "fmt"
"maps"
"slices" "slices"
"sort"
"strings" "strings"
"charm.land/bubbles/v2/key" "charm.land/bubbles/v2/key"
@@ -266,11 +266,11 @@ func (m *Map) Apply(cfg map[string]map[string][]string) error {
byName[a.Context+"."+a.Name] = a byName[a.Context+"."+a.Name] = a
contexts[a.Context] = true contexts[a.Context] = true
} }
for _, ctx := range sortedKeys(cfg) { for _, ctx := range slices.Sorted(maps.Keys(cfg)) {
if !contexts[ctx] { if !contexts[ctx] {
return fmt.Errorf("keybinding: unknown context %q (known: %s)", ctx, strings.Join(sortedKeys(contexts), ", ")) return fmt.Errorf("keybinding: unknown context %q (known: %s)", ctx, strings.Join(slices.Sorted(maps.Keys(contexts)), ", "))
} }
for _, name := range sortedKeys(cfg[ctx]) { for _, name := range slices.Sorted(maps.Keys(cfg[ctx])) {
a, ok := byName[ctx+"."+name] a, ok := byName[ctx+"."+name]
if !ok { if !ok {
return fmt.Errorf("keybinding.%s: unknown action %q (known: %s)", ctx, name, strings.Join(m.names(ctx), ", ")) return fmt.Errorf("keybinding.%s: unknown action %q (known: %s)", ctx, name, strings.Join(m.names(ctx), ", "))
@@ -289,16 +289,7 @@ func (m *Map) names(ctx string) []string {
out = append(out, a.Name) out = append(out, a.Name)
} }
} }
sort.Strings(out) slices.Sort(out)
return out
}
func sortedKeys[V any](set map[string]V) []string {
out := make([]string, 0, len(set))
for k := range set {
out = append(out, k)
}
sort.Strings(out)
return out return out
} }
-10
View File
@@ -60,16 +60,6 @@ func (d *Detail) ShowSecret(s provider.Object) {
d.container = &s.Container d.container = &s.Container
} }
// Refresh shows s's newer metadata if s is the secret shown, keeping
// its account and any value shown or loading.
func (d *Detail) Refresh(s provider.Object) {
if d.secret == nil || d.secret.ID != s.ID {
return
}
s.Container = d.secret.Container
d.secret = &s
}
// ShowContainer shows a container summary when no secret is selected. // ShowContainer shows a container summary when no secret is selected.
func (d *Detail) ShowContainer(c provider.Container) { func (d *Detail) ShowContainer(c provider.Container) {
d.Clear() d.Clear()
-1
View File
@@ -9,7 +9,6 @@ import (
"github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/ansi"
) )
// sgr matches SGR escape sequences.
var sgr = regexp.MustCompile("\x1b\\[([0-9;:]*)m") var sgr = regexp.MustCompile("\x1b\\[([0-9;:]*)m")
// Highlight draws a cursor row: line, padded to width, on style's // Highlight draws a cursor row: line, padded to width, on style's
-9
View File
@@ -301,15 +301,6 @@ func matchRanges(plain string, hits []int, style lipgloss.Style) []lipgloss.Rang
return out return out
} }
// RuneIndexes converts byte offsets in s to rune offsets.
func RuneIndexes(s string, bytes []int) []int {
out := make([]int, 0, len(bytes))
for _, b := range bytes {
out = append(out, utf8.RuneCountInString(s[:b]))
}
return out
}
// highlight returns the rune indexes to highlight in a row's plain text: // highlight returns the rune indexes to highlight in a row's plain text:
// the filter hits, shifted to where the Filter text appears in the row. // the filter hits, shifted to where the Filter text appears in the row.
// It returns nil if the row doesn't contain the Filter text verbatim. // It returns nil if the row doesn't contain the Filter text verbatim.
+2 -8
View File
@@ -3,8 +3,8 @@ package styles
import ( import (
"fmt" "fmt"
"image/color" "image/color"
"maps"
"slices" "slices"
"sort"
"strconv" "strconv"
"strings" "strings"
@@ -51,12 +51,7 @@ var Roles = map[string]string{
func FromTheme(theme map[string][]string) (Styles, color.Color, error) { func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
st := Default() st := Default()
var fg color.Color var fg color.Color
roles := make([]string, 0, len(theme)) for _, role := range slices.Sorted(maps.Keys(theme)) {
for r := range theme {
roles = append(roles, r)
}
sort.Strings(roles)
for _, role := range roles {
if _, ok := Roles[role]; !ok { if _, ok := Roles[role]; !ok {
continue continue
} }
@@ -131,7 +126,6 @@ type value struct {
bold, italic, underline, reverse, faint bool bold, italic, underline, reverse, faint bool
} }
// fg styles s with the value as foreground, bg as background.
func (v value) fg(s lipgloss.Style) lipgloss.Style { func (v value) fg(s lipgloss.Style) lipgloss.Style {
if v.color != nil { if v.color != nil {
s = s.Foreground(v.color) s = s.Foreground(v.color)
+3 -5
View File
@@ -89,14 +89,9 @@ func (r Release) Notes(current string) string {
return PlainLinks(strings.TrimSpace(notes)) return PlainLinks(strings.TrimSpace(notes))
} }
// recentReleases is how many releases Latest reads the changelogs of.
const recentReleases = 10
// PlainLinks shows Markdown links as their text, for a terminal. // PlainLinks shows Markdown links as their text, for a terminal.
func PlainLinks(s string) string { return mdLink.ReplaceAllString(s, "$1") } func PlainLinks(s string) string { return mdLink.ReplaceAllString(s, "$1") }
// mdLink is a Markdown link, [text](url): the notes link PRs and commits,
// and a terminal shows them as their text.
var mdLink = regexp.MustCompile(`\[([^\]]+)\]\((https?://[^)\s]+)\)`) var mdLink = regexp.MustCompile(`\[([^\]]+)\]\((https?://[^)\s]+)\)`)
// Client talks to the release servers. // Client talks to the release servers.
@@ -148,6 +143,9 @@ func (c *Client) Latest(ctx context.Context) (Release, error) {
return Release{}, errors.Join(errs...) return Release{}, errors.Join(errs...)
} }
// recentReleases is how many releases Latest reads the changelogs of.
const recentReleases = 10
func (c *Client) latest(ctx context.Context, repo string) (Release, error) { func (c *Client) latest(ctx context.Context, repo string) (Release, error) {
api, err := API(repo) api, err := API(repo)
if err != nil { if err != nil {