24 Commits
Author SHA1 Message Date
keb b64ca81dfe Merge branch 'feat/expiring' into feat/l-for-details
CI / Checks (pull_request) Successful in 25s
PR title / Conventional title (pull_request) Successful in 2s
# Conflicts:
#	docs/azure.md
#	internal/ui/app/app_test.go
2026-10-11 13:56:22 +02:00
kebandClaude Opus 5.5 c9bc4c1431 feat: what's expiring, in the status pane and lazysecrets expiring
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 13:55:52 +02:00
kebandClaude Opus 5.5 8e8d1c5af0 fix: the merged tests' closing braces
CI / Checks (pull_request) Successful in 24s
PR title / Conventional title (pull_request) Successful in 2s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 13:53:22 +02:00
keb 75f62240fc Merge remote-tracking branch 'origin/feat/container-details' into feat/l-for-details
CI / Checks (pull_request) Failing after 5s
PR title / Conventional title (pull_request) Successful in 2s
# Conflicts:
#	internal/ui/app/app_test.go
2026-10-11 13:51:22 +02:00
keb dee144f33f Merge remote-tracking branch 'origin/feat/first-run' into feat/l-for-details 2026-10-11 13:50:46 +02:00
keb 1d7efb135d Merge remote-tracking branch 'origin/feat/cache-age' into feat/l-for-details 2026-10-11 13:50:46 +02:00
kebandClaude Opus 5.5 365cb5b1df feat: l goes to the details of what's selected; enter still drills in
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 13:50:43 +02:00
kebandClaude Opus 5.5 cd35a8acf4 fix: details load once the cursor rests, and moving on cancels them
CI / Checks (pull_request) Successful in 25s
PR title / Conventional title (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 13:43:38 +02:00
kebandClaude Opus 5.5 879b961ac5 feat: a vault's settings come with the vault list, with no requests of their own
CI / Checks (pull_request) Successful in 24s
PR title / Conventional title (pull_request) Successful in 2s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 13:42:48 +02:00
kebandClaude Opus 5.5 5381cf5d43 feat: vaults, scopes and files show their details: a vault's settings, who has a scope, a file's keys and git status
CI / Checks (pull_request) Successful in 24s
PR title / Conventional title (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 13:41:12 +02:00
kebandClaude Opus 5.5 164d933bea feat: with nothing to show, the right pane says how to get started
CI / Checks (pull_request) Successful in 23s
PR title / Conventional title (pull_request) Successful in 2s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 13:37:21 +02:00
kebandClaude Opus 5.5 4d6db97d54 feat: a list from the cache says how old it is while it reloads
PR title / Conventional title (pull_request) Successful in 2s
CI / Checks (pull_request) Successful in 24s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 13:35:11 +02:00
keb 9b0329e9da feat: the changelog in the status pane, built in (#12)
The status pane's rows (`0`, then `enter`) end with the changelog: every release up to the running one, newest first.

```
v0.2.0     2026-10-12
           added: Search with fzf's scoring and query syntax (#11) (65dc2d7)
           fixed: …
v0.1.0     2026-10-10
           Hello world 👋
```

- `CHANGELOG.md` is embedded at build time by a small root package (`changelog.go`, `package lazysecrets`, `//go:embed`), so it needs no network and matches the binary. `release.sh` updates the file before tagging, so a release's binary includes its own entry. `go install …@latest` gets it too, since the module includes the file.
- Entries show as text: links become `(#11) (65dc2d7)` (`update.PlainLinks`, shared with the `U` notes). `/` filters them like any Fields pane (`/fixed`, `/search`), and `y` copies one.
- `cliff.toml`: #11 was squash-merged with #10 (the status pane) in it, so a preprocessor rewrites its entry to name both. Otherwise the status pane would be missing from the changelog.

Test for the parsing: versions and dates, kinds, links as text, a release's own words, and the header and footer left out.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
Reviewed-on: #12
2026-10-11 13:12:51 +02:00
keb 56d3d25c9c feat: link each changelog entry to its PR and commit (#11)
Like Immich's and RomM's release notes: every changelog entry now links to its pull request and its commit, in `CHANGELOG.md` and in the release notes on Gitea:

```
- Search with fzf's scoring and query syntax ([#11](…/pulls/11)) ([65dc2d7](…/commit/65dc2d7…))
```

- `cliff.toml`: the entry template adds the short commit hash as a link; a postprocessor turns the squash commit's `(#N)` into a link to the PR.
- The app shows release notes as text (`U`, and the status pane's changelog to come), so `Release.Notes()` turns Markdown links back into their text: `Search (#11) (65dc2d7)`.

Checked with `git cliff --unreleased` on main: every entry since v0.1.0 gets both links. Test for the link stripping.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
Reviewed-on: #11
2026-10-11 11:20:19 +02:00
keb 1c8d69d957 feat: the right pane describes what's selected in the pane you're in (#9)
The right pane kept showing the selected secret when you moved to Vaults or Sources. Now it follows focus:

| focus | right pane |
|---|---|
| Sources | the account (group row), or the subscription / workspace / repo: name, note, ID / profile / path, host or remote, account, web page, vault count |
| Vaults | the vault / scope / file: name, note, URI / backend / format, source, web page, counts of loaded secrets, keys, certificates |
| Secrets | the secret, as before |
| the right pane itself | what it described when you went in; `shift+tab` goes back there |

All but the secret use the `Fields` pane (#7), so `j`/`k`, `/` and `y` work on them; `Detail` loses the error message it got in #4, which is now a red "problem" row in `Fields`. A value revealed from a left pane stays in view until hidden, so `space` from any pane still shows it. `tab` from a subscription goes to Vaults as before; only an account row skips to its details.

This also changes the startup view: focus starts in Sources, so the right pane describes the first subscription instead of the first secret (golden output updated). Tests that read the secret pane focus Secrets first; a new test walks secret → vault → subscription → into the pane → back, and reveals from Sources.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
Reviewed-on: #9
2026-10-11 10:59:40 +02:00
keb c8abc1dc95 feat: the value's keys on the line below it: reveal and copy (#8) 2026-10-11 10:38:00 +02:00
keb 71396f1e02 feat: a Fields pane for anything but a secret, first for Azure accounts: fields and groups to move through, filter and copy (#7) 2026-10-11 10:37:40 +02:00
keb f6c4f5f6f6 fix: show search instead of grouped/flat in the Sources help bar (#6)
The Sources pane's help bar listed `` ` `` (grouped/flat sources), a rarely used toggle, but not Go to (`ctrl+s`), the main way to find things. Search now takes its place, right after `enter`; the toggle is still in `?`.

Golden output updated: at 100 columns the bar now reads `enter open • ctrl+s search • s copy link • S share… • f star • F favourites • … • ? help • q quit` (`n note` moves behind the `…`).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
Reviewed-on: #6
2026-10-11 10:09:46 +02:00
keb 52b6257dd5 feat: show who an Azure account signs in as, in Details on its row (#5)
On an Azure account's group row, the Details pane now describes the account:

| field | from |
|---|---|
| name | token `name` |
| user (and "service principal") | az's account list |
| tenant: name · ID | az's account list (the name is borrowed from another entry in the same tenant when az left it out) |
| object ID | token `oid`; `y` on the row copies it |
| sign-in: MFA or not, and how | token `amr` |
| directory roles | token `wids`: built-in roles by name, others as `role 1a2b3c4d`, the default member role left out |
| subs: count · default | az's account list |
| cloud | az's account list, when not AzureCloud |

Nothing is fetched for it: the claims are the ones from the token lazysecrets already gets for the account's own tenant, read without verification. The token itself is never stored or shown. Before the first token, only the az-list fields show. A failing account shows its sign-in problem there instead (from #4).

New optional `provider.Accounts` interface; `azure.Provider` implements it. Tests: the fields from claims and az's list, sign-in methods, the UI showing it on the group row only, and `y` copying the object ID. Checked against a real signed-in account (name, MFA · password, default subscription) and an expired one (az-list fields only, since there's no token).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
Reviewed-on: #5
2026-10-11 10:09:17 +02:00
keb a82f6bff27 fix: say a sign-in error once, then in Details for that account (#4)
A failing account's error ("sign-in required … press L to run az login --tenant …") stayed in the footer for the whole session.

- **Footer:** it's now an ordinary message, shown once when an account's error first appears (at start, or when a load fails), and gone on the next key press. It's said again only when the errors change, or for another account.
- **Details:** with that account's group header or one of its sources selected, the Details pane shows the error and the sign-in hint, in place of "Select a vault or scope…".
- **`L`** signs in to the selected account when it needs it, else to the last one that asked, as before. With two accounts, `L` used to run whichever sign-in had failed most recently.

Test: two accounts, one failing to list its subscriptions. The message is in the footer at start and gone after a key press, in Details on that account's group, and `L` targets it. It isn't shown on the other account's rows.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
Reviewed-on: #4
2026-10-11 10:06:28 +02:00
keb 87d98d038d fix: never show one az account's subscriptions under another (#3)
With two az accounts, signing in to one could make the other account's group list the first one's subscriptions.

**Why:** az can't be told which user to get a token for, so each account is pinned with `--subscription`, using one of its subscriptions. The pin was chosen once at start. But az's profile has one entry per subscription, tagged with the account that signed in to it last. Signing in to another account that also sees the pinned subscription moves it there, and from then on the first account's token requests silently returned the other account's token. That's what happened here: the in-app sign-in as one account re-tagged the other account's default subscription.

**Fix:**
- The pin is looked up in az's profile for every new token (only when the in-memory token cache misses), so it follows az's current mapping. An account with no subscriptions left in az gets "sign in as …".
- A token az issues for **another of the signed-in users** (by its `upn`/`unique_name`/`preferred_username`/`email` claim) is refused with "az gave a token for X, not Y: sign in as Y again", which shows as the usual sign-in prompt. Only a match against another known user is refused, so tokens without those claims (service principals) or with unusual guest names aren't turned away.

Tests cover the subscription moving between users, a refused token, a user gone from az, and the claim matching. I also checked against two real az accounts (one signed in, one expired): the signed-in account lists its subscriptions, and the expired one shows the sign-in error.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
Reviewed-on: #3
2026-10-11 09:55:43 +02:00
keb 10dc747f0f feat: keep what was loaded between runs, and start where you left off (#2)
Starting lazysecrets again now shows what you had loaded at once, and puts you back where you were.

- **Cache file** `~/.cache/lazysecrets/cache.json` (0600, written atomically): the Sources lists, vaults/scopes, secrets' metadata, Describe results and each list's cursor. **Never values**, and never repos (they're on disk anyway). Loaded on start; lists older than 2 minutes reload in the background as before. Saved every 30s when changed, and on quit.
- **Housekeeping:** entries not loaded for 30 days are dropped, as is everything from accounts no longer signed in (every cache key starts with the account ID). The file has a format version; another version is ignored and replaced. `cache: false` turns it off; `--demo` never uses it.
- **Sources are cached too:** Azure lists subscriptions over the network, so without this the Sources pane would still wait.
- **Go to** searches everything cached straight away, and its background loading now also refreshes lists from an earlier run instead of skipping them.
- **Where you left off:** the state file remembers the selected source, vault or secret per folder (the `--dir` or working directory, 100 most recent folders), and the next start in that folder goes back there quietly (no error if it's gone). Starting on a link skips it.

Docs: `cache:` in config.md, a Cache section, the place in State, and the README Privacy section now mentions the cache file.

Tests: cache round trip with a backend too slow to answer (lists and cursor come from the file), no value in the file, file mode 0600, old and signed-out entries dropped, back where it was left, not for another folder or a link.

This touches `app.go` near PR #1's changes (Model fields, the Update switch), so whichever merges second needs a small conflict fix.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
Reviewed-on: #2
2026-10-11 09:53:32 +02:00
keb 17d2705e12 feat: e opens a repo's SOPS or .env file, in nvim, sops edit or your editor (#1)
`e` on a file in a repo opens it; on a secret, it opens the file at that key's line.

- **SOPS files**: `sops edit` in the terminal, then back to lazysecrets with the keys reloaded. Exit 200 (nothing changed) shows "unchanged".
- **Other files inside nvim** (`$NVIM` set): lazysecrets asks that nvim, over `--remote-expr`, to open the file once lazysecrets' terminal closes, then quits. The file lands in the window you came from, not the closing float. Started from a shell in a terminal, it opens in the previous window straight away. No change to the nvim plugin is needed.
- **Otherwise**: `$VISUAL`/`$EDITOR` in the terminal (with `+N`, `file:N` or `-g file:N` for editors that take a line), else the system default app.

The repo provider now records each key's line (YAML node lines, env/ini line numbers) behind a new optional `provider.Files` interface. Other providers show "only SOPS and .env files in repos can be opened".

The nvim handoff was tested against a headless nvim 0.12: a floating terminal closed on exit the way snacks does, and a shell in a split. A file name with a space and a quote opened at the right line in both. Unit tests cover lines per format, the nvim, sops and default-app paths, and the editor line arguments.

Note: `e` is a new default key, so a config that already binds `e` to something else now gets the "bound to both" error, until one of them is rebound.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
Reviewed-on: #1
2026-10-11 09:34:05 +02:00
kebandClaude Opus 5.5 6fab41703e Hello world 👋
Release / Build and publish (push) Successful in 22s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 23:58:25 +02:00
59 changed files with 764 additions and 1462 deletions

No files matched your search

+2 -14
View File
@@ -4,9 +4,8 @@ name: Release
# as a pre-release) tests lazysecrets, then GoReleaser (.goreleaser.yaml) # as a pre-release) tests lazysecrets, then GoReleaser (.goreleaser.yaml)
# builds it for macOS and Linux, arm64 and amd64, and publishes a Gitea # builds it for macOS and Linux, arm64 and amd64, and publishes a Gitea
# release with the archives, checksums and this release's section of the # release with the archives, checksums and this release's section of the
# changelog (git-cliff, cliff.toml), then copies it to the GitHub # changelog (git-cliff, cliff.toml). Pushing the same tag again (after
# mirror. Pushing the same tag again (after deleting it) replaces the # deleting it) replaces the release.
# release in both places.
# #
# scripts/release.sh v0.2.0 # updates CHANGELOG.md, tags and pushes # scripts/release.sh v0.2.0 # updates CHANGELOG.md, tags and pushes
@@ -82,14 +81,3 @@ jobs:
args: release --clean --release-notes ${{ runner.temp }}/notes.md args: release --clean --release-notes ${{ runner.temp }}/notes.md
env: env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Copy the release to GitHub
# The same notes and files on the GitHub mirror, which lazysecrets
# updates from first (internal/update). GH_RELEASE_TOKEN is a
# fine-grained token that can write the mirror's contents; without
# it the step does nothing.
run: node scripts/github-release.mjs
env:
TAG: ${{ github.ref_name }}
GH_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }}
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-46
View File
@@ -1,46 +0,0 @@
name: Bug report
description: Something in lazysecrets doesn't work as it should.
labels: [bug]
body:
- type: markdown
attributes:
value: |
Thanks for reporting! Please leave out secret values, tokens and anything else private: names of vaults and secrets are enough.
- type: textarea
id: what
attributes:
label: What happened
description: What you did, what you expected, and what happened instead.
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: If you can, try `lazysecrets --demo` first.
placeholder: |
1. lazysecrets --demo
2. ctrl+s, type …
- type: input
id: version
attributes:
label: Version
description: The status pane shows it, e.g. `v0.1.0`.
validations:
required: true
- type: dropdown
id: source
attributes:
label: Where
multiple: true
options:
- Azure Key Vault
- Databricks
- SOPS or .env files
- Links
- Everywhere / not sure
- type: input
id: platform
attributes:
label: OS and terminal
placeholder: macOS 26, kitty + tmux
-5
View File
@@ -1,5 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Docs
url: https://git.kebcloud.no/keb/lazysecrets/src/branch/main/docs
about: Keys, sources, links, config and more.
-20
View File
@@ -1,20 +0,0 @@
name: Feature request
description: An idea for lazysecrets, or something that could work better.
labels: [enhancement]
body:
- type: markdown
attributes:
value: |
lazysecrets stays focused on finding, reading and sharing secrets, so not every idea will make it in, but they're all read.
- type: textarea
id: problem
attributes:
label: What you're trying to do
description: The problem or the situation, more than the solution.
validations:
required: true
- type: textarea
id: idea
attributes:
label: Your idea
description: How you'd like it to work, if you have one in mind.
+1 -1
View File
@@ -2,7 +2,7 @@
Every release of lazysecrets, newest first. Every release of lazysecrets, newest first.
## v0.1.0 – 2026-10-12 ## v0.1.0 – 2026-10-10
Hello world 👋 Hello world 👋
+16 -26
View File
@@ -1,28 +1,30 @@
# lazysecrets # lazysecrets
A terminal UI for finding, reading and sharing secrets. Inspired by the fantastic [lazygit](https://github.com/jesseduffield/lazygit). A terminal UI for finding, reading and sharing secrets. Inspired by the fantastic lazygit.
![lazysecrets](docs/assets/lazysecrets.png)
## Main features ## Main features
- **Read-only**: lazysecrets never creates, changes or deletes a secret. - **Read-only**: lazysecrets never creates, changes or deletes a secret, so
That's by design and may stay that way. it's safe to point at production. That's by design and may stay that way.
(`e` opens a repo's SOPS or `.env` file in your editor, or `sops edit`;
any change is yours.)
- **Zero setup**: uses the logins you already have (`az`, the Databricks - **Zero setup**: uses the logins you already have (`az`, the Databricks
CLI, `sops`, `git`). No config file needed. CLI, `sops`, `git`). No config file needed.
- **Search everything**: `ctrl+s` searches every source at once, with - **Search everything**: `ctrl+s` searches every source at once, with
fzf-style matching. fzf-style matching (`'exact`, `^prefix`, `suffix$`, `!not`), and jumps
- **Share links**: Share `lazysecrets://` links with colleagues. A colleague opens it straight to the result.
- **Share links**: `s` copies a `lazysecrets://` link. A colleague opens it
with their own access, from the terminal or with a click in the browser with their own access, from the terminal or with a click in the browser
or chat. or chat.
- **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**: `lazysecrets get <link>` prints a value for scripts and
pipes. pipes.
- **Favourites**: star the secrets you use most for easy access. - **Favourites**: star the secrets you use most and get back to them in one
key.
- **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, and `lazysecrets expiring`
checks every vault from a pipeline.
- **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.
@@ -38,9 +40,8 @@ On macOS or Linux (arm64 or x86_64):
curl -fsSL https://git.kebcloud.no/keb/lazysecrets/raw/branch/main/install.sh | sh curl -fsSL https://git.kebcloud.no/keb/lazysecrets/raw/branch/main/install.sh | sh
``` ```
It installs the latest release, from GitHub or else It installs the latest [release](https://git.kebcloud.no/keb/lazysecrets/releases)
[git.kebcloud.no](https://git.kebcloud.no/keb/lazysecrets/releases), into into `~/.local/bin` after checking it against the release's checksums.
`~/.local/bin` after checking it against the release's checksums.
`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.
@@ -98,20 +99,9 @@ lazysecrets never stores, caches or sends your secret values. It keeps the
names and metadata of what you've loaded in a cache file readable only by names and metadata of what you've loaded in a cache file readable only by
you, to show it at once next time (`cache: false` turns it off). It talks you, to show it at once next time (`cache: false` turns it off). It talks
only to the services you use it with, and collects no usage data. The one only to the services you use it with, and collects no usage data. The one
call of its own is a daily check for a new release, on GitHub or else call of its own is a daily check for a new release on this server, which
git.kebcloud.no, which `updateCheck: false` turns off. `updateCheck: false` turns off.
## Repositories
The main repo of this codebase is at [git.kebcloud.no](https://git.kebcloud.no/keb/lazysecrets).
The [GitHub mirror](https://github.com/karleinarb/lazysecrets) is for discoverability, issue-tracking and releases.
It also makes it possible to give a ⭐ if you like lazysecrets!
## Contributing
I don't take pull requests as I don't have time to review and test them.
But bug reports and ideas are very welcome!
Open an [issue on GitHub](https://github.com/karleinarb/lazysecrets/issues) and I'll have a look.
## AI disclosure ## AI disclosure
+163
View File
@@ -0,0 +1,163 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"slices"
"strings"
"sync"
"text/tabwriter"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
)
// lazysecrets expiring lists what has expired, or will within --days,
// in every vault of every account: for a pipeline or a cron job. Expiry
// dates come with the lists, so it's one request per vault and type,
// a few at a time. Only Key Vault has expiry dates.
// errExpiring makes the exit status 1 when anything was listed.
var errExpiring = errors.New("expiring")
// listLimit is how many lists load at once.
const listLimit = 8
type expiringOpts struct {
days int
timeout time.Duration
now time.Time
}
func parseExpiring(args []string, days int) (int, error) {
fs := flag.NewFlagSet("expiring", flag.ContinueOnError)
n := fs.Int("days", days, "list what expires within this many days (default: expiryWarningDays, else 30)")
if err := fs.Parse(args); err != nil {
return 0, err
}
if fs.NArg() > 0 || *n < 0 {
return 0, fmt.Errorf("usage: lazysecrets expiring [--days N]")
}
return *n, nil
}
func expiring(providers []provider.Provider, o expiringOpts, out, errs io.Writer) error {
var (
mu sync.Mutex
found []provider.Object
seen = map[string]bool{}
failed int
vaults int
wg sync.WaitGroup
limit = make(chan struct{}, listLimit)
within = o.now.AddDate(0, 0, o.days)
warnErr = func(what string, err error) {
mu.Lock()
defer mu.Unlock()
failed++
fmt.Fprintf(errs, "lazysecrets: %s: %v\n", what, err)
}
)
run := func(f func(ctx context.Context)) {
wg.Add(1)
go func() {
defer wg.Done()
limit <- struct{}{}
defer func() { <-limit }()
ctx, cancel := context.WithTimeout(context.Background(), o.timeout)
defer cancel()
f(ctx)
}()
}
var vaultsDone sync.WaitGroup
for _, p := range providers {
if p.Kind() != provider.KindAzure {
continue
}
vaultsDone.Add(1)
run(func(ctx context.Context) {
defer vaultsDone.Done()
srcs, err := p.Sources(ctx)
if err != nil {
warnErr(p.Label(), err)
return
}
for _, src := range srcs {
vaultsDone.Add(1)
run(func(ctx context.Context) {
defer vaultsDone.Done()
cs, err := p.Containers(ctx, src)
if err != nil {
warnErr(src.Name, err)
return
}
for _, c := range cs {
mu.Lock()
fresh := !seen[c.ID]
seen[c.ID] = true
if fresh {
vaults++
}
mu.Unlock()
if !fresh { // the same vault through another account
continue
}
for _, t := range c.ObjectTypes() {
run(func(ctx context.Context) {
objs, err := p.Objects(ctx, c, t)
if err != nil {
warnErr(c.Name+" "+strings.ToLower(string(t))+"s", err)
return
}
mu.Lock()
defer mu.Unlock()
for _, o := range objs {
if !o.Expires.IsZero() && o.Expires.Before(within) {
found = append(found, o)
}
}
})
}
}
})
}
})
}
// Listing vaults queues the lists of their secrets, so wait for both.
vaultsDone.Wait()
wg.Wait()
slices.SortFunc(found, func(a, b provider.Object) int { return a.Expires.Compare(b.Expires) })
if len(found) == 0 {
fmt.Fprintf(errs, "nothing expires within %d days (%d vaults)\n", o.days, vaults)
if failed > 0 {
return fmt.Errorf("%d lists couldn't be read", failed)
}
return nil
}
tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "LEFT\tEXPIRES\tTYPE\tVAULT\tNAME\tLINK")
for _, x := range found {
left := "expired"
if d := x.Expires.Sub(o.now); d > 0 {
left = fmt.Sprintf("%dd", int(d.Hours()/24))
}
link := ""
if r, err := ref.ForObject(x, ""); err == nil {
link = r.String()
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", left, x.Expires.Local().Format("2006-01-02"),
strings.ToLower(string(x.Type)), x.Container.Name, x.Name, link)
}
if err := tw.Flush(); err != nil {
return err
}
if failed > 0 {
fmt.Fprintf(errs, "lazysecrets: %d lists couldn't be read: the list may be incomplete\n", failed)
}
return errExpiring
}
+41
View File
@@ -0,0 +1,41 @@
package main
import (
"bytes"
"errors"
"strings"
"testing"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
)
func TestExpiring(t *testing.T) {
providers := []provider.Provider{fake.Azure(0), fake.Databricks(0)}
now := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
var out, errs bytes.Buffer
err := expiring(providers, expiringOpts{days: 30, timeout: time.Minute, now: now}, &out, &errs)
if !errors.Is(err, errExpiring) {
t.Fatalf("err %v (want errExpiring: something listed)", err)
}
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
if len(lines) != 2 || !strings.HasPrefix(lines[1], "expired 2026-03-01 certificate kv-app-prod old-tls") ||
!strings.Contains(lines[1], "lazysecrets://azure/kv-app-prod.vault.azure.net/certificates/old-tls") {
t.Errorf("out:\n%s", out.String())
}
if !strings.Contains(errs.String(), "kv-restricted") {
t.Errorf("unreadable vaults not said:\n%s", errs.String())
}
// Further ahead, the soonest first.
out.Reset()
expiring(providers, expiringOpts{days: 400, timeout: time.Minute, now: now}, &out, &errs)
if got := strings.Count(out.String(), "\n"); got != 4 || !strings.Contains(out.String(), "app-tls") {
t.Errorf("400 days:\n%s", out.String())
}
if _, err := parseExpiring([]string{"--days", "-1"}, 30); err == nil {
t.Error("negative days accepted")
}
}
+22 -6
View File
@@ -4,6 +4,7 @@ package main
import ( import (
"context" "context"
"errors"
"flag" "flag"
"fmt" "fmt"
"io" "io"
@@ -33,7 +34,9 @@ import (
) )
func main() { func main() {
if err := run(); err != nil { if err := run(); errors.Is(err, errExpiring) {
os.Exit(1) // listed: a pipeline fails on it
} else if err != nil {
fmt.Fprintln(os.Stderr, "lazysecrets:", err) fmt.Fprintln(os.Stderr, "lazysecrets:", err)
os.Exit(1) os.Exit(1)
} }
@@ -118,6 +121,9 @@ func run() error {
@Microsoft.KeyVault(...) or {{secrets/scope/key}} @Microsoft.KeyVault(...) or {{secrets/scope/key}}
lazysecrets [flags] get <ref> print the value a lazysecrets:// link points at, lazysecrets [flags] get <ref> print the value a lazysecrets:// link points at,
for scripts: VAR=$(lazysecrets get <ref>) for scripts: VAR=$(lazysecrets get <ref>)
lazysecrets [flags] expiring [--days N]
list what has expired or will within N days
(default 30) in every vault; exits 1 if any
lazysecrets [flags] keybindings print every action and its keys (with your lazysecrets [flags] keybindings print every action and its keys (with your
config applied), as config to copy and edit config applied), as config to copy and edit
lazysecrets handler install [--terminal NAME] lazysecrets handler install [--terminal NAME]
@@ -140,6 +146,7 @@ Flags:
} }
var open, getRef *ref.Ref var open, getRef *ref.Ref
var expiringArgs []string
printKeys := false printKeys := false
switch args := flag.Args(); { switch args := flag.Args(); {
case len(args) == 1 && args[0] == "keybindings": case len(args) == 1 && args[0] == "keybindings":
@@ -150,12 +157,14 @@ Flags:
return err return err
} }
getRef = &r getRef = &r
case len(args) > 0 && args[0] == "expiring":
expiringArgs = append([]string{}, args[1:]...)
case len(args) > 0 && args[0] == "handler": case len(args) > 0 && args[0] == "handler":
return handler(*configPath, args[1:]) return handler(*configPath, args[1:])
case len(args) > 0 && args[0] == "launch": case len(args) > 0 && args[0] == "launch":
return launch(*configPath, args[1:]) return launch(*configPath, args[1:])
case len(args) > 0 && args[0] == "update": case len(args) > 0 && args[0] == "update":
return updateCmd(*configPath, args[1:]) return updateCmd(args[1:])
case len(args) == 0: case len(args) == 0:
case args[0] == "open" && len(args) == 2: case args[0] == "open" && len(args) == 2:
r, err := ref.Parse(args[1]) r, err := ref.Parse(args[1])
@@ -243,10 +252,9 @@ Flags:
if *demo { if *demo {
// Two Azure accounts, like a personal and an admin login in az. // Two Azure accounts, like a personal and an admin login in az.
providers = []provider.Provider{ providers = []provider.Provider{
fake.Azure(400*time.Millisecond).As("Azure:alice@example.com/demo", "Azure · alice"), fake.Azure(400*time.Millisecond).As("Azure:dev@example.com/demo", "Azure · dev"),
fake.AzureOps(700*time.Millisecond).As("Azure:admin.alice@example.com/demo", "Azure · admin.alice"), fake.AzureOps(700*time.Millisecond).As("Azure:ops@example.com/demo", "Azure · ops"),
fake.Databricks(600 * time.Millisecond), fake.Databricks(600 * time.Millisecond),
fake.Repos(),
} }
} else { } else {
pc := cfg.Providers pc := cfg.Providers
@@ -299,6 +307,14 @@ Flags:
// the standard logger, which would draw over the TUI. // the standard logger, which would draw over the TUI.
log.SetOutput(io.Discard) log.SetOutput(io.Discard)
if expiringArgs != nil {
days, err := parseExpiring(expiringArgs, expiryDays)
if err != nil {
return err
}
return expiring(providers, expiringOpts{days: days, timeout: *timeout, now: time.Now()}, os.Stdout, os.Stderr)
}
if getRef != nil { if getRef != nil {
ctx, cancel := context.WithTimeout(context.Background(), *timeout) ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel() defer cancel()
@@ -364,7 +380,7 @@ Flags:
ExpiryWarning: time.Duration(expiryDays) * 24 * time.Hour, ExpiryWarning: time.Duration(expiryDays) * 24 * time.Hour,
HideOnBlur: config.On(cfg.HideOnBlur), HideOnBlur: config.On(cfg.HideOnBlur),
HandlerSetup: setup, HandlerSetup: setup,
Updater: updater(cfg.Releases), Updater: updater(),
UpdateCheck: config.On(cfg.UpdateCheck), UpdateCheck: config.On(cfg.UpdateCheck),
Keys: &keymap, Keys: &keymap,
Commands: cfg.CustomCommands, Commands: cfg.CustomCommands,
+5 -11
View File
@@ -7,16 +7,14 @@ import (
"strings" "strings"
"time" "time"
"git.kebcloud.no/keb/lazysecrets/internal/config"
"git.kebcloud.no/keb/lazysecrets/internal/ui/app" "git.kebcloud.no/keb/lazysecrets/internal/ui/app"
"git.kebcloud.no/keb/lazysecrets/internal/update" "git.kebcloud.no/keb/lazysecrets/internal/update"
) )
// updater is the app's way to find and install releases, replacing the // updater is the app's way to find and install releases, replacing the
// binary that's running, from the servers (config releases) or the // binary that's running.
// default ones. func updater() *app.Updater {
func updater(servers []string) *app.Updater { c := &update.Client{}
c := &update.Client{Servers: servers}
return &app.Updater{ return &app.Updater{
Latest: c.Latest, Latest: c.Latest,
Install: func(ctx context.Context, r update.Release) error { Install: func(ctx context.Context, r update.Release) error {
@@ -31,19 +29,15 @@ func updater(servers []string) *app.Updater {
// updateCmd is lazysecrets update [--check]: the newer release's // updateCmd is lazysecrets update [--check]: the newer release's
// changelog, then (unless --check) install it. // changelog, then (unless --check) install it.
func updateCmd(configPath string, args []string) error { func updateCmd(args []string) error {
check := len(args) == 1 && args[0] == "--check" check := len(args) == 1 && args[0] == "--check"
if len(args) > 0 && !check { if len(args) > 0 && !check {
return errors.New("usage: lazysecrets update [--check]") return errors.New("usage: lazysecrets update [--check]")
} }
cfg, err := config.LoadAll(strings.Split(configPath, ","))
if err != nil {
return err
}
current := versionString() current := versionString()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel() defer cancel()
c := &update.Client{Servers: cfg.Releases} c := &update.Client{}
r, err := c.Latest(ctx) r, err := c.Latest(ctx)
if err != nil { if err != nil {
return fmt.Errorf("looking for a newer lazysecrets: %w", err) return fmt.Errorf("looking for a newer lazysecrets: %w", err)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

+22 -8
View File
@@ -16,10 +16,12 @@ 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. Below them are the groups the
the row copies the object ID, the one access policies and role account is in, nested ones too: those come from Microsoft Graph, once a
assignments ask for; `l` goes into the pane, where `j`/`k` pick any session, the first time the pane shows them. `y` on the row copies the
field, `/` filters them (`/tenant`) and `y` copies the selected one. object ID, the one access policies and role assignments ask for; `tab`
goes into the pane, where `j`/`k` pick any field or group, `/` filters
them (`/tenant`, `/platform`) and `y` copies the selected one.
If an account's az login has expired, the status line says so once, and the If an account's az login has expired, the status line says so once, and the
Details pane on that account's rows says it until you sign in: press `L` Details pane on that account's rows says it until you sign in: press `L`
@@ -38,10 +40,8 @@ vault resources (e.g. *Reader*). Key Vault data-plane access is separate
| read secret values | *Key Vault Secrets User* | | read secret values | *Key Vault Secrets User* |
| read certificate details | *Key Vault Certificate User* | | read certificate details | *Key Vault Certificate User* |
A missing permission is shown in the pane, shortened to what was denied A missing permission is shown in the pane, including the action that was
(the action, or the access policy permission), with what to check: a denied.
role or group to activate in PIM, access from the vault's owner, or, when
its firewall refused, the network you're on.
## Vaults you can't discover ## Vaults you can't discover
@@ -71,6 +71,20 @@ on one goes to it. That makes no requests of its own: expiry dates come
with the lists. It says how many vaults it covers; `ctrl+a` in search with the lists. It says how many vaults it covers; `ctrl+a` in search
loads the rest. loads the rest.
For a pipeline or a cron job, `lazysecrets expiring` lists them in every
vault of every account, loading each vault's lists (a few at a time), and
exits 1 when it lists anything:
```sh
$ lazysecrets expiring --days 30
LEFT EXPIRES TYPE VAULT NAME LINK
expired 2026-03-01 certificate kv-app-prod old-tls lazysecrets://azure/kv-app-prod.vault.azure.net/certificates/old-tls
12d 2026-10-23 secret kv-app-prod api-key lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/api-key
```
`--days` defaults to `expiryWarningDays`. Lists it can't read are named on
stderr.
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
purge protection, which networks can reach it, private endpoints and tags. purge protection, which networks can reach it, private endpoints and tags.
+6 -12
View File
@@ -15,9 +15,6 @@ loadDelay: 150ms # how long the cursor rests on a row before it loads; 0s
sourcesView: grouped # or flat; ` toggles it sourcesView: grouped # or flat; ` toggles it
collapseGroups: true # start with the Sources pane's groups folded; enter or space unfolds one collapseGroups: true # start with the Sources pane's groups folded; enter or space unfolds one
updateCheck: true # look for a newer release once a day (U and lazysecrets update work either way) updateCheck: true # look for a newer release once a day (U and lazysecrets update work either way)
releases: # where updates come from, tried in order (GitHub, or a Gitea or Forgejo server)
- https://github.com/karleinarb/lazysecrets
- https://git.kebcloud.no/keb/lazysecrets
history: true # count uses of items for Recent (R) history: true # count uses of items for Recent (R)
cache: true # keep what was loaded (never values) for the next start cache: true # keep what was loaded (never values) for the next start
providers: providers:
@@ -59,7 +56,7 @@ theme:
activeBorderColor: ["#ff966c", bold] # focused pane activeBorderColor: ["#ff966c", bold] # focused pane
inactiveBorderColor: ["#589ed7"] inactiveBorderColor: ["#589ed7"]
selectedLineBgColor: ["#2d3f76"] # cursor row background (default: palette 8) selectedLineBgColor: ["#2d3f76"] # cursor row background (default: palette 8)
inactiveViewSelectedLineBgColor: ["#2f334d"] # in the other panes (default: palette 8, not bold) inactiveViewSelectedLineBgColor: ["#2f334d"]
defaultFgColor: ["#c8d3f5"] defaultFgColor: ["#c8d3f5"]
optionsTextColor: ["#82aaff"] # keys and labels optionsTextColor: ["#82aaff"] # keys and labels
searchMatchColor: ["#ffc777", bold] searchMatchColor: ["#ffc777", bold]
@@ -128,17 +125,14 @@ are read from disk anyway. Deleting the file is always safe, and
## Updates ## Updates
Once a day (`updateCheck`), in the background, lazysecrets asks for the Once a day (`updateCheck`), in the background, lazysecrets asks the
latest release; within the day it reuses the answer from the state file. release server for the latest release; within the day it reuses the
Releases are published on GitHub and on git.kebcloud.no, and it asks answer from the state file. Offline, or with the server down, it says
them in that order, the next when one doesn't answer within 5 seconds; nothing. A newer release shows in the footer, `v0.1.1 ↑ v0.2.0`; `U` (or
`releases` 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
`lazysecrets update --check`) shows its changelog. `lazysecrets update --check`) shows its changelog.
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, checks it against the release's `checksums.txt`, makes
that server's `checksums.txt`, makes
sure it runs, and only then replaces the binary, atomically: a sure it runs, and only then replaces the binary, atomically: a
lazysecrets that's running keeps working, and the next start is the new lazysecrets that's running keeps working, and the next start is the new
version. A binary a package manager owns (Homebrew, Nix, a distro version. A binary a package manager owns (Homebrew, Nix, a distro
+3 -3
View File
@@ -13,9 +13,9 @@ Secrets in Azure Key Vault-backed scopes are read through Databricks; if
Databricks refuses, lazysecrets reads the backing vault directly with your Databricks refuses, lazysecrets reads the backing vault directly with your
az login (which then needs the [Key Vault roles](azure.md#permissions)). az login (which then needs the [Key Vault roles](azure.md#permissions)).
When Databricks refuses, the pane says what to check: access to the On a scope, the right pane shows who has access to it and how much
workspace and READ on the scope, or, when the workspace's IP access list (manage, write, read). Databricks shows that only to the scope's
refused, the network you're on. managers; to others it says so.
Hide profiles you don't want in the [config](config.md): Hide profiles you don't want in the [config](config.md):
+2 -6
View File
@@ -56,12 +56,8 @@ from the pull request titles. The tag runs the release workflow
(`.gitea/workflows/release.yml`): it tests, then (`.gitea/workflows/release.yml`): it tests, then
[GoReleaser](https://goreleaser.com) (`.goreleaser.yaml`) builds the [GoReleaser](https://goreleaser.com) (`.goreleaser.yaml`) builds the
archives and publishes a Gitea release with them and the release's archives and publishes a Gitea release with them and the release's
changelog section. Then `scripts/github-release.mjs` copies that release, changelog section. `goreleaser release --snapshot --clean` builds the same
notes and files, to the GitHub mirror, with the `GH_RELEASE_TOKEN` archives locally, into `dist/`, without publishing.
secret (a fine-grained token that can write the mirror's contents);
without the secret it skips that. `goreleaser release --snapshot
--clean` builds the same archives locally, into `dist/`, without
publishing.
A suffix (`v0.2.0-rc.1`) makes a pre-release, which leaves `CHANGELOG.md` A suffix (`v0.2.0-rc.1`) makes a pre-release, which leaves `CHANGELOG.md`
alone. alone.
+16 -28
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; cleared from the clipboard after 30s. On an Azure account's row: its object ID; in its pane, the selected field or group |
| `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) |
@@ -60,11 +60,6 @@ match case exactly.
| `L` | sign in again (shown when a login has expired) | | `L` | sign in again (shown when a login has expired) |
| `U` | a newer lazysecrets: its changelog, and update in place (restart to use it); or look for one | | `U` | a newer lazysecrets: its changelog, and update in place (restart to use it); or look for one |
A revealed value is shown as it reads best: JSON indented, and PEM
certificates as their subject, issuer, dates, names and thumbprint, with
any other PEM block (a private key) named but not shown. `y` still copies
it exactly as stored.
A revealed value hides again after `revealTimeout` (60s) and when the A revealed value hides again after `revealTimeout` (60s) and when the
terminal loses focus (`hideOnBlur`); see [config](config.md). terminal loses focus (`hideOnBlur`); see [config](config.md).
@@ -84,24 +79,20 @@ under every account. Both are kept in the [state file](config.md#state).
starred) and `R` on Recent (what you used last). `enter` goes to the starred) and `R` on Recent (what you used last). `enter` goes to the
selected item. selected item.
Search has the scope on the left and what's in it on the right. The Search has the scope on the left (All; all of Azure, Databricks or repos;
scopes are All, then grouped like the Sources pane: each account (or or one source; with `○ ◐ ●` for how much of it is loaded) and what's in
Databricks, or Repos) with its sources under it, with `○ ◐ ●` for how it on the right. All repos searches every repo without asking Azure or
much of each is loaded. Repos searches every repo without asking Azure Databricks for anything. It opens on the
or Databricks for anything. Go to opens on All; `ctrl+s` again narrows source you're in. Typing a search loads what it covers: everything on
it to the source or group selected in the Sources pane, and once more All, else the source picked; results appear as lists arrive. That's once
goes back to All. Typing a search loads what it covers: everything on each time Go to opens, and only what isn't loaded yet; `ctrl+a` loads
All, else the account or source picked; results appear as lists again, lists that failed included.
arrive. That's once each time Go to opens, and only what isn't loaded
yet; `ctrl+a` loads again, lists that failed included.
Like a pane, Go to is either browsing or typing a filter: Like a pane, Go to is either browsing or typing a filter:
| Browsing | Typing | Action | | Browsing | Typing | Action |
|---|---|---| |---|---|---|
| `j`/`k` | `ctrl+j`/`ctrl+k` | move | | `j`/`k` | `ctrl+j`/`ctrl+k` | move |
| `g`/`G` | | top / bottom |
| `ctrl+s` | `ctrl+s` | only the source or group you're in / All |
| `h`/`l`, `tab` | `ctrl+h`/`ctrl+l`, `tab` | scope list / results | | `h`/`l`, `tab` | `ctrl+h`/`ctrl+l`, `tab` | scope list / results |
| `[` / `]` | | previous / next tab | | `[` / `]` | | previous / next tab |
| `f` | `ctrl+f` | star | | `f` | `ctrl+f` | star |
@@ -122,16 +113,13 @@ Secrets. Going into it (`l`, or `4`) keeps it on that; there `j`/`k`,
it's hidden. 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. Focused, the right pane lists more under the your accounts are doing. Focused, the right pane lists more: the version
logo (when there's room): the version and updates (`U`), each account and updates (`U`), each account (`L` on one that needs it signs in), the
(`L` on one that needs it signs in), what's expiring (`enter` goes to config, state and cache files (`e` opens one in your editor), and links
it), this session's errors, newest first (what a pane showed, or the to the docs, source, issues, releases and changelog (`o` opens one; on
footer flashed before you moved on; not kept after quitting), the config, the version or update, its release), then the changelog of
state and cache files (`e` opens one in your editor), and links to the every release up to yours, built in so it needs no network: `/` filters
docs, source, issues, releases and changelog (`o` opens one; on the it (`/fixed`, `/search`). `y` copies any row.
version or update, its release), then the changelog of every release up
to yours, built in so it needs no network: `/` filters it (`/fixed`,
`/search`). `y` copies any row.
| Key | Action | | Key | Action |
|---|---| |---|---|
+14 -40
View File
@@ -7,12 +7,10 @@
# #
# LAZYSECRETS_INSTALL_DIR installs somewhere else. macOS and Linux, arm64 # LAZYSECRETS_INSTALL_DIR installs somewhere else. macOS and Linux, arm64
# and x86_64. # and x86_64.
#
# Releases are on GitHub and git.kebcloud.no, tried in that order;
# LAZYSECRETS_RELEASES (repository addresses, space-separated) uses others.
set -eu set -eu
repos="${LAZYSECRETS_RELEASES:-https://github.com/karleinarb/lazysecrets https://git.kebcloud.no/keb/lazysecrets}" repo=https://git.kebcloud.no/keb/lazysecrets
api=https://git.kebcloud.no/api/v1/repos/keb/lazysecrets
dir="${LAZYSECRETS_INSTALL_DIR:-$HOME/.local/bin}" dir="${LAZYSECRETS_INSTALL_DIR:-$HOME/.local/bin}"
fail() { fail() {
@@ -20,13 +18,12 @@ fail() {
exit 1 exit 1
} }
# Short timeouts, so a server that's down leaves time for the next.
if command -v curl >/dev/null 2>&1; then if command -v curl >/dev/null 2>&1; then
fetch() { curl -fsSL --connect-timeout 5 --max-time 15 "$1"; } fetch() { curl -fsSL "$1"; }
download() { curl -fsSL --connect-timeout 5 -o "$2" "$1"; } download() { curl -fsSL -o "$2" "$1"; }
elif command -v wget >/dev/null 2>&1; then elif command -v wget >/dev/null 2>&1; then
fetch() { wget -qO- -T 15 "$1"; } fetch() { wget -qO- "$1"; }
download() { wget -qO "$2" -T 30 "$1"; } download() { wget -qO "$2" "$1"; }
else else
fail "needs curl or wget" fail "needs curl or wget"
fi fi
@@ -42,26 +39,11 @@ arm64 | aarch64) arch=arm64 ;;
*) fail "no release for $(uname -m)" ;; *) fail "no release for $(uname -m)" ;;
esac esac
# api is a repository's API: GitHub's, or Gitea's (and Forgejo's).
api() {
case "$1" in
https://github.com/*) echo "https://api.github.com/repos/${1#https://github.com/}" ;;
*)
host=${1#*://}
host=${host%%/*}
echo "${1%%://*}://$host/api/v1/repos/${1#*://"$host"/}"
;;
esac
}
tag="${1:-}" tag="${1:-}"
if [ -z "$tag" ]; then if [ -z "$tag" ]; then
for repo in $repos; do tag=$(fetch "$api/releases/latest" | sed -n 's/.*"tag_name":"\([^"]*\)".*/\1/p') ||
tag=$(fetch "$(api "$repo")/releases/latest" 2>/dev/null | fail "couldn't look up the latest release"
sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n 1) || tag= [ -n "$tag" ] || fail "couldn't look up the latest release"
[ -n "$tag" ] && break
done
[ -n "$tag" ] || fail "couldn't look up the latest release on $repos"
fi fi
case "$tag" in case "$tag" in
v*) ;; v*) ;;
@@ -73,19 +55,11 @@ name="lazysecrets_${version}_${os}_${arch}"
tmp=$(mktemp -d) tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT trap 'rm -rf "$tmp"' EXIT
# The archive and its checksums.txt from the first server that has both. echo "Downloading lazysecrets $tag for $os/$arch"
from= download "$repo/releases/download/$tag/$name.tar.gz" "$tmp/$name.tar.gz" ||
for repo in $repos; do fail "couldn't download $tag for $os/$arch: see $repo/releases"
base="$repo/releases/download/$tag" download "$repo/releases/download/$tag/checksums.txt" "$tmp/checksums.txt" ||
if download "$base/checksums.txt" "$tmp/checksums.txt" 2>/dev/null; then fail "couldn't download $tag's checksums.txt"
echo "Downloading lazysecrets $tag for $os/$arch from $repo"
if download "$base/$name.tar.gz" "$tmp/$name.tar.gz"; then
from=$repo
break
fi
fi
done
[ -n "$from" ] || fail "couldn't download $tag for $os/$arch from $repos"
want=$(awk -v f="$name.tar.gz" '$2 == f { print $1 }' "$tmp/checksums.txt") want=$(awk -v f="$name.tar.gz" '$2 == f { print $1 }' "$tmp/checksums.txt")
[ -n "$want" ] || fail "$name.tar.gz isn't in checksums.txt" [ -n "$want" ] || fail "$name.tar.gz isn't in checksums.txt"
-10
View File
@@ -14,7 +14,6 @@ import (
"go.yaml.in/yaml/v3" "go.yaml.in/yaml/v3"
"git.kebcloud.no/keb/lazysecrets/internal/commands" "git.kebcloud.no/keb/lazysecrets/internal/commands"
"git.kebcloud.no/keb/lazysecrets/internal/update"
) )
type Config struct { type Config struct {
@@ -41,10 +40,6 @@ type Config struct {
// background; defaults to true. U and lazysecrets update work either // background; defaults to true. U and lazysecrets update work either
// way. // way.
UpdateCheck *bool `yaml:"updateCheck"` UpdateCheck *bool `yaml:"updateCheck"`
// Releases are the repositories updates come from, tried in order:
// on GitHub, or a Gitea or Forgejo server. Defaults to GitHub, then
// git.kebcloud.no (update.DefaultServers).
Releases []string `yaml:"releases"`
// Cache keeps what was loaded (never values) between runs, so lists // Cache keeps what was loaded (never values) between runs, so lists
// show at once and refresh in the background; defaults to true. // show at once and refresh in the background; defaults to true.
Cache *bool `yaml:"cache"` Cache *bool `yaml:"cache"`
@@ -216,11 +211,6 @@ func (c Config) validate(path string) error {
if d := c.LoadDelay; d != nil && (*d < 0 || *d > 5*time.Second) { if d := c.LoadDelay; d != nil && (*d < 0 || *d > 5*time.Second) {
return fmt.Errorf("%s: loadDelay: %s is not between 0s and 5s", path, *d) return fmt.Errorf("%s: loadDelay: %s is not between 0s and 5s", path, *d)
} }
for i, r := range c.Releases {
if _, err := update.API(r); err != nil || !strings.HasPrefix(r, "https://") {
return fmt.Errorf("%s: releases[%d]: %q is not an https:// repository address, like %s", path, i, r, update.GitHubRepo)
}
}
for i, v := range c.Providers.Azure.Vaults { for i, v := range c.Providers.Azure.Vaults {
if !strings.HasPrefix(v, "https://") { if !strings.HasPrefix(v, "https://") {
return fmt.Errorf("%s: providers.azure.vaults[%d]: %q is not an https:// vault URL", path, i, v) return fmt.Errorf("%s: providers.azure.vaults[%d]: %q is not an https:// vault URL", path, i, v)
-9
View File
@@ -70,8 +70,6 @@ func TestLoadRejectsMistakes(t *testing.T) {
"old layout": "azure:\n vaults: [https://kv.vault.azure.net/]\n", "old layout": "azure:\n vaults: [https://kv.vault.azure.net/]\n",
"duration": "timeout: soon\n", "duration": "timeout: soon\n",
"view": "sourcesView: tree\n", "view": "sourcesView: tree\n",
"releases": "releases: [git.example.com/team/lazysecrets]\n",
"http": "releases: [http://git.example.com/team/lazysecrets]\n",
} { } {
if _, err := Load(write(t, content)); err == nil { if _, err := Load(write(t, content)); err == nil {
t.Errorf("%s: no error", name) t.Errorf("%s: no error", name)
@@ -115,13 +113,6 @@ theme:
} }
} }
func TestReleases(t *testing.T) {
c, err := Load(write(t, "releases: [https://git.example.com/team/lazysecrets]\n"))
if err != nil || len(c.Releases) != 1 {
t.Errorf("releases = %v, %v", c.Releases, err)
}
}
func TestLoadDelay(t *testing.T) { func TestLoadDelay(t *testing.T) {
c, err := Load(write(t, "loadDelay: 300ms\n")) c, err := Load(write(t, "loadDelay: 300ms\n"))
if err != nil || c.LoadDelay == nil || *c.LoadDelay != 300*time.Millisecond { if err != nil || c.LoadDelay == nil || *c.LoadDelay != 300*time.Millisecond {
+70
View File
@@ -1,10 +1,15 @@
package azure package azure
import ( import (
"context"
"encoding/json"
"fmt" "fmt"
"net/http"
"slices" "slices"
"strings" "strings"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
) )
@@ -155,3 +160,68 @@ func directoryRoles(wids any) string {
slices.Sort(names) slices.Sort(names)
return strings.Join(names, ", ") return strings.Join(names, ", ")
} }
// graphGroups lists the signed-in user's groups, nested ones too, from
// Microsoft Graph with az's token for it.
const graphGroups = "https://graph.microsoft.com/v1.0/me/transitiveMemberOf?$select=displayName&$top=999"
var _ provider.AccountGroups = (*Provider)(nil)
// Groups lists the groups the account is a member of, by name.
func (p *Provider) Groups(ctx context.Context) ([]string, error) {
if p.account == nil || (p.account.cloud != "" && p.account.cloud != "AzureCloud") {
return nil, provider.ErrUnsupported
}
tok, err := p.cred.GetToken(ctx, policy.TokenRequestOptions{Scopes: []string{"https://graph.microsoft.com/.default"}})
if err != nil {
return nil, err
}
var names []string
for next := graphGroups; next != ""; {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, next, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+tok.Token)
resp, err := p.http().Do(req)
if err != nil {
return nil, err
}
var page struct {
Value []struct {
Type string `json:"@odata.type"`
Name string `json:"displayName"`
} `json:"value"`
Next string `json:"@odata.nextLink"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
err = json.NewDecoder(resp.Body).Decode(&page)
resp.Body.Close()
switch {
case err != nil:
return nil, fmt.Errorf("listing groups from Microsoft Graph: %w", err)
case page.Error != nil:
return nil, fmt.Errorf("listing groups from Microsoft Graph: %s", page.Error.Message)
case resp.StatusCode != http.StatusOK:
return nil, fmt.Errorf("listing groups from Microsoft Graph: %s", resp.Status)
}
for _, v := range page.Value {
if v.Type == "#microsoft.graph.group" && v.Name != "" {
names = append(names, v.Name)
}
}
next = page.Next
}
slices.SortFunc(names, func(a, b string) int { return strings.Compare(strings.ToLower(a), strings.ToLower(b)) })
return names, nil
}
// http is the client for Microsoft Graph; tests replace it.
func (p *Provider) http() *http.Client {
if p.graphClient != nil {
return p.graphClient
}
return http.DefaultClient
}
+47
View File
@@ -1,7 +1,17 @@
package azure package azure
import ( import (
"context"
"errors"
"io"
"net/http"
"slices"
"strings"
"testing" "testing"
"time"
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
) )
@@ -65,3 +75,40 @@ func TestSignInMethods(t *testing.T) {
} }
} }
} }
type staticToken struct{}
func (staticToken) GetToken(context.Context, policy.TokenRequestOptions) (azcore.AccessToken, error) {
return azcore.AccessToken{Token: "tok", ExpiresOn: time.Now().Add(time.Hour)}, nil
}
type graphPages map[string]string // by URL
func (g graphPages) RoundTrip(r *http.Request) (*http.Response, error) {
body, ok := g[r.URL.String()]
if !ok || r.Header.Get("Authorization") != "Bearer tok" {
return &http.Response{StatusCode: 404, Status: "404 Not Found", Body: io.NopCloser(strings.NewReader(`{}`))}, nil
}
return &http.Response{StatusCode: 200, Status: "200 OK", Body: io.NopCloser(strings.NewReader(body))}, nil
}
func TestGroups(t *testing.T) {
next := "https://graph.microsoft.com/v1.0/next"
p := &Provider{
cred: staticToken{},
account: &accountDetails{account: account{user: "alice@contoso.com"}},
graphClient: &http.Client{Transport: graphPages{
graphGroups: `{"value":[{"@odata.type":"#microsoft.graph.group","displayName":"vault readers"},
{"@odata.type":"#microsoft.graph.directoryRole","displayName":"Global Reader"}],"@odata.nextLink":"` + next + `"}`,
next: `{"value":[{"@odata.type":"#microsoft.graph.group","displayName":"Platform team"}]}`,
}},
}
got, err := p.Groups(context.Background())
if err != nil || !slices.Equal(got, []string{"Platform team", "vault readers"}) {
t.Errorf("groups %q, %v", got, err)
}
p.account.cloud = "AzureChinaCloud"
if _, err := p.Groups(context.Background()); !errors.Is(err, provider.ErrUnsupported) {
t.Errorf("other cloud: %v", err)
}
}
+2
View File
@@ -9,6 +9,7 @@ import (
"crypto/x509" "crypto/x509"
"errors" "errors"
"fmt" "fmt"
"net/http"
"regexp" "regexp"
"slices" "slices"
"strings" "strings"
@@ -39,6 +40,7 @@ type Provider struct {
cred azcore.TokenCredential cred azcore.TokenCredential
opts options opts options
account *accountDetails // nil for a service principal from AZURE_* account *accountDetails // nil for a service principal from AZURE_*
graphClient *http.Client // for Groups; nil for the default
subs *armsubscriptions.Client subs *armsubscriptions.Client
graph *armresourcegraph.Client graph *armresourcegraph.Client
+2 -12
View File
@@ -175,11 +175,8 @@ func TestForbiddenIsShortened(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("expected error") t.Fatal("expected error")
} }
if got, want := err.Error(), "403 Forbidden: Caller is not authorized to perform action on resource.\n\nCheck:\n• your role or group is activated in PIM\n• "; !strings.HasPrefix(got, want) { if got, want := err.Error(), "403 Forbidden: Caller is not authorized to perform action on resource."; got != want {
t.Errorf("error = %q, want %q…", got, want) t.Errorf("error = %q, want %q", got, want)
}
if checks := deniedChecks("Client address is not authorized and caller is not a trusted service."); !strings.Contains(checks[0], "network") {
t.Errorf("firewall checks = %q", checks)
} }
var re *azcore.ResponseError var re *azcore.ResponseError
if !errors.As(err, &re) || re.StatusCode != 403 { if !errors.As(err, &re) || re.StatusCode != 403 {
@@ -187,13 +184,6 @@ func TestForbiddenIsShortened(t *testing.T) {
} }
} }
func TestShortenPolicyDenied(t *testing.T) {
msg := "The user, group or application 'appid=04b07795;oid=1234;numgroups=83;iss=https://sts.windows.net/t/' does not have secrets list permission on key vault 'kv-app;location=norwayeast'. For help resolving this issue, please see https://go.microsoft.com/fwlink/?linkid=2125287"
if got := shortenDenied(msg); got != "no secrets list permission in the vault's access policies" {
t.Errorf("shortenDenied = %q", got)
}
}
func TestShortenDenied(t *testing.T) { func TestShortenDenied(t *testing.T) {
msg := "Caller is not authorized to perform action on resource.\r\nIf role assignments, deny assignments or role definitions were changed recently, please observe propagation time.\r\nCaller: appid=04b07795;oid=1234\r\nAction: 'Microsoft.KeyVault/vaults/secrets/readMetadata/action'\r\nResource: '/subscriptions/x/vaults/kv'" msg := "Caller is not authorized to perform action on resource.\r\nIf role assignments, deny assignments or role definitions were changed recently, please observe propagation time.\r\nCaller: appid=04b07795;oid=1234\r\nAction: 'Microsoft.KeyVault/vaults/secrets/readMetadata/action'\r\nResource: '/subscriptions/x/vaults/kv'"
want := "Caller is not authorized to perform action on resource; missing Microsoft.KeyVault/vaults/secrets/readMetadata/action" want := "Caller is not authorized to perform action on resource; missing Microsoft.KeyVault/vaults/secrets/readMetadata/action"
+1 -29
View File
@@ -11,7 +11,6 @@ import (
"fmt" "fmt"
"math/big" "math/big"
"net/http" "net/http"
"regexp"
"strings" "strings"
"github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore"
@@ -280,24 +279,13 @@ func cleanError(err error) error {
} }
} }
} }
se := &serviceError{status: re.StatusCode, msg: shortenDenied(msg), err: err} return &serviceError{status: re.StatusCode, msg: shortenDenied(msg), err: err}
if se.status == http.StatusForbidden {
return &provider.Denied{Err: se, Checks: deniedChecks(se.msg)}
} }
return se
}
var policyDenied = regexp.MustCompile(`does not have (\w+ \w+) permission on key vault`)
// shortenDenied reduces Key Vault's RBAC denial, which runs to several // shortenDenied reduces Key Vault's RBAC denial, which runs to several
// sentences plus caller and resource IDs, to its first sentence and the // sentences plus caller and resource IDs, to its first sentence and the
// missing action. // missing action.
func shortenDenied(msg string) string { func shortenDenied(msg string) string {
// Access policies: "The user, group or application 'appid=…;oid=…'
// does not have secrets list permission on key vault 'kv;location=…'".
if m := policyDenied.FindStringSubmatch(msg); m != nil {
return "no " + m[1] + " permission in the vault's access policies"
}
const marker = "Action: '" const marker = "Action: '"
i := strings.Index(msg, marker) i := strings.Index(msg, marker)
if i < 0 { if i < 0 {
@@ -329,20 +317,4 @@ func (e *serviceError) Error() string {
return s return s
} }
// deniedChecks are what to check when Key Vault refuses: the network,
// for its firewall's refusals, else access.
func deniedChecks(msg string) []string {
if strings.Contains(msg, "Client address is not authorized") || strings.Contains(msg, "network access") ||
strings.Contains(msg, "private link") {
return []string{
"you're on a network the vault lets in (VPN, office)",
"the vault's owner has let your network in",
}
}
return []string{
"your role or group is activated in PIM",
"the vault's owner has given you access: a Key Vault role, or its access policies",
}
}
func (e *serviceError) Unwrap() error { return e.err } func (e *serviceError) Unwrap() error { return e.err }
+3 -43
View File
@@ -317,52 +317,15 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
return v, nil return v, nil
} }
// deniedChecks are what to check when Databricks refuses: the network,
// when the workspace's IP access list refused, else access, and for a
// profile signed in through az, which account az is on.
func deniedChecks(pr profile, msg string) []string {
if strings.Contains(msg, "IP ACL") || strings.Contains(msg, "IP access list") {
return []string{
"you're on a network the workspace lets in (VPN, office)",
"the workspace's admin has let your network in",
}
}
var checks []string
if pr.authType == "azure-cli" {
checks = append(checks, "az is signed in as the account with access: profile "+pr.name+" uses your az login (az account show)")
}
return append(checks,
"you have access to the workspace",
"the scope's manager has given you READ on it, or a group you're in",
)
}
var errNoFallback = errors.New("no Key Vault fallback") var errNoFallback = errors.New("no Key Vault fallback")
// combine reports both the Databricks and the Key Vault failure, with // combine reports both the Databricks and the Key Vault failure. An auth
// both their checks after. An auth error from either stays reachable // error from either stays reachable with errors.As.
// with errors.As.
func combine(dbxErr, kvErr error) error { func combine(dbxErr, kvErr error) error {
if errors.Is(kvErr, errNoFallback) { if errors.Is(kvErr, errNoFallback) {
return dbxErr return dbxErr
} }
var checks []string return fmt.Errorf("%w; Key Vault fallback: %w", dbxErr, kvErr)
strip := func(err error) error {
if d, ok := err.(*provider.Denied); ok {
for _, c := range d.Checks {
if !slices.Contains(checks, c) {
checks = append(checks, c)
}
}
return d.Err
}
return err
}
err := fmt.Errorf("%w; Key Vault fallback: %w", strip(dbxErr), strip(kvErr))
if len(checks) > 0 {
return &provider.Denied{Err: err, Checks: checks}
}
return err
} }
// classify turns authentication failures into *provider.AuthError: a 401 // classify turns authentication failures into *provider.AuthError: a 401
@@ -373,9 +336,6 @@ func classify(pr profile, err error) error {
} }
var api *apierr.APIError var api *apierr.APIError
if errors.As(err, &api) { if errors.As(err, &api) {
if api.StatusCode == 403 {
return &provider.Denied{Err: err, Checks: deniedChecks(pr, api.Message)}
}
if api.StatusCode != 401 { if api.StatusCode != 401 {
return err return err
} }
+18 -20
View File
@@ -140,8 +140,13 @@ func workspaceAPI(t *testing.T, status int) *httptest.Server {
map[string]any{"key": "storage-key", "last_updated_timestamp": 1767225600000}, map[string]any{"key": "storage-key", "last_updated_timestamp": 1767225600000},
map[string]any{"key": "client-secret"}, map[string]any{"key": "client-secret"},
}}) }})
case r.URL.Path == "/api/2.0/secrets/list" && q.Get("scope") == "locked": case r.URL.Path == "/api/2.0/secrets/acls/list" && q.Get("scope") == "app":
write(w, 403, map[string]any{"error_code": "PERMISSION_DENIED", "message": "User does not have READ permission on scope locked"}) write(w, 200, map[string]any{"items": []any{
map[string]any{"principal": "users", "permission": "READ"},
map[string]any{"principal": "admins", "permission": "MANAGE"},
}})
case r.URL.Path == "/api/2.0/secrets/acls/list" && q.Get("scope") == "locked":
write(w, 403, map[string]any{"error_code": "PERMISSION_DENIED", "message": "User does not have MANAGE permission"})
case r.URL.Path == "/api/2.0/secrets/get" && q.Get("scope") == "app": case r.URL.Path == "/api/2.0/secrets/get" && q.Get("scope") == "app":
write(w, 200, map[string]any{"key": q.Get("key"), write(w, 200, map[string]any{"key": q.Get("key"),
"value": base64.StdEncoding.EncodeToString([]byte("value-of-" + q.Get("key")))}) "value": base64.StdEncoding.EncodeToString([]byte("value-of-" + q.Get("key")))})
@@ -294,26 +299,19 @@ func TestClassify(t *testing.T) {
} }
} }
func TestDeniedSaysWhatToCheck(t *testing.T) { func TestScopeDetails(t *testing.T) {
p := newTestProvider(t, workspaceAPI(t, 0), nil) p := newTestProvider(t, workspaceAPI(t, 0), nil)
_, err := p.Objects(context.Background(), provider.Container{Source: provider.Source{ID: "prod"}, Name: "locked"}, provider.TypeSecret) src := provider.Source{ID: "prod"}
var d *provider.Denied attrs, err := p.Details(context.Background(), provider.Container{Source: src, Name: "app"})
// The test profile signs in through az: that's the first check. if err != nil {
if !errors.As(err, &d) || !strings.Contains(err.Error(), "READ permission on scope locked\n\nCheck:\n• az is signed in as the account with access: profile prod uses your az login") || t.Fatal(err)
!strings.Contains(err.Error(), "\n• you have access to the workspace") {
t.Errorf("err %q", err)
} }
if got := deniedChecks(profile{authType: "pat"}, "denied"); strings.Contains(got[0], "az") { want := []provider.Attr{{Label: "access", Value: "admins · manage"}, {Value: "users · read"}}
t.Errorf("a token profile's checks %q", got) if !reflect.DeepEqual(attrs, want) {
t.Errorf("app: %+v", attrs)
} }
if got := deniedChecks(profile{}, "Source IP address: 1.2.3.4 is blocked by Databricks IP ACL for workspace: 1"); !strings.Contains(got[0], "network") { attrs, err = p.Details(context.Background(), provider.Container{Source: src, Name: "locked"})
t.Errorf("IP ACL checks %q", got) if err != nil || len(attrs) != 1 || !strings.Contains(attrs[0].Value, "managers") {
} t.Errorf("locked: %+v, %v", attrs, err)
// Both refused: one message, then both lists of checks.
err = combine(&provider.Denied{Err: errors.New("dbx"), Checks: []string{"a", "b"}},
&provider.Denied{Err: errors.New("kv"), Checks: []string{"b", "c"}})
if got := err.Error(); got != "dbx; Key Vault fallback: kv\n\nCheck:\n• a\n• b\n• c" {
t.Errorf("combined %q", got)
} }
} }
+43
View File
@@ -0,0 +1,43 @@
package databricks
import (
"cmp"
"context"
"errors"
"slices"
"strings"
"github.com/databricks/databricks-sdk-go/apierr"
"github.com/databricks/databricks-sdk-go/service/workspace"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// Details are who has access to a scope, and how much. Only the scope's
// managers may see that; to others it says so.
func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provider.Attr, error) {
w, pr, err := p.client(c.Source.ID)
if err != nil {
return nil, err
}
acls, err := w.ListAclsAll(ctx, workspace.ListAclsRequest{Scope: c.Name})
var api *apierr.APIError
switch {
case errors.As(err, &api) && api.StatusCode == 403:
return []provider.Attr{{Label: "access", Value: "only the scope's managers can see who has it"}}, nil
case err != nil:
return nil, classify(pr, err)
case len(acls) == 0:
return []provider.Attr{{Label: "access", Value: "workspace admins only"}}, nil
}
rank := map[workspace.AclPermission]int{workspace.AclPermissionManage: 0, workspace.AclPermissionWrite: 1, workspace.AclPermissionRead: 2}
slices.SortFunc(acls, func(a, b workspace.AclItem) int {
return cmp.Or(cmp.Compare(rank[a.Permission], rank[b.Permission]), cmp.Compare(a.Principal, b.Principal))
})
out := make([]provider.Attr, len(acls))
for i, a := range acls {
out[i] = provider.Attr{Value: a.Principal + " · " + strings.ToLower(string(a.Permission))}
}
out[0].Label = "access"
return out, nil
}
+2 -3
View File
@@ -24,7 +24,6 @@ type Provider struct {
errs map[listKey]error errs map[listKey]error
values map[string]string // by object ID values map[string]string // by object ID
versions map[string][]provider.Object // by object ID versions map[string][]provider.Object // by object ID
details map[string][]provider.Attr // by container ID
} }
type listKey struct { type listKey struct {
@@ -102,7 +101,7 @@ func (p *Provider) Versions(ctx context.Context, o provider.Object) ([]provider.
if err := p.wait(ctx); err != nil { if err := p.wait(ctx); err != nil {
return nil, err return nil, err
} }
if p.kind != provider.KindAzure { // only Key Vault keeps versions if p.kind == provider.KindDatabricks {
return nil, provider.ErrUnsupported return nil, provider.ErrUnsupported
} }
vs := p.versions[o.ID] vs := p.versions[o.ID]
@@ -210,7 +209,7 @@ func (p *Provider) addContainer(src provider.Source, name, detail string, types
if p.kind == provider.KindDatabricks { if p.kind == provider.KindDatabricks {
vault, _ = strings.CutPrefix(detail, "Azure Key Vault-backed: ") vault, _ = strings.CutPrefix(detail, "Azure Key Vault-backed: ")
} }
if p.kind == provider.KindAzure || (p.kind == provider.KindDatabricks && vault != detail) { if p.kind == provider.KindAzure || vault != detail {
c.URL = portal + "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/" + vault + "/overview" c.URL = portal + "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/" + vault + "/overview"
} }
p.containers[src.ID] = append(p.containers[src.ID], c) p.containers[src.ID] = append(p.containers[src.ID], c)
-136
View File
@@ -1,136 +0,0 @@
package fake
import (
"context"
"strings"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
var _ provider.ContainerDetails = (*Provider)(nil)
// Details returns a file's details, like the repo provider's.
func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provider.Attr, error) {
attrs, ok := p.details[c.ID]
if !ok {
return nil, provider.ErrUnsupported
}
return attrs, ctx.Err()
}
// Repos returns a fake repo provider: three git repos with SOPS and plain
// .env files, the first one the repo lazysecrets was started in.
func Repos() *Provider {
p := newProvider(provider.KindRepo, 0)
p.id, p.label = "Repos", "Repos"
p.details = map[string][]provider.Attr{}
repo := func(name string, here bool) provider.Source {
s := p.addSource("/home/dev/git/"+name, name, "github.com/acme/"+name)
s.Here = here
p.sources[len(p.sources)-1] = s
return s
}
// file adds a file and its keys, alternating name and value. git and
// commit are what git says of it: "tracked", "ignored" or "untracked".
file := func(src provider.Source, path, method, git, commit string, kv ...string) {
sops := method != ""
detail := "env · plain text"
if sops {
detail = "sops · " + method
}
c := p.addContainer(src, path, detail, provider.TypeSecret)
attrs := []provider.Attr{{Label: "format", Value: "env"}, {Label: "encrypted", Value: "no: plain text in the file"}}
if sops {
attrs = []provider.Attr{{Label: "format", Value: "sops " + format(path)}, {Label: "encryption", Value: method}}
}
for i := 0; i+1 < len(kv); i += 2 {
o := provider.Object{Name: kv[i], Attrs: attrs}
if sops {
o.Updated = base
}
p.add(c, provider.TypeSecret, o, kv[i+1])
}
d := []provider.Attr{{Label: "encryption", Value: "none: plain text"}}
if sops {
d = []provider.Attr{{Label: "encryption", Value: detail}}
keys := map[string]string{
"age": "age age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p",
"azure_kv": "azure_kv https://kv-sops-prod.vault.azure.net/keys/sops/1f2e3d4c",
}
for i, m := range strings.Split(method, ", ") {
label := ""
if i == 0 {
label = "keys" // on the first only, like the repo provider
}
d = append(d, provider.Attr{Label: label, Value: keys[m]})
}
d = append(d, provider.Attr{Label: "encrypted", Value: base.Local().Format("2006-01-02 15:04")})
}
switch {
case git == "tracked" && !sops:
d = append(d, provider.Attr{Label: "git", Value: "tracked: its values are in the repo's history", Warn: true})
case git == "untracked":
d = append(d, provider.Attr{Label: "git", Value: "untracked and not ignored: easy to commit by mistake", Warn: !sops})
default:
d = append(d, provider.Attr{Label: "git", Value: git})
}
if commit != "" {
d = append(d, provider.Attr{Label: "commit", Value: commit})
}
p.details[c.ID] = d
}
api := repo("lazysecrets-demo", true)
file(api, "deploy/prod/secrets.yaml", "age, azure_kv", "tracked", "4f9c2ab · 3 days ago",
"database.password", "pr0d-Pg-7f3k9QwZ",
"database.url", "postgres://app:pr0d-Pg-7f3k9QwZ@pg-demo-prod:5432/app",
"stripe.secret_key", "sk_live_demo_51NxDemoKey0000",
"jwt.signing_key", "c2lnbmluZy1rZXktZm9yLXRoZS1kZW1v",
"gcp.service_account", `{"type":"service_account","project_id":"lazysecrets-demo","client_email":"app@lazysecrets-demo.iam.gserviceaccount.com","private_key_id":"demo1234"}`)
file(api, "deploy/staging/secrets.yaml", "age", "tracked", "a71e05d · 2 weeks ago",
"database.password", "stg-Pg-2b8d1L",
"database.url", "postgres://app:stg-Pg-2b8d1L@pg-demo-stg:5432/app",
"stripe.secret_key", "sk_test_demo_51NxDemoKey0000")
file(api, "config/oauth.enc.json", "age", "tracked", "e03b6f1 · 2 months ago",
"github.client_secret", "ghs_demo0000000000000000000000",
"google.client_secret", "GOCSPX-demo-000000000000")
file(api, ".env", "", "ignored", "",
"DATABASE_URL", "postgres://app:local@localhost:5432/app",
"STRIPE_SECRET_KEY", "sk_test_demo_local",
"LOG_LEVEL", "debug")
infra := repo("platform-infra", false)
file(infra, "terraform/prod/secrets.enc.yaml", "azure_kv", "tracked", "9d40c3e · yesterday",
"postgres.admin_password", "infra-Pg-admin-0Xq",
"grafana.admin_password", "grafana-demo-admin",
"acr.pull_token", "acr-demo-pull-token")
file(infra, "k8s/monitoring/alertmanager.enc.yaml", "age", "tracked", "1c58e7a · 5 days ago",
"slack.webhook_url", "https://hooks.slack.com/services/T000/B000/demo",
"pagerduty.routing_key", "pd-demo-routing-key")
file(infra, "scripts/.env", "", "tracked", "77b2d90 · 8 months ago",
"AZURE_CLIENT_SECRET", "azure-demo-client-secret",
"ARM_SUBSCRIPTION_ID", "00000000-0000-0000-0000-000000000000")
web := repo("web-frontend", false)
file(web, ".env.production", "age", "tracked", "b2e91f4 · 4 days ago",
"SENTRY_AUTH_TOKEN", "sntrys_demo_token",
"NEXT_PUBLIC_API_URL", "https://api.acme.example")
file(web, ".env.local", "", "untracked", "",
"NEXT_PUBLIC_API_URL", "http://localhost:8080",
"ANALYTICS_KEY", "demo-analytics-key")
return p
}
// format is a SOPS file's format, by its name.
func format(path string) string {
switch {
case strings.HasSuffix(path, ".json"):
return "json"
case strings.HasSuffix(path, ".yaml"):
return "yaml"
}
return "dotenv"
}
+10 -20
View File
@@ -12,7 +12,6 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"strings"
"time" "time"
) )
@@ -203,26 +202,17 @@ type Account struct {
Copy, CopyLabel string Copy, CopyLabel string
} }
// Denied is a refusal with what to check, from what the service said: a
// role to activate in PIM, access to ask for, the network. Error is the
// refusal, then the checks as a list.
type Denied struct {
Err error
Checks []string
}
func (d *Denied) Error() string {
if len(d.Checks) == 0 {
return d.Err.Error()
}
return d.Err.Error() + "\n\nCheck:\n• " + strings.Join(d.Checks, "\n• ")
}
func (d *Denied) Unwrap() error { return d.Err }
// ContainerDetails is implemented by providers that can say more about a // ContainerDetails is implemented by providers that can say more about a
// container than its listing does: how a file is encrypted and what git // container than its listing does: a vault's settings, who has access to
// knows of it. ErrUnsupported means there's nothing more for c. // a scope, how a file is encrypted and what git knows of it. It may go
// over the network; ErrUnsupported means there's nothing more for c.
type ContainerDetails interface { type ContainerDetails interface {
Details(ctx context.Context, c Container) ([]Attr, error) Details(ctx context.Context, c Container) ([]Attr, error)
} }
// AccountGroups is implemented by providers that can list the groups
// their account is a member of, directly or through other groups. It
// goes over the network.
type AccountGroups interface {
Groups(ctx context.Context) ([]string, error)
}
+1 -1
View File
@@ -55,7 +55,7 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
} }
return out, nil return out, nil
} }
if last, ok := git("log", "-1", "--format=%h · %ar", "--", rel); ok && last != "" { if last, ok := git("log", "-1", "--format=%h · %ar · %an: %s", "--", rel); ok && last != "" {
out = append(out, provider.Attr{Label: "last commit", Value: last}) out = append(out, provider.Attr{Label: "last commit", Value: last})
} }
return out, nil return out, nil
+1 -1
View File
@@ -505,7 +505,7 @@ func TestFileDetails(t *testing.T) {
d := details("secrets/app.yaml") d := details("secrets/app.yaml")
if d["encryption"].Value != "sops · age" || d["keys"].Value != "age age1xxxx" || d["git"].Value != "tracked" || if d["encryption"].Value != "sops · age" || d["keys"].Value != "age age1xxxx" || d["git"].Value != "tracked" ||
!strings.HasSuffix(d["last commit"].Value, " ago") || strings.Contains(d["last commit"].Value, ":") || d["encrypted"].Value == "" { !strings.Contains(d["last commit"].Value, "t: files") || d["encrypted"].Value == "" {
t.Errorf("sops yaml: %+v", d) t.Errorf("sops yaml: %+v", d)
} }
if d := details("prod.env"); d["keys"].Value != "age age1xxxx" { if d := details("prod.env"); d["keys"].Value != "age age1xxxx" {
+11 -23
View File
@@ -56,7 +56,7 @@ const (
slotDescribe // extra metadata for the selected object slotDescribe // extra metadata for the selected object
slotCopy // fetch a value straight to the clipboard slotCopy // fetch a value straight to the clipboard
slotVersions // count the selected object's versions slotVersions // count the selected object's versions
slotDetails // a file's details slotDetails // a vault's, scope's or file's details
slotCount slotCount
) )
@@ -172,15 +172,13 @@ type Model struct {
clip Clipboard clip Clipboard
clipClear time.Duration clipClear time.Duration
openURL func(string) error openURL func(string) error
// fields is the fourth pane for anything but a secret; lastLeft is // fields is the fourth pane for anything but a secret, with each
// the left pane it describes while the fourth has focus (fields.go). // account's groups as they load; lastLeft is the left pane it
// describes while the fourth has focus (fields.go).
fields panes.Fields fields panes.Fields
lastLeft pane lastLeft pane
groups map[string]groupsState
details map[string]detailsState // by containerKey details map[string]detailsState // by containerKey
// errLog is this session's errors, newest first; loggedFlash the
// footer's text when last looked at (errlog.go).
errLog []loggedErr
loggedFlash string
getenv func(string) string getenv func(string) string
nvimOpen func(server, path string, line int) error // edit.go nvimOpen func(server, path string, line int) error // edit.go
now func() time.Time now func() time.Time
@@ -379,6 +377,7 @@ func New(providers []provider.Provider, opts Options) Model {
getenv: os.Getenv, getenv: os.Getenv,
nvimOpen: nvimOpen, nvimOpen: nvimOpen,
cache: newCache(), cache: newCache(),
groups: map[string]groupsState{},
details: map[string]detailsState{}, details: map[string]detailsState{},
fields: panes.NewFields(), fields: panes.NewFields(),
} }
@@ -489,7 +488,7 @@ func (m *Model) setVersions(items []provider.Object) {
} }
func (m Model) Init() tea.Cmd { func (m Model) Init() tea.Cmd {
cmds := []tea.Cmd{m.sourcesCmd(), m.spin.Tick, m.checkClipboard(), tea.RequestBackgroundColor} cmds := []tea.Cmd{m.sourcesCmd(), m.spin.Tick, m.checkClipboard()}
if m.cachePath != "" || m.placeDir != "" { if m.cachePath != "" || m.placeDir != "" {
cmds = append(cmds, tea.Tick(persistEvery, func(time.Time) tea.Msg { return persistTickMsg{} })) cmds = append(cmds, tea.Tick(persistEvery, func(time.Time) tea.Msg { return persistTickMsg{} }))
} }
@@ -514,9 +513,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd var cmds []tea.Cmd
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.BackgroundColorMsg:
m.st = m.st.OnBackground(msg.Color, msg.IsDark())
return m, nil
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.w, m.h = msg.Width, msg.Height m.w, m.h = msg.Width, msg.Height
@@ -594,6 +590,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case persistedMsg: case persistedMsg:
m.handlePersisted(msg) m.handlePersisted(msg)
case groupsMsg:
m.groups[msg.provider] = groupsState{names: msg.names, err: msg.err}
case detailsMsg: case detailsMsg:
if msg.req == m.reqID[slotDetails] { if msg.req == m.reqID[slotDetails] {
m.done(slotDetails) m.done(slotDetails)
@@ -630,9 +629,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.done(slotContainers) m.done(slotContainers)
if msg.err != nil { if msg.err != nil {
m.noteErr(msg.err) m.noteErr(msg.err)
if src, ok := m.selectedSource(); ok {
m.logErrOf(src.Name, msg.err)
}
delete(m.cache.containers, msg.key) delete(m.cache.containers, msg.key)
m.containers.SetError(msg.err) m.containers.SetError(msg.err)
} else { } else {
@@ -651,9 +647,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.closeVersions() m.closeVersions()
} else if msg.err != nil { } else if msg.err != nil {
m.noteErr(msg.err) m.noteErr(msg.err)
if c, ok := m.containers.Selected(); ok {
m.logErrOf(c.Name, msg.err)
}
delete(m.cache.objects, msg.key) delete(m.cache.objects, msg.key)
m.secrets.SetError(msg.err) m.secrets.SetError(msg.err)
} else { } else {
@@ -676,7 +669,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.done(slotValue) m.done(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.detail.SetValueError(msg.err) m.detail.SetValueError(msg.err)
} else { } else {
m.detail.SetValue(msg.value) m.detail.SetValue(msg.value)
@@ -703,7 +695,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.done(slotDescribe) m.done(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.detail.SetDescribeError(msg.err) m.detail.SetDescribeError(msg.err)
} else { } else {
m.cache.details[msg.key] = entry[provider.Object]{msg.obj, m.now()} m.cache.details[msg.key] = entry[provider.Object]{msg.obj, m.now()}
@@ -774,7 +765,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.focus != paneDetail { if m.focus != paneDetail {
m.lastLeft = m.focus m.lastLeft = m.focus
} }
m.logFlash()
cmds = append(cmds, m.syncFields()) cmds = append(cmds, m.syncFields())
if m.anyLoading() && !m.spinning { if m.anyLoading() && !m.spinning {
m.spinning = true m.spinning = true
@@ -1369,7 +1359,6 @@ func (m *Model) handleSources(msg sourcesMsg) tea.Cmd {
for _, p := range m.providers { for _, p := range m.providers {
if r, ok := m.sourceResults[p.ID()]; ok && r.err != nil { if r, ok := m.sourceResults[p.ID()]; ok && r.err != nil {
m.noteErr(r.err) m.noteErr(r.err)
m.logErrOf(p.Label(), r.err)
errs = append(errs, fmt.Sprintf("%s: %v", p.Label(), r.err)) errs = append(errs, fmt.Sprintf("%s: %v", p.Label(), r.err))
} }
} }
@@ -1719,8 +1708,7 @@ func (m Model) footerView() string {
} else if l != nil && l.Filtering() { } else if l != nil && l.Filtering() {
help = m.helpBar(keys.FilterHelp{Map: m.keys}.ShortHelp(), 2) help = m.helpBar(keys.FilterHelp{Map: m.keys}.ShortHelp(), 2)
} else { } else {
revealed := m.detail.State() == panes.ValueShown help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, of: m.detailOf(), commands: len(m.commandsHere()) > 0, account: m.fields.Shown()}.ShortHelp(), 2)
help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, of: m.detailOf(), commands: len(m.commandsHere()) > 0, account: m.fields.Shown(), revealed: &revealed}.ShortHelp(), 2)
} }
var lines []string var lines []string
if !m.clipDeadline.IsZero() { if !m.clipDeadline.IsZero() {
+28 -58
View File
@@ -494,7 +494,7 @@ func TestVersionHistoryInDetail(t *testing.T) {
screen := d.screen() screen := d.screen()
for _, want := range []string{ for _, want := range []string{
"versions 3 · v to browse", "versions 3 · v to browse",
" 3f9c2a 2026-09-01 current", "▸ 3f9c2a 2026-09-01 current", // the version shown above
" 91d3e0 2026-03-01 expired", " 91d3e0 2026-03-01 expired",
" 0a77c4 2025-09-01 disabled", " 0a77c4 2025-09-01 disabled",
} { } {
@@ -502,9 +502,6 @@ func TestVersionHistoryInDetail(t *testing.T) {
t.Errorf("missing %q:\n%s", want, screen) t.Errorf("missing %q:\n%s", want, screen)
} }
} }
if strings.Contains(screen, "▸ 3f9c2a") {
t.Errorf("a cursor on a list that can't be moved in:\n%s", screen)
}
d.keys("3", "j") // api-key: only its current version d.keys("3", "j") // api-key: only its current version
if !strings.Contains(d.screen(), "versions 1") || strings.Contains(d.screen(), "v to browse") { if !strings.Contains(d.screen(), "versions 1") || strings.Contains(d.screen(), "v to browse") {
t.Errorf("wrong history for api-key:\n%s", d.screen()) t.Errorf("wrong history for api-key:\n%s", d.screen())
@@ -991,14 +988,9 @@ 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(lastLine(scr), "z reveal/hide") {
t.Errorf("help bar: %q", lastLine(scr)) t.Errorf("help bar: %q", lastLine(scr))
} }
d.keys("z")
if bar := lastLine(d.screen()); !strings.Contains(bar, "z hide") {
t.Errorf("help bar with the value shown: %q", bar)
}
d.keys("z")
if strings.Contains(scr, "to browse") { if strings.Contains(scr, "to browse") {
t.Errorf("hint for unbound versions:\n%s", scr) t.Errorf("hint for unbound versions:\n%s", scr)
} }
@@ -1114,16 +1106,25 @@ func TestAccountInDetails(t *testing.T) {
} }
} }
// grouped is an account in two groups.
type grouped struct{ described }
func (grouped) Groups(context.Context) ([]string, error) {
return []string{"Platform team", "Vault readers"}, nil
}
func TestAccountPane(t *testing.T) { func TestAccountPane(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t, described{fake.Azure(0)}) d := newDriver(t, grouped{described{fake.Azure(0)}})
d.keys("g", "l") // the account's row, then its pane d.keys("g", "tab") // the account's row, then straight to its pane
if d.m.focus != paneDetail { if d.m.focus != paneDetail {
t.Fatalf("focus %v, want the fourth pane", d.m.focus) t.Fatalf("focus %v, want the fourth pane", d.m.focus)
} }
scr := d.screen() scr := d.screen()
if !strings.Contains(scr, "Alice Example") { for _, want := range []string{"Alice Example", "groups 2", "Platform team", "Vault readers"} {
t.Errorf("account not shown:\n%s", scr) if !strings.Contains(scr, want) {
t.Errorf("missing %q:\n%s", want, scr)
}
} }
bar := lastLine(scr) bar := lastLine(scr)
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") {
@@ -1133,20 +1134,28 @@ func TestAccountPane(t *testing.T) {
if got := d.clip().content; got != "oid-123" { if got := d.clip().content; got != "oid-123" {
t.Errorf("y on the object ID copied %q", got) t.Errorf("y on the object ID copied %q", got)
} }
d.keys("G", "y")
if got := d.clip().content; got != "Vault readers" {
t.Errorf("y on a group copied %q", got)
}
// / filters it, labels too. // / filters it, labels too.
d.keys("/") d.keys("/")
d.typeText("object") d.typeText("vault")
d.keys("enter") d.keys("enter")
if scr := d.screen(); !strings.Contains(scr, "oid-123") || strings.Contains(scr, "Alice Example") { if scr := d.screen(); !strings.Contains(scr, "Vault readers") || strings.Contains(scr, "Platform team") {
t.Errorf("filtered:\n%s", scr) t.Errorf("filtered:\n%s", scr)
} }
d.keys("y")
if got := d.clip().content; got != "Vault readers" {
t.Errorf("y in the filtered pane copied %q", got)
}
d.keys("esc") d.keys("esc")
if !strings.Contains(d.screen(), "Alice Example") { if !strings.Contains(d.screen(), "Platform team") {
t.Errorf("filter not cleared:\n%s", d.screen()) t.Errorf("filter not cleared:\n%s", d.screen())
} }
d.keys("h") d.keys("shift+tab")
if d.m.focus != paneSources { if d.m.focus != paneSources {
t.Errorf("back to %v, want Sources", d.m.focus) t.Errorf("back to %v, want Sources", d.m.focus)
} }
@@ -1184,18 +1193,6 @@ func TestDetailsFollowFocus(t *testing.T) {
} }
} }
func TestStatusLineCountsRepos(t *testing.T) {
t.Parallel()
d := newDriver(t, fake.Azure(0), fake.Repos())
if line := strings.Split(d.screen(), "\n")[1]; !strings.Contains(line, "1 account · 3 repos") {
t.Errorf("status line: %q", line)
}
d = newDriver(t, fake.Azure(0))
if line := strings.Split(d.screen(), "\n")[1]; strings.Contains(line, "repo") {
t.Errorf("repos counted without any: %q", line)
}
}
func TestStatusPane(t *testing.T) { func TestStatusPane(t *testing.T) {
t.Parallel() t.Parallel()
var opened []string var opened []string
@@ -1210,7 +1207,7 @@ func TestStatusPane(t *testing.T) {
t.Fatalf("focus %v", d.m.focus) t.Fatalf("focus %v", d.m.focus)
} }
scr := d.screen() scr := d.screen()
for _, want := range []string{logo[2], "version v0.1.0", "GPL-3.0-or-later", "Azure · 2 subscriptions", "Azure · admin · sign-in required", for _, want := range []string{"version v0.1.0", "GPL-3.0-or-later", "Azure · 2 subscriptions", "Azure · admin · sign-in required",
"/cfg/config.yaml", "/state/state.json", "lazysecrets/…/docs"} { "/cfg/config.yaml", "/state/state.json", "lazysecrets/…/docs"} {
if !strings.Contains(scr, want) { if !strings.Contains(scr, want) {
t.Errorf("missing %q:\n%s", want, scr) t.Errorf("missing %q:\n%s", want, scr)
@@ -1236,11 +1233,6 @@ func TestStatusPane(t *testing.T) {
if len(opened) != 1 || !strings.HasSuffix(opened[0], "/src/branch/main/docs") { if len(opened) != 1 || !strings.HasSuffix(opened[0], "/src/branch/main/docs") {
t.Errorf("o opened %q", opened) t.Errorf("o opened %q", opened)
} }
d.m.fields.SelectWhere(func(f panes.Field) bool { return f.Label == "version" })
d.keys("o")
if len(opened) != 2 || !strings.HasSuffix(opened[1], "/releases/tag/v0.1.0") {
t.Errorf("o on the version opened %q", opened)
}
d.keys("shift+tab") d.keys("shift+tab")
if d.m.focus != paneStatus { if d.m.focus != paneStatus {
t.Errorf("back to %v", d.m.focus) t.Errorf("back to %v", d.m.focus)
@@ -1320,25 +1312,3 @@ func TestExpiringInStatus(t *testing.T) {
t.Errorf("enter went to %s/%s\n%s", v, o, d.screen()) t.Errorf("enter went to %s/%s\n%s", v, o, d.screen())
} }
} }
func TestErrorLog(t *testing.T) {
t.Parallel()
d := newDriverOpts(t, Options{}, fake.Azure(0))
d.keys("2", "j", "j", "3") // kv-restricted: its secrets can't be listed
d.m.flash = flash{text: "copy failed: no clipboard", isErr: true}
d.send(nil)
d.send(nil) // still showing: logged once
d.keys("0")
scr := d.screen()
if !strings.Contains(scr, "errors") || !strings.Contains(scr, "copy failed: no clipboard") || !strings.Contains(scr, "kv-restricted: 403 Forbidden") {
t.Errorf("errors not listed:\n%s", scr)
}
if n := len(d.m.errLog); n != 2 || !strings.HasPrefix(d.m.errLog[0].text, "copy failed") {
t.Errorf("log %+v", d.m.errLog)
}
d.m.logErr("403 Forbidden: again") // a new one, and an old one again: on top
d.m.logErr(d.m.errLog[2].text)
if len(d.m.errLog) != 3 || d.m.errLog[0].text == "403 Forbidden: again" {
t.Errorf("log %+v", d.m.errLog)
}
}
-64
View File
@@ -1,64 +0,0 @@
package app
import (
"errors"
"slices"
"strings"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
)
// The status pane lists this session's errors, newest first: what a pane
// showed (a list that couldn't load) and what the footer flashed (a copy
// that failed), which go when you move on. Only the first line of each,
// and never kept after quitting.
// maxErrors is how many errors are kept.
const maxErrors = 20
type loggedErr struct {
at time.Time
text string
}
// logErr records text; an error already there moves to the top, with
// the time it happened again.
func (m *Model) logErr(text string) {
text, _, _ = strings.Cut(strings.TrimSpace(text), "\n")
if text == "" {
return
}
m.errLog = slices.DeleteFunc(m.errLog, func(e loggedErr) bool { return e.text == text })
m.errLog = append([]loggedErr{{m.now(), text}}, m.errLog[:min(len(m.errLog), maxErrors-1)]...)
}
// logErrOf records err, about what, except a sign-in, which the footer
// and the account's details say.
func (m *Model) logErrOf(what string, err error) {
var auth *provider.AuthError
if !errors.As(err, &auth) {
m.logErr(what + ": " + err.Error())
}
}
// logFlash records an error the footer is showing, once.
func (m *Model) logFlash() {
if m.flash.isErr && m.flash.text != m.loggedFlash {
m.logErr(m.flash.text)
}
m.loggedFlash = m.flash.text
}
// errorFields are the status pane's rows for them.
func (m Model) errorFields() []panes.Field {
out := make([]panes.Field, len(m.errLog))
for i, e := range m.errLog {
out[i] = panes.Field{Value: e.at.Format("15:04") + " " + e.text, Err: true}
}
if len(out) > 0 {
out[0].Label = "errors"
}
return out
}
+1 -1
View File
@@ -60,7 +60,7 @@ func (m Model) expiringFields() []panes.Field {
} }
out = append(out, f) out = append(out, f)
} }
cov := m.coverageOf(ofKind(provider.KindAzure)) cov := m.coverageOf(provider.KindAzure)
switch { switch {
case cov.vaults == 0 && len(items) == 0: case cov.vaults == 0 && len(items) == 0:
return nil return nil
+63 -10
View File
@@ -1,6 +1,7 @@
package app package app
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
@@ -13,9 +14,22 @@ import (
) )
// The fourth pane shows Fields for anything but a secret. On a provider's // The fourth pane shows Fields for anything but a secret. On a provider's
// row: its account, from what the provider knows locally. // row: its account, from what the provider knows locally, then the
// groups the account is in, loaded once a session when first shown.
// A file's details beyond its listing load once a type groupsState struct {
names []string
err error
loading bool
}
type groupsMsg struct {
provider string
names []string
err error
}
// A vault's, scope's or file's details beyond its listing load once a
// session, when the cursor rests on it, and again with refresh. They're // session, when the cursor rests on it, and again with refresh. They're
// extra: moving on cancels the load, and search never waits for them. // extra: moving on cancels the load, and search never waits for them.
type detailsState struct { type detailsState struct {
@@ -43,6 +57,7 @@ func (m Model) detailOf() pane {
// syncFields shows the Fields pane for what the focused pane has selected, // syncFields shows the Fields pane for what the focused pane has selected,
// unless that's a secret (or a vault with the Secrets pane in focus), which // unless that's a secret (or a vault with the Secrets pane in focus), which
// the secret pane shows, as it does a revealed value whatever the focus. // the secret pane shows, as it does a revealed value whatever the focus.
// An account's groups load the first time.
func (m *Model) syncFields() tea.Cmd { func (m *Model) syncFields() tea.Cmd {
if m.detail.State() != panes.ValueHidden { if m.detail.State() != panes.ValueHidden {
// A value revealed (or being fetched) from any pane stays in // A value revealed (or being fetched) from any pane stays in
@@ -50,7 +65,6 @@ func (m *Model) syncFields() tea.Cmd {
m.fields.Hide() m.fields.Hide()
return nil return nil
} }
m.fields.Header, m.fields.HeaderShort = nil, ""
switch m.detailOf() { switch m.detailOf() {
case paneSources: case paneSources:
r, ok := m.sources.Selected() r, ok := m.sources.Selected()
@@ -62,14 +76,13 @@ func (m *Model) syncFields() tea.Cmd {
case m.selectedProblem() != "": case m.selectedProblem() != "":
m.fields.Show(cmpOr(r.label, r.source.Name), m.problemFields(m.selectedProblem())) m.fields.Show(cmpOr(r.label, r.source.Name), m.problemFields(m.selectedProblem()))
case r.kind == rowGroup: case r.kind == rowGroup:
m.showAccount(r) return m.showAccount(r)
case r.kind == rowSource: case r.kind == rowSource:
m.fields.Show(r.source.Name, m.sourceFields(r.source)) m.fields.Show(r.source.Name, m.sourceFields(r.source))
default: default:
m.fields.Show("", nil) m.fields.Show("", nil)
} }
case paneStatus: case paneStatus:
m.fields.Header, m.fields.HeaderShort = logo, strings.TrimSpace("lazysecrets "+m.versionLabel())
m.fields.Show("lazysecrets", m.statusFields()) m.fields.Show("lazysecrets", m.statusFields())
case paneContainers: case paneContainers:
if c, ok := m.containers.Selected(); ok { if c, ok := m.containers.Selected(); ok {
@@ -85,8 +98,9 @@ func (m *Model) syncFields() tea.Cmd {
return nil return nil
} }
// showAccount shows a provider's account. // showAccount shows a provider's account, loading its groups the first
func (m *Model) showAccount(r sourceRow) { // time.
func (m *Model) showAccount(r sourceRow) tea.Cmd {
p := m.byID[r.provider] p := m.byID[r.provider]
var fields []panes.Field var fields []panes.Field
if a, ok := p.(provider.Accounts); ok { if a, ok := p.(provider.Accounts); ok {
@@ -96,11 +110,28 @@ func (m *Model) showAccount(r sourceRow) {
} }
} }
} }
var cmd tea.Cmd
if g, ok := p.(provider.AccountGroups); ok {
st, seen := m.groups[r.provider]
if !seen {
st = groupsState{loading: true}
m.groups[r.provider] = st
id, timeout := r.provider, m.timeout
cmd = func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
names, err := g.Groups(ctx)
return groupsMsg{provider: id, names: names, err: err}
}
}
fields = append(fields, groupFields(st)...)
}
if res, done := m.sourceResults[r.provider]; done && res.err == nil && len(res.sources) == 0 { if res, done := m.sourceResults[r.provider]; done && res.err == nil && len(res.sources) == 0 {
hint := emptyHint(p.Kind()) hint := emptyHint(p.Kind())
fields = append(fields, panes.Field{Label: "nothing", Value: "found: try", Info: true}, panes.Field{Value: hint.Value, URL: hint.URL}) fields = append(fields, panes.Field{Label: "nothing", Value: "found: try", Info: true}, panes.Field{Value: hint.Value, URL: hint.URL})
} }
m.fields.Show(p.Label(), fields) m.fields.Show(p.Label(), fields)
return cmd
} }
// sourceFields describe a subscription, workspace or repo. // sourceFields describe a subscription, workspace or repo.
@@ -217,8 +248,30 @@ func (m Model) problemFields(problem string) []panes.Field {
return out return out
} }
// fieldName names a copied field: its label, or a row of several under // fieldName names a copied field: its label, or for a group, the group.
// one label (a tag), its value.
func fieldName(f panes.Field) string { func fieldName(f panes.Field) string {
return cmpOr(f.Label, f.Value) if f.Label == "" || strings.HasPrefix(f.Label, "groups") {
return "group " + f.Value
}
return f.Label
}
// groupFields are the groups' rows: labelled on the first.
func groupFields(st groupsState) []panes.Field {
switch {
case st.loading:
return []panes.Field{{Label: "groups", Value: "loading…", Info: true}}
case errors.Is(st.err, provider.ErrUnsupported):
return nil
case st.err != nil:
return []panes.Field{{Label: "groups", Value: "couldn't list them: " + st.err.Error(), Info: true}}
case len(st.names) == 0:
return []panes.Field{{Label: "groups", Value: "none", Info: true}}
}
out := make([]panes.Field, len(st.names))
for i, n := range st.names {
out[i] = panes.Field{Value: n}
}
out[0].Label = fmt.Sprintf("groups %d", len(st.names))
return out
} }
+3 -11
View File
@@ -38,10 +38,6 @@ type contextHelp struct {
commands bool // custom commands apply here commands bool // custom commands apply here
account bool // the fourth pane shows Fields (fields.go) account bool // the fourth pane shows Fields (fields.go)
of pane // what the fourth pane describes of pane // what the fourth pane describes
// revealed, when set, says whether the selected value is shown, so
// the reveal key says just what it'll do: show or hide, the same
// width so the bar doesn't shift.
revealed *bool
} }
func (c contextHelp) ShortHelp() []key.Binding { func (c contextHelp) ShortHelp() []key.Binding {
@@ -63,15 +59,11 @@ func (c contextHelp) ShortHelp() []key.Binding {
} }
switch c.focus { switch c.focus {
case paneSources: case paneSources:
b = append(b, k.Enter, k.Search, k.Share, k.ShareMenu, k.Favourite, k.Favourites, k.Filter) b = append(b, k.Enter, k.Search, k.Share, k.ShareMenu, k.Favourite, k.Favourites, k.Note, k.Filter)
case paneContainers: case paneContainers:
b = append(b, k.Enter, k.Share, k.ShareMenu, k.Favourite, k.Open, k.Filter) b = append(b, k.Enter, k.Share, k.ShareMenu, k.Favourite, k.Note, k.Open, k.Filter)
default: default:
reveal := k.Reveal b = append(b, k.Reveal, k.Copy, k.Share, k.ShareMenu, k.Versions, k.Favourite, k.Note, k.Filter)
if c.revealed != nil {
reveal = key.NewBinding(key.WithKeys(k.Reveal.Keys()...), key.WithHelp(k.Reveal.Help().Key, map[bool]string{false: "show", true: "hide"}[*c.revealed]))
}
b = append(b, reveal, k.Copy, k.Share, k.ShareMenu, k.Versions, k.Favourite, k.Filter)
} }
if c.commands { if c.commands {
b = append(b, k.Commands) b = append(b, k.Commands)
+2 -2
View File
@@ -147,8 +147,8 @@ func sourceMatches(r ref.Ref, src provider.Source) bool {
return src.ID == r.Subscription return src.ID == r.Subscription
} }
// accountOf is the account part of a source's provider label ("alice" in // accountOf is the account part of a source's provider label ("dev" in
// "Azure · alice"), or "" when there's a single account. // "Azure · dev"), or "" when there's a single account.
func accountOf(src provider.Source) string { func accountOf(src provider.Source) string {
account, ok := strings.CutPrefix(src.ProviderLabel, string(src.Kind)+" · ") account, ok := strings.CutPrefix(src.ProviderLabel, string(src.Kind)+" · ")
if !ok { if !ok {
+2 -2
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 := lastLine(d.screen()); !strings.Contains(bar, "S share…") || !strings.HasSuffix(strings.TrimSpace(bar), "? help • q quit") || strings.Contains(bar, "space reveal") {
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 := lastLine(d.screen()); !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)
} }
} }
+2 -1
View File
@@ -3,6 +3,7 @@ package app
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"time" "time"
@@ -243,6 +244,6 @@ func (m *Model) restorePlace() {
func (m *Model) handlePersisted(msg persistedMsg) { func (m *Model) handlePersisted(msg persistedMsg) {
if msg.err != nil { if msg.err != nil {
m.logErrOf("saving the cache or place", msg.err) m.noteErr(fmt.Errorf("saving the cache or place: %w", msg.err))
} }
} }
+3 -3
View File
@@ -206,9 +206,9 @@ func TestSearchAllRepos(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 d.m.search.scope().id != accountScope+"Repos" { for d.m.search.scope().id != kindScope+string(provider.KindRepo) {
if d.m.search.src == len(d.m.search.scopes)-1 { if d.m.search.src == len(d.m.search.scopes)-1 {
t.Fatalf("no Repos scope: %v", d.m.search.scopes) t.Fatalf("no All repos scope: %v", d.m.search.scopes)
} }
d.send(tea.KeyPressMsg{Code: tea.KeyDown}) d.send(tea.KeyPressMsg{Code: tea.KeyDown})
} }
@@ -235,7 +235,7 @@ func TestSearchAllRepos(t *testing.T) {
if azure() != azureBefore || d.m.crawl.running { if azure() != azureBefore || d.m.crawl.running {
t.Errorf("searching all repos loaded Azure: %d → %d lists", azureBefore, azure()) t.Errorf("searching all repos loaded Azure: %d → %d lists", azureBefore, azure())
} }
if !strings.Contains(d.screen(), "Results in Repos") { if !strings.Contains(d.screen(), "Results in all repos") {
t.Errorf("title:\n%s", d.screen()) t.Errorf("title:\n%s", d.screen())
} }
} }
+8 -20
View File
@@ -131,25 +131,13 @@ 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) } func (m *Model) searchCoverage() coverage { return m.coverageOf("") }
// providerFilter picks the providers a search or a load covers; nil is // coverageOf is searchCoverage for one kind of provider ("" for all).
// all of them. func (m *Model) coverageOf(kind provider.Kind) coverage {
type providerFilter func(provider.Provider) bool
func ofKind(k provider.Kind) providerFilter {
return func(p provider.Provider) bool { return p.Kind() == k }
}
func ofProvider(id string) providerFilter {
return func(p provider.Provider) bool { return p.ID() == id }
}
// coverageOf is searchCoverage for the providers in.
func (m *Model) coverageOf(in providerFilter) coverage {
var c coverage var c coverage
for _, p := range m.providers { for _, p := range m.providers {
if in != nil && !in(p) { if kind != "" && p.Kind() != kind {
continue continue
} }
for _, src := range m.sourceResults[p.ID()].sources { for _, src := range m.sourceResults[p.ID()].sources {
@@ -209,12 +197,12 @@ func plural(n int, word string) string {
// startCrawl loads every unlisted subscription's vaults and every vault's // startCrawl loads every unlisted subscription's vaults and every vault's
// secrets not loaded yet. // secrets not loaded yet.
func (m *Model) startCrawl() tea.Cmd { return m.startCrawlOf(nil) } func (m *Model) startCrawl() tea.Cmd { return m.startCrawlOf("") }
// startCrawlOf is startCrawl for the providers in. // startCrawlOf is startCrawl for one kind of provider ("" for all).
func (m *Model) startCrawlOf(in providerFilter) tea.Cmd { func (m *Model) startCrawlOf(kind provider.Kind) tea.Cmd {
for _, p := range m.providers { for _, p := range m.providers {
if in != nil && !in(p) { if kind != "" && p.Kind() != kind {
continue continue
} }
for _, src := range m.sourceResults[p.ID()].sources { for _, src := range m.sourceResults[p.ID()].sources {
+14 -76
View File
@@ -42,15 +42,11 @@ func TestSearchLoaded(t *testing.T) {
t.Fatalf("missing %q:\n%s", want, d.screen()) t.Fatalf("missing %q:\n%s", want, d.screen())
} }
} }
d.typeText("api-key") // on All: found at once d.typeText("api-key")
if !strings.Contains(d.screen(), "ctrl+s only platform-dev") { if !strings.Contains(d.screen(), "no matches here · All searches everything") {
t.Errorf("no hint to narrow to the source:\n%s", d.screen())
}
d.send(ctrl('s'))
if !strings.Contains(d.screen(), "no matches here · All searches everything") || !strings.Contains(d.screen(), "ctrl+s searches all") {
t.Errorf("no hint to search everything:\n%s", d.screen()) t.Errorf("no hint to search everything:\n%s", d.screen())
} }
d.send(ctrl('s')) d.searchAll()
if !strings.Contains(d.screen(), "platform-prod / kv-app-prod / api-key") { if !strings.Contains(d.screen(), "platform-prod / kv-app-prod / api-key") {
t.Fatalf("loaded secret not found:\n%s", d.screen()) t.Fatalf("loaded secret not found:\n%s", d.screen())
} }
@@ -66,8 +62,7 @@ func TestSearchLoaded(t *testing.T) {
func TestSearchLoadsAsYouType(t *testing.T) { func TestSearchLoadsAsYouType(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t) d := newDriver(t)
d.openSearch() d.openSearch() // on the source the cursor is on: platform-prod
d.send(ctrl('s')) // only the source the cursor is on: platform-prod
// Typing loads the scope: that source, without ctrl+a. // Typing loads the scope: that source, without ctrl+a.
d.typeText("smtp") d.typeText("smtp")
if !strings.Contains(d.screen(), "platform-prod / kv-shared-prod / smtp-password") { if !strings.Contains(d.screen(), "platform-prod / kv-shared-prod / smtp-password") {
@@ -81,7 +76,10 @@ func TestSearchLoadsAsYouType(t *testing.T) {
} }
// On All: everything. // On All: everything.
d.send(ctrl('s')) d.keys("tab") // to the scopes
for d.m.search.src > 0 {
d.send(tea.KeyPressMsg{Code: tea.KeyUp})
}
d.typeText("-") d.typeText("-")
if _, ok := d.m.cache.containers[sourceRowKey("Azure", "sub-dev")]; !ok { if _, ok := d.m.cache.containers[sourceRowKey("Azure", "sub-dev")]; !ok {
t.Errorf("typing on All didn't load the other sources:\n%s", d.screen()) t.Errorf("typing on All didn't load the other sources:\n%s", d.screen())
@@ -185,61 +183,19 @@ func TestSearchStartsWhereYouAre(t *testing.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) } if got := ansiStrip(v.scopes[v.src].label); !strings.Contains(got, "platform-prod") {
if scope() != "All" { t.Errorf("source not preselected: %q", got)
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(ansiStrip(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
if !strings.Contains(scope(), "platform-prod") {
t.Errorf("ctrl+s narrowed to %q", scope())
}
d.send(ctrl('s'))
if scope() != "All" {
t.Errorf("second ctrl+s on %q, want All", scope())
}
// From Favourites: to Search, on All. // From the Sources pane: just the source.
d.keys("esc", "]", "]") // stop typing; Favourites d.keys("esc", "esc", "1", "j") // stop typing, close; platform-dev
d.send(ctrl('s'))
if v.tab != tabSearch || scope() != "All" {
t.Errorf("ctrl+s from another tab: tab %d, %q", v.tab, scope())
}
// From the Sources pane: the source.
d.keys("esc", "1", "j") // close; platform-dev
d.openSearch() d.openSearch()
v = d.m.search v = d.m.search
d.send(ctrl('s')) if got := ansiStrip(v.scopes[v.src].label); !strings.Contains(got, "platform-dev") {
if !strings.Contains(scope(), "platform-dev") { t.Errorf("source not preselected: %q", got)
t.Errorf("ctrl+s narrowed to %q", scope())
}
}
func TestSearchNarrowsToAccount(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("1", "g") // the first group header
header, _ := d.m.sources.Selected()
d.openSearch()
d.send(ctrl('s'))
v := d.m.search
if v.scope().id != accountScope+header.provider {
t.Fatalf("ctrl+s on %q narrowed to %q", header.label, v.scope().id)
}
if len(v.shown) == 0 || !strings.Contains(d.screen(), "Results in "+header.label) {
t.Errorf("no results in the account:\n%s", d.screen())
}
for _, o := range v.shown {
if providerOf(o.group) != header.provider {
t.Errorf("result from another account: %q", ansiStrip(o.label))
}
}
// 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), " ") {
t.Errorf("after the account: %q", ansiStrip(next.label))
} }
} }
@@ -283,24 +239,6 @@ func TestGoToTabsAndModes(t *testing.T) {
t.Errorf("[ → tab %d, want Search:\n%s", v.tab, d.screen()) t.Errorf("[ → tab %d, want Search:\n%s", v.tab, d.screen())
} }
// g and G: top and bottom of the list with the cursor.
d.keys("G")
if v.res != len(v.shown)-1 || v.res == 0 {
t.Errorf("G on results: %d of %d", v.res, len(v.shown))
}
d.keys("g")
if v.res != 0 {
t.Errorf("g on results: %d", v.res)
}
d.keys("h", "G")
if v.src != len(v.scopes)-1 {
t.Errorf("G on scopes: %d of %d", v.src, len(v.scopes))
}
d.keys("g", "l")
if v.src != 0 {
t.Errorf("g on scopes: %d", v.src)
}
d.keys("/") d.keys("/")
d.typeText("shared") d.typeText("shared")
if !v.typing || len(v.shown) == 0 { if !v.typing || len(v.shown) == 0 {
+64 -141
View File
@@ -20,17 +20,16 @@ import (
) )
// Go to is one popup for finding a place and going there. Tabs at the // Go to is one popup for finding a place and going there. Tabs at the
// top: Search has two lists side by side, the scope on the left (All, or // top: Favourites and Recent (from the store, so they work before
// one source with how much of it is loaded: ○ ◐ ● ◌ ✗) and what's in it // anything loads) are one list each; Search has two lists side by side,
// on the right; Favourites and Recent (from the store, so they work // the scope on the left (All, or one source with how much of it is
// before anything loads) are one list each. // loaded: ○ ◐ ● ◌ ✗) and what's in it on the right.
// //
// Like a pane, it's either browsing (letters: j/k, [ and ] for tabs, f // Like a pane, it's either browsing (letters: j/k, [ and ] for tabs, f
// star, / to type) or typing a filter (ctrl+ letters). esc while typing // star, / to type) or typing a filter (ctrl+ letters). esc while typing
// clears the text and goes back to browsing; esc while browsing closes. // clears the text and goes back to browsing; esc while browsing closes.
// F opens it on Favourites and R on Recent, browsing; search on Search, // F opens it on Favourites and R on Recent, browsing; search on Search,
// typing, on All. search again narrows to the source you were in, and // typing.
// back to All.
// //
// The results don't move by themselves: as lists load, rows already shown // The results don't move by themselves: as lists load, rows already shown
// keep their order and new ones come after them, with the cursor on the // keep their order and new ones come after them, with the cursor on the
@@ -40,7 +39,7 @@ import (
type searchView struct { type searchView struct {
input textinput.Model input textinput.Model
typing bool // the text field has the keyboard typing bool // the text field has the keyboard
tab int // tabSearch, tabFavs or tabRecent tab int // tabFavs, tabRecent or tabSearch
results bool // in Search, the results have the cursor (else the scopes) results bool // in Search, the results have the cursor (else the scopes)
scopes []option // All, then the sources (Search) scopes []option // All, then the sources (Search)
@@ -50,7 +49,6 @@ type searchView struct {
shown []option // the tab's (or scope's) entries matching the input shown []option // the tab's (or scope's) entries matching the input
src int // cursor in scopes src int // cursor in scopes
res int // cursor in shown res int // cursor in shown
here string // the scope of the source Go to was opened from, if any
// loaded are the scopes a search has loaded since the popup opened: // loaded are the scopes a search has loaded since the popup opened:
// typing loads the scope once, so lists that fail aren't asked for // typing loads the scope once, so lists that fail aren't asked for
@@ -59,25 +57,25 @@ type searchView struct {
} }
const ( const (
tabSearch = iota tabFavs = iota
tabFavs
tabRecent tabRecent
tabSearch
tabCount tabCount
) )
var tabNames = [tabCount]string{"Search", "Favourites", "Recent"} var tabNames = [tabCount]string{"Favourites", "Recent", "Search"}
const ( const (
scopeFavs = "#favs" scopeFavs = "#favs"
scopeRecent = "#recent" scopeRecent = "#recent"
allSources = "#all" allSources = "#all"
accountScope = "#account:" // + a provider ID: an account's sources, or Databricks', or the repos kindScope = "#kind:" // + a provider kind: all of Azure, Databricks or repos
) )
const goToTitle = "Go to" const goToTitle = "Go to"
// showSearch opens Go to on Search, typing, on All, with the selected // showSearch opens Go to on Search, typing, in the source you're in
// vault under the cursor. // (else All), with the selected vault under the cursor.
func (m *Model) showSearch() tea.Cmd { func (m *Model) showSearch() tea.Cmd {
cmd := m.openGoTo(tabSearch, true) cmd := m.openGoTo(tabSearch, true)
m.preselect() m.preselect()
@@ -133,20 +131,17 @@ func (v *searchView) scope() option {
// onSource reports whether the results come from one source. // onSource reports whether the results come from one source.
func (v *searchView) onSource() bool { return v.tab == tabSearch && v.scope().group != "" } func (v *searchView) onSource() bool { return v.tab == tabSearch && v.scope().group != "" }
// preselect starts where the user is: the selected source is the one // preselect starts where the user is: the selected source picked on the
// search narrows to, and the selected vault or scope is under the cursor. // left, and the selected vault or scope under the cursor on the right.
func (m *Model) preselect() { func (m *Model) preselect() {
v := m.search v := m.search
row := m.sources.SelectedID() row := m.sources.SelectedID()
account := "" i := slices.IndexFunc(v.scopes, func(o option) bool { return o.group != "" && o.group == row })
if id, ok := strings.CutPrefix(row, "group:"); ok { // a group header
account = accountScope + id
}
i := slices.IndexFunc(v.scopes, func(o option) bool { return o.id == account || o.group != "" && o.group == row })
if i < 0 { if i < 0 {
return return
} }
v.here = v.scopes[i].id v.src = i
m.fillSearch(false)
if m.focus == paneSources { if m.focus == paneSources {
return return
} }
@@ -176,36 +171,25 @@ func (m *Model) fillSearch(keep bool) {
} }
sources, items := m.searchEntries() sources, items := m.searchEntries()
all := option{label: "All", id: allSources, mark: m.coverageMark(nil)} all := option{label: "All", id: allSources, mark: m.coverageMark("")}
// Grouped like the Sources pane: each account (or Databricks, or the
// repos) is a scope too, its sources under it, so a search can stay on
// one, like the repos (local, quick), without asking Azure for every
// vault. With only one, it's the same as All.
var groups []provider.Provider
for _, p := range m.providers {
if len(m.sourceResults[p.ID()].sources) > 0 {
groups = append(groups, p)
}
}
scopes := []option{all} scopes := []option{all}
if len(groups) < 2 { // All of one kind, so a search can stay on repos (local, quick)
scopes = append(scopes, sources...) // without asking Azure for every vault; only with several kinds.
} else { var kinds []provider.Kind
for _, p := range groups { for _, p := range m.providers {
k := p.Kind() if len(m.sourceResults[p.ID()].sources) > 0 && !slices.Contains(kinds, p.Kind()) {
kinds = append(kinds, p.Kind())
}
}
if len(kinds) > 1 {
for _, k := range kinds {
scopes = append(scopes, option{ scopes = append(scopes, option{
label: m.st.Kind(k).Render(m.icons.Prefix(k) + p.Label()), label: m.st.Kind(k).Render(m.icons.Prefix(k) + kindScopeLabel(k)),
id: accountScope + p.ID(), kind: k, mark: m.coverageMark(ofProvider(p.ID())), id: kindScope + string(k), kind: k, mark: m.coverageMark(k),
}) })
for _, o := range sources {
if providerOf(o.group) == p.ID() {
o.label = " " + o.label
scopes = append(scopes, o)
} }
} }
} v.scopes, v.items = append(scopes, sources...), items
}
v.scopes, v.items = scopes, items
v.src = max(0, slices.IndexFunc(v.scopes, func(o option) bool { return o.id == atSrc })) v.src = max(0, slices.IndexFunc(v.scopes, func(o option) bool { return o.id == atSrc }))
v.shown = v.match() v.shown = v.match()
@@ -218,9 +202,17 @@ func (m *Model) fillSearch(keep bool) {
v.res = max(0, min(v.res, len(v.shown)-1)) v.res = max(0, min(v.res, len(v.shown)-1))
} }
// coverageMark is ○ ◐ ● for how much of the providers in is loaded. // kindScopeLabel names the scope of all of a kind's sources.
func (m *Model) coverageMark(in providerFilter) string { func kindScopeLabel(k provider.Kind) string {
switch c := m.coverageOf(in); { if k == provider.KindRepo {
return "All repos"
}
return "All " + string(k)
}
// coverageMark is ○ ◐ ● for how much of a kind ("" for all) is loaded.
func (m *Model) coverageMark(kind provider.Kind) string {
switch c := m.coverageOf(kind); {
case c.complete(): case c.complete():
return m.st.Success.Render("●") return m.st.Success.Render("●")
case c.vaultsLoaded > 0: case c.vaultsLoaded > 0:
@@ -229,28 +221,15 @@ func (m *Model) coverageMark(in providerFilter) string {
return m.st.Muted.Render("○") return m.st.Muted.Render("○")
} }
// scopeAccount is the provider whose sources the scope covers, if it's // scopeKind is the kind a scope covers all of, if it's one of those.
// an account's. func (v *searchView) scopeKind() (provider.Kind, bool) {
func (v *searchView) scopeAccount() (string, bool) { k, ok := strings.CutPrefix(v.scope().id, kindScope)
id, ok := strings.CutPrefix(v.scope().id, accountScope)
if !ok || v.tab != tabSearch { if !ok || v.tab != tabSearch {
return "", false return "", false
} }
return id, true return provider.Kind(k), true
} }
// scopeFilter is what loading everything in the scope covers: an
// account's, else all of them.
func (v *searchView) scopeFilter() providerFilter {
if id, ok := v.scopeAccount(); ok {
return ofProvider(id)
}
return nil
}
// scopeName is the scope's label without colours or indent.
func scopeName(o option) string { return strings.TrimSpace(ansi.Strip(o.label)) }
// match lists the scope's entries matching the input, best first. // match lists the scope's entries matching the input, best first.
func (v *searchView) match() []option { func (v *searchView) match() []option {
var cand []option var cand []option
@@ -260,9 +239,9 @@ func (v *searchView) match() []option {
case scopeRecent: case scopeRecent:
cand = v.recent cand = v.recent
default: default:
account, ofAccount := v.scopeAccount() kind, ofKind := v.scopeKind()
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 || ofKind && o.kind == kind || sc.group != "" && o.group == sc.group {
cand = append(cand, o) cand = append(cand, o)
} }
} }
@@ -341,9 +320,6 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd {
// The actions shared by both modes, by their keys in the mode. // The actions shared by both modes, by their keys in the mode.
type modeKeys struct{ down, up, sources, results, switchList, star, load, loadAll, sel key.Binding } type modeKeys struct{ down, up, sources, results, switchList, star, load, loadAll, sel key.Binding }
k := modeKeys{km.GoDown, km.GoUp, km.GoSources, km.GoResults, km.GoSwitch, km.GoStar, km.GoLoad, km.GoLoadAll, km.GoSelect} k := modeKeys{km.GoDown, km.GoUp, km.GoSources, km.GoResults, km.GoSwitch, km.GoStar, km.GoLoad, km.GoLoadAll, km.GoSelect}
if key.Matches(msg, km.Search) {
return m.narrow()
}
if v.typing { if v.typing {
k = modeKeys{km.PopupDown, km.PopupUp, km.SearchSources, km.SearchResults, km.SearchSwitch, km.PopupStar, km.SearchLoad, km.SearchLoadAll, km.PopupSelect} k = modeKeys{km.PopupDown, km.PopupUp, km.SearchSources, km.SearchResults, km.SearchSwitch, km.PopupStar, km.SearchLoad, km.SearchLoadAll, km.PopupSelect}
if key.Matches(msg, km.PopupClose) { if key.Matches(msg, km.PopupClose) {
@@ -362,22 +338,6 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd {
return nil return nil
case key.Matches(msg, km.GoFilter): case key.Matches(msg, km.GoFilter):
return m.setTyping(true) return m.setTyping(true)
case key.Matches(msg, km.GoTop), key.Matches(msg, km.GoBottom):
n := len(v.shown)
if !v.results {
n = len(v.scopes)
}
i := 0
if key.Matches(msg, km.GoBottom) {
i = max(0, n-1)
}
if v.results {
v.res = i
} else if v.src != i {
v.src, v.res = i, 0
m.fillSearch(false)
}
return nil
case key.Matches(msg, km.Help): case key.Matches(msg, km.Help):
return m.showKeybindingsFor(keys.GoTo) return m.showKeybindingsFor(keys.GoTo)
case key.Matches(msg, km.GoNextTab), key.Matches(msg, km.GoPrevTab): case key.Matches(msg, km.GoNextTab), key.Matches(msg, km.GoPrevTab):
@@ -441,9 +401,11 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd {
if v.onSource() { if v.onSource() {
return v.scope().reload(m) return v.scope().reload(m)
} }
return m.startCrawlOf(v.scopeFilter()) kind, _ := v.scopeKind()
return m.startCrawlOf(kind)
case inSearch && key.Matches(msg, k.loadAll): case inSearch && key.Matches(msg, k.loadAll):
return m.startCrawlOf(v.scopeFilter()) kind, _ := v.scopeKind()
return m.startCrawlOf(kind)
case key.Matches(msg, k.star): case key.Matches(msg, k.star):
if o, ok := v.selected(); ok && o.star != "" { if o, ok := v.selected(); ok && o.star != "" {
m.toggleStarKey(o.star, o.starAccount) m.toggleStarKey(o.star, o.starAccount)
@@ -474,22 +436,6 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd {
return cmd return cmd
} }
// narrow switches Search between All and the source Go to was opened
// from; from another tab, or another scope, it goes to Search on All.
func (m *Model) narrow() tea.Cmd {
v := m.search
src := 0
if v.tab == tabSearch && v.scope().id == allSources {
src = slices.IndexFunc(v.scopes, func(o option) bool { return o.id == v.here })
if src < 0 {
return nil
}
}
v.tab, v.src, v.res, v.results = tabSearch, src, 0, true
m.fillSearch(false)
return m.searchLoads()
}
// searchLoads loads what a search in the Search tab covers, like the // searchLoads loads what a search in the Search tab covers, like the
// load-all key: everything on All, else the source picked. Only what // load-all key: everything on All, else the source picked. Only what
// isn't loaded yet, once a popup; repos load by themselves anyway. // isn't loaded yet, once a popup; repos load by themselves anyway.
@@ -506,8 +452,8 @@ func (m *Model) searchLoads() tea.Cmd {
v.loaded = map[string]bool{} v.loaded = map[string]bool{}
} }
v.loaded[sc.id] = true v.loaded[sc.id] = true
if _, ok := v.scopeAccount(); ok { if kind, ok := v.scopeKind(); ok {
return m.startCrawlOf(v.scopeFilter()) return m.startCrawlOf(kind)
} }
if !v.onSource() { if !v.onSource() {
return m.startCrawl() return m.startCrawl()
@@ -551,35 +497,6 @@ func (m *Model) toggleStarKey(key, account string) {
// searchHint tops the results when what they come from isn't all // searchHint tops the results when what they come from isn't all
// loaded, saying how to load it. // loaded, saying how to load it.
func (m Model) searchHint() string { func (m Model) searchHint() string {
load, narrow := m.loadHint(), m.narrowHint()
switch {
case narrow == "":
return load
case load != "":
narrow = " · " + narrow
}
return load + m.st.Muted.Render(narrow)
}
// narrowHint names the key for the other of All and the source Go to was
// opened from.
func (m Model) narrowHint() string {
v := m.search
if v.tab != tabSearch || m.crawl.running {
return ""
}
if v.scope().id == v.here && v.here != "" {
return withKey(m.keys.Search, "%s searches all")
}
i := slices.IndexFunc(v.scopes, func(o option) bool { return o.id == v.here })
if i < 0 || v.scope().id != allSources {
return ""
}
return withKey(m.keys.Search, "%s only "+scopeName(v.scopes[i]))
}
// loadHint says how much of the scope is loaded, and the key to load it.
func (m Model) loadHint() string {
v := m.search v := m.search
if m.crawl.running { if m.crawl.running {
return m.st.Muted.Render(fmt.Sprintf("◌ loading… %d lists left", len(m.crawl.queue)+m.crawl.active)) return m.st.Muted.Render(fmt.Sprintf("◌ loading… %d lists left", len(m.crawl.queue)+m.crawl.active))
@@ -587,8 +504,8 @@ func (m Model) loadHint() string {
if v.tab != tabSearch { if v.tab != tabSearch {
return "" // from the store: nothing to load return "" // from the store: nothing to load
} }
if _, ok := v.scopeAccount(); ok || v.scope().id == allSources { if kind, ok := v.scopeKind(); ok || v.scope().id == allSources {
c := m.coverageOf(v.scopeFilter()) c := m.coverageOf(kind)
if c.complete() { if c.complete() {
return "" return ""
} }
@@ -720,8 +637,14 @@ func (m Model) searchViewBox() string {
} }
title := func(s string, active bool) string { return panes.TitleStyle(m.st, active && focused).Render(s) } title := func(s string, active bool) string { return panes.TitleStyle(m.st, active && focused).Render(s) }
resultsTitle := map[string]string{scopeFavs: "Favourites", scopeRecent: "Recent", allSources: "Results in all sources"}[v.scope().id] resultsTitle := map[string]string{scopeFavs: "Favourites", scopeRecent: "Recent", allSources: "Results in all sources"}[v.scope().id]
if _, ok := v.scopeAccount(); ok || v.onSource() { if v.onSource() {
resultsTitle = "Results in " + scopeName(v.scope()) resultsTitle = "Results in " + ansi.Strip(v.scope().label)
}
if kind, ok := v.scopeKind(); ok {
resultsTitle = "Results in " + string(kind)
if kind == provider.KindRepo {
resultsTitle = "Results in all repos"
}
} }
var lists string var lists string
if v.tab == tabSearch { if v.tab == tabSearch {
+2 -19
View File
@@ -16,16 +16,6 @@ import (
// (0), the right pane has the rest: updates, each account, the files // (0), the right pane has the rest: updates, each account, the files
// lazysecrets uses, links, and the changelog. // lazysecrets uses, links, and the changelog.
// logo heads the status pane's rows.
var logo = []string{
` _ _`,
`| | __ _ _____ _ ___ ___ ___ _ __ ___| |_ ___`,
`| |/ _` + "`" + ` |_ / | | / __|/ _ \/ __| '__/ _ \ __/ __|`,
`| | (_| |/ /| |_| \__ \ __/ (__| | | __/ |_\__ \`,
`|_|\__,_/___|\__, |___/\___|\___|_| \___|\__|___/`,
` |___/`,
}
// statusH is the status pane's height: one line in a box. // statusH is the status pane's height: one line in a box.
const statusH = 3 const statusH = 3
@@ -38,10 +28,9 @@ func (m Model) statusLine() string {
case m.newer != nil: case m.newer != nil:
parts = append(parts, m.st.Notice.Render("↑ "+m.newer.Tag+withKey(m.keys.Update, " %s"))) parts = append(parts, m.st.Notice.Render("↑ "+m.newer.Tag+withKey(m.keys.Update, " %s")))
} }
accounts, failing, repos := 0, 0, 0 accounts, failing := 0, 0
for _, p := range m.providers { for _, p := range m.providers {
if p.Kind() == provider.KindRepo { if p.Kind() == provider.KindRepo {
repos += len(m.sourceResults[p.ID()].sources) // those with secret files
continue continue
} }
accounts++ accounts++
@@ -59,9 +48,6 @@ func (m Model) statusLine() string {
} }
parts = append(parts, s) parts = append(parts, s)
} }
if repos > 0 {
parts = append(parts, plural(repos, "repo"))
}
return " " + strings.Join(parts, m.st.Muted.Render(" · ")) return " " + strings.Join(parts, m.st.Muted.Render(" · "))
} }
@@ -106,7 +92,6 @@ func (m Model) statusFields() []panes.Field {
} }
out = append(out, m.expiringFields()...) out = append(out, m.expiringFields()...)
out = append(out, m.errorFields()...)
for i, path := range m.configFiles { for i, path := range m.configFiles {
label := "" label := ""
@@ -122,9 +107,7 @@ func (m Model) statusFields() []panes.Field {
repo := strings.TrimPrefix(update.DefaultRepo, "https://") repo := strings.TrimPrefix(update.DefaultRepo, "https://")
add(panes.Field{Label: "docs", Value: repo + "/…/docs", URL: update.DefaultRepo + "/src/branch/main/docs"}) add(panes.Field{Label: "docs", Value: repo + "/…/docs", URL: update.DefaultRepo + "/src/branch/main/docs"})
add(panes.Field{Label: "source", Value: repo, URL: update.DefaultRepo}) add(panes.Field{Label: "source", Value: repo, URL: update.DefaultRepo})
// Issues are on the GitHub mirror. add(panes.Field{Label: "issues", Value: repo + "/issues", URL: update.DefaultRepo + "/issues"})
github := strings.TrimPrefix(update.GitHubRepo, "https://")
add(panes.Field{Label: "issues", Value: github + "/issues", URL: update.GitHubRepo + "/issues"})
add(panes.Field{Label: "releases", Value: repo + "/releases", URL: update.DefaultRepo + "/releases"}) add(panes.Field{Label: "releases", Value: repo + "/releases", URL: update.DefaultRepo + "/releases"})
add(panes.Field{Label: "changelog", Value: repo + "/…/CHANGELOG.md", URL: update.DefaultRepo + "/src/branch/main/CHANGELOG.md"}) add(panes.Field{Label: "changelog", Value: repo + "/…/CHANGELOG.md", URL: update.DefaultRepo + "/src/branch/main/CHANGELOG.md"})
return append(out, m.changelog...) return append(out, m.changelog...)
+1 -1
View File
@@ -30,4 +30,4 @@
│ ││ │ │ ││ │
│ ││ │ │ ││ │
╰────────────────────── 1 of 3 ─╯╰──────────────────────────────────────────────────────── 1 of 4 ─╯ ╰────────────────────── 1 of 3 ─╯╰──────────────────────────────────────────────────────── 1 of 4 ─╯
enter open • ctrl+s search • s copy link • S share • f star • F favourites • … • ? help • q quit enter open • ctrl+s search • s copy link • S share… • f star • F favourites • … • ? help • q quit
+5 -8
View File
@@ -61,8 +61,7 @@ type Map struct {
SearchSources, SearchResults, SearchSwitch key.Binding SearchSources, SearchResults, SearchSwitch key.Binding
SearchLoad, SearchLoadAll key.Binding SearchLoad, SearchLoadAll key.Binding
// goto // goto
GoDown, GoUp, GoTop, GoBottom key.Binding GoDown, GoUp, GoPrevTab, GoNextTab key.Binding
GoPrevTab, GoNextTab key.Binding
GoSources, GoResults, GoSwitch key.Binding GoSources, GoResults, GoSwitch key.Binding
GoStar, GoLoad, GoLoadAll, GoFilter key.Binding GoStar, GoLoad, GoLoadAll, GoFilter key.Binding
GoSelect, GoClose key.Binding GoSelect, GoClose key.Binding
@@ -103,7 +102,7 @@ func (m *Map) Actions() []Action {
a(Lists, "nextTab", &m.NextTab, "next tab", "lists", "Next tab of the focused pane."), a(Lists, "nextTab", &m.NextTab, "next tab", "lists", "Next tab of the focused pane."),
a(Lists, "sourcesView", &m.SourcesView, "grouped/flat sources", "lists", "Switch the Sources pane between grouped by provider and a flat list."), a(Lists, "sourcesView", &m.SourcesView, "grouped/flat sources", "lists", "Switch the Sources pane between grouped by provider and a flat list."),
a(Lists, "star", &m.Favourite, "star", "lists", "Star or unstar the selected subscription, vault or secret, for the account showing it. Starred items get a ★ and sort first."), a(Lists, "star", &m.Favourite, "star", "lists", "Star or unstar the selected subscription, vault or secret, for the account showing it. Starred items get a ★ and sort first."),
a(Lists, "search", &m.Search, "search", "lists", "Go to anything: search every vault and secret loaded and jump to one. Again in Go to: only the source or group you're in, and back to all. On the left, the scope: All, an account (or Databricks, or Repos), or one source (○ ◐ ● for how much of it is loaded)."), a(Lists, "search", &m.Search, "search", "lists", "Go to anything: search every vault and secret loaded and jump to one, starting in the source you're in. On the left, the scope: Favourites, Recent, All, or one source (○ ◐ ● for how much of it is loaded)."),
a(Lists, "favourites", &m.Favourites, "favourites", "lists", "Go to, on Favourites: everything you've starred, to search and jump to."), a(Lists, "favourites", &m.Favourites, "favourites", "lists", "Go to, on Favourites: everything you've starred, to search and jump to."),
a(Lists, "recent", &m.Recent, "recent", "lists", "Go to, on Recent: what you used last, newest first."), a(Lists, "recent", &m.Recent, "recent", "lists", "Go to, on Recent: what you used last, newest first."),
a(Lists, "commands", &m.Commands, "commands", "lists", "List the custom commands for the focused pane (customCommands in the config) and run one."), a(Lists, "commands", &m.Commands, "commands", "lists", "List the custom commands for the focused pane (customCommands in the config) and run one."),
@@ -117,7 +116,7 @@ func (m *Map) Actions() []Action {
a(Lists, "edit", &m.Edit, "open file", "secrets", "Open the selected SOPS or .env file, or the file of the selected secret at its line. SOPS files open with sops edit. Others open in the nvim lazysecrets runs in (quitting lazysecrets), else in $VISUAL or $EDITOR, else in the system's default app."), a(Lists, "edit", &m.Edit, "open file", "secrets", "Open the selected SOPS or .env file, or the file of the selected secret at its line. SOPS files open with sops edit. Others open in the nvim lazysecrets runs in (quitting lazysecrets), else in $VISUAL or $EDITOR, else in the system's default app."),
a(Lists, "share", &m.Share, "copy link", "share", "Copy a lazysecrets:// link to the selected subscription, workspace, vault, scope or secret, for sharing. From the versions view the link is pinned to that version."), a(Lists, "share", &m.Share, "copy link", "share", "Copy a lazysecrets:// link to the selected subscription, workspace, vault, scope or secret, for sharing. From the versions view the link is pinned to that version."),
a(Lists, "shareMenu", &m.ShareMenu, "share", "share", "Choose what to share: the lazysecrets link, the plain Key Vault URL, the config reference, or the web page."), a(Lists, "shareMenu", &m.ShareMenu, "share…", "share", "Choose what to share: the lazysecrets link, the plain Key Vault URL, the config reference, or the web page."),
a(Lists, "openLink", &m.OpenRef, "open link", "share", "Open a lazysecrets:// link, Key Vault URL or reference. Pasting one into lazysecrets does the same."), a(Lists, "openLink", &m.OpenRef, "open link", "share", "Open a lazysecrets:// link, Key Vault URL or reference. Pasting one into lazysecrets does the same."),
a(Lists, "dividerLeft", &m.SplitLeft, "divider left", "layout", "Move the divider between the left panes and the detail pane to the left."), a(Lists, "dividerLeft", &m.SplitLeft, "divider left", "layout", "Move the divider between the left panes and the detail pane to the left."),
@@ -161,9 +160,7 @@ func (m *Map) Actions() []Action {
a(GoTo, "down", &m.GoDown, "down", "go to", "Move down in Go to while browsing."), a(GoTo, "down", &m.GoDown, "down", "go to", "Move down in Go to while browsing."),
a(GoTo, "up", &m.GoUp, "up", "go to", "Move up in Go to while browsing."), a(GoTo, "up", &m.GoUp, "up", "go to", "Move up in Go to while browsing."),
a(GoTo, "top", &m.GoTop, "top", "go to", "Jump to the top of the list in Go to while browsing."), a(GoTo, "prevTab", &m.GoPrevTab, "prev tab", "go to", "Previous tab of Go to: Favourites, Recent, Search."),
a(GoTo, "bottom", &m.GoBottom, "bottom", "go to", "Jump to the bottom of the list in Go to while browsing."),
a(GoTo, "prevTab", &m.GoPrevTab, "prev tab", "go to", "Previous tab of Go to: Search, Favourites, Recent."),
a(GoTo, "nextTab", &m.GoNextTab, "next tab", "go to", "Next tab of Go to."), a(GoTo, "nextTab", &m.GoNextTab, "next tab", "go to", "Next tab of Go to."),
a(GoTo, "sources", &m.GoSources, "sources", "go to", "Move to the sources list (Search tab)."), a(GoTo, "sources", &m.GoSources, "sources", "go to", "Move to the sources list (Search tab)."),
a(GoTo, "results", &m.GoResults, "results", "go to", "Move to the results list (Search tab)."), a(GoTo, "results", &m.GoResults, "results", "go to", "Move to the results list (Search tab)."),
@@ -209,7 +206,7 @@ var defaults = map[string][]string{
"search.sources": {"ctrl+h"}, "search.results": {"ctrl+l"}, "search.switchList": {"tab", "shift+tab"}, "search.sources": {"ctrl+h"}, "search.results": {"ctrl+l"}, "search.switchList": {"tab", "shift+tab"},
"search.load": {"ctrl+r"}, "search.loadAll": {"ctrl+a"}, "search.load": {"ctrl+r"}, "search.loadAll": {"ctrl+a"},
"goto.down": {"j", "down"}, "goto.up": {"k", "up"}, "goto.top": {"g", "home"}, "goto.bottom": {"G", "end"}, "goto.prevTab": {"["}, "goto.nextTab": {"]"}, "goto.down": {"j", "down"}, "goto.up": {"k", "up"}, "goto.prevTab": {"["}, "goto.nextTab": {"]"},
"goto.sources": {"h", "left"}, "goto.results": {"l", "right"}, "goto.switchList": {"tab", "shift+tab"}, "goto.sources": {"h", "left"}, "goto.results": {"l", "right"}, "goto.switchList": {"tab", "shift+tab"},
"goto.star": {"f"}, "goto.load": {"r"}, "goto.loadAll": {"a"}, "goto.filter": {"/"}, "goto.star": {"f"}, "goto.load": {"r"}, "goto.loadAll": {"a"}, "goto.filter": {"/"},
"goto.select": {"enter"}, "goto.close": {"esc", "q"}, "goto.select": {"enter"}, "goto.close": {"esc", "q"},
+5 -14
View File
@@ -313,8 +313,7 @@ func (d Detail) lines(st styles.Styles) []string {
case ValueShown: case ValueShown:
// Where the dots were, continuing under itself; the keys below. // Where the dots were, continuing under itself; the keys below.
first := true first := true
lines, note := valueView(d.value.Reveal()) for _, line := range strings.Split(d.value.Reveal(), "\n") {
for _, line := range lines {
for _, w := range strings.Split(ansi.Hardwrap(line, max(1, inner-11), true), "\n") { for _, w := range strings.Split(ansi.Hardwrap(line, max(1, inner-11), true), "\n") {
if first { if first {
out = append(out, " "+st.Label.Render(fmt.Sprintf("%-10s", label))+" "+st.Value.Render(w)) out = append(out, " "+st.Label.Render(fmt.Sprintf("%-10s", label))+" "+st.Value.Render(w))
@@ -325,12 +324,6 @@ func (d Detail) lines(st styles.Styles) []string {
} }
} }
var more []string var more []string
if note != "" && d.CopyKey != "" {
note += ": " + d.CopyKey + " copies it as stored"
}
if note != "" {
more = append(more, note)
}
if d.hidesIn != "" { if d.hidesIn != "" {
more = append(more, "hides in "+d.hidesIn) more = append(more, "hides in "+d.hidesIn)
} }
@@ -362,13 +355,11 @@ func (d Detail) versionLines(st styles.Styles) []string {
out = append(out, muted(fmt.Sprintf(" … %d more", len(d.versions)-i))) out = append(out, muted(fmt.Sprintf(" … %d more", len(d.versions)-i)))
break break
} }
// A list to read, not to move in (v browses them): no cursor. The marker := " "
// version shown above, when it isn't the current one, is named. if d.secret.Version != "" && v.Version == d.secret.Version {
line := " " + VersionLine(st, v, d.Now(), current) marker = "▸ " // the version shown above
if d.secret.Version != "" && v.Version == d.secret.Version && v.Version != current {
line += muted(" · shown above")
} }
out = append(out, line) out = append(out, " "+marker+VersionLine(st, v, d.Now(), current))
} }
return out return out
} }
+1 -55
View File
@@ -37,11 +37,6 @@ type List[T any] struct {
// Age says how old the items are ("2h ago") while they reload, by // Age says how old the items are ("2h ago") while they reload, by
// the counter. // the counter.
Age string Age string
// Header lines (a logo) go above the items while the cursor is in
// the rows under it, the pane has room and there's no filter; else
// HeaderShort, a line of plain text, does.
Header []string
HeaderShort string
item Item[T] item Item[T]
items []T items []T
@@ -168,36 +163,9 @@ func (l List[T]) rows() int {
if l.filtering { if l.filtering {
r-- r--
} }
r -= len(l.header())
return max(0, r) return max(0, r)
} }
// headerMinRows is how many item rows Header leaves at least.
const headerMinRows = 5
// header is the lines above the items: Header and a blank line, if it
// fits, leaves headerMinRows and the cursor is in the rows under it;
// else HeaderShort.
func (l List[T]) header() []string {
short := []string{}
if l.HeaderShort != "" {
short = []string{l.HeaderShort}
}
if len(l.Header) == 0 || l.filtering || l.input.Value() != "" {
return short
}
for _, line := range l.Header {
if ansi.StringWidth(line) > l.w-3 {
return short
}
}
below := l.h - 2 - len(l.Header) - 1
if below < headerMinRows || l.cursor >= below {
return short
}
return append(l.Header[:len(l.Header):len(l.Header)], "")
}
// Filtering reports whether the filter input has focus. // Filtering reports whether the filter input has focus.
func (l List[T]) Filtering() bool { return l.filtering } func (l List[T]) Filtering() bool { return l.filtering }
@@ -361,15 +329,12 @@ func (l List[T]) View(st styles.Styles, focused bool, spin string) string {
if l.filtering { if l.filtering {
body = append(body, l.input.View()) body = append(body, l.input.View())
} }
for _, line := range l.header() {
body = append(body, st.Notice.Render(" "+line))
}
switch { switch {
case l.Loading && len(l.items) == 0: case l.Loading && len(l.items) == 0:
body = append(body, " "+spin+" loading…") body = append(body, " "+spin+" loading…")
case l.Err != nil: case l.Err != nil:
for _, line := range wrapLines(l.Err.Error(), inner-1) { for _, line := range strings.Split(ansi.Wrap(l.Err.Error(), inner-1, " -/"), "\n") {
body = append(body, st.Error.Render(" "+line)) body = append(body, st.Error.Render(" "+line))
} }
case len(l.visible) == 0 && len(l.items) > 0: case len(l.visible) == 0 && len(l.items) > 0:
@@ -428,22 +393,3 @@ func (l *List[T]) scroll() {
} }
l.offset = max(0, min(l.offset, len(l.visible)-rows)) l.offset = max(0, min(l.offset, len(l.visible)-rows))
} }
// wrapLines wraps text to width, line by line; a "• " item's further
// lines line up under its text.
func wrapLines(text string, width int) []string {
var out []string
for _, line := range strings.Split(text, "\n") {
indent := ""
if strings.HasPrefix(line, "• ") {
indent = " "
}
for i, l := range strings.Split(ansi.Wrap(line, max(1, width-len(indent)), " -/"), "\n") {
if i > 0 {
l = indent + l
}
out = append(out, l)
}
}
return out
}
-24
View File
@@ -177,27 +177,3 @@ func TestHighlightKeepsColours(t *testing.T) {
t.Error("reverse kept the colours") t.Error("reverse kept the colours")
} }
} }
func TestListHeader(t *testing.T) {
l := NewList("t", Item[string]{
ID: func(s string) string { return s },
Filter: func(s string) string { return s },
Render: func(_ styles.Styles, s string, _ int) string { return s },
})
l.Header, l.HeaderShort = []string{"ART1", "ART2"}, "short"
l.SetItems([]string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j"})
l.SetSize(30, 2+2+1+6) // the art, a blank line and 6 rows
st := styles.Default()
if v := l.View(st, true, ""); !strings.Contains(v, "ART1") || strings.Contains(v, "short") {
t.Errorf("at the top, the art:\n%s", v)
}
l.Move(6) // below the rows under the art
if v := l.View(st, true, ""); strings.Contains(v, "ART1") || !strings.Contains(v, "short") || !strings.Contains(v, " g") {
t.Errorf("further down, the text:\n%s", v)
}
l.Top()
l.SetSize(30, 8) // too short for the art and 5 rows
if v := l.View(st, true, ""); strings.Contains(v, "ART1") || !strings.Contains(v, "short") {
t.Errorf("no room, the text:\n%s", v)
}
}
-79
View File
@@ -1,79 +0,0 @@
package panes
import (
"bytes"
"crypto/sha1"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"strings"
"time"
)
// A revealed value is shown as it reads best: JSON indented, and PEM
// certificates as what they say (subject, issuer, dates, names) rather
// than base64, with any other PEM block, a private key say, named but not
// shown. Anything else is shown as it is. y copies it as stored.
// valueView is how to show v, and what's been done to it ("" when it's
// shown as it is).
func valueView(v string) (lines []string, note string) {
if lines, ok := pemView(v); ok {
return lines, "certificate"
}
if t := strings.TrimSpace(v); (strings.HasPrefix(t, "{") || strings.HasPrefix(t, "[")) && json.Valid([]byte(t)) {
var b bytes.Buffer
if json.Indent(&b, []byte(t), "", " ") == nil {
return strings.Split(b.String(), "\n"), "JSON, formatted"
}
}
return strings.Split(v, "\n"), ""
}
// pemView describes the PEM blocks in v, if it's PEM with a certificate.
func pemView(v string) ([]string, bool) {
rest := []byte(strings.TrimSpace(v))
var out []string
certs := 0
for len(rest) > 0 {
block, after := pem.Decode(rest)
if block == nil {
return nil, false // text that isn't PEM: show it all
}
rest = bytes.TrimSpace(after)
if len(out) > 0 {
out = append(out, "")
}
if block.Type != "CERTIFICATE" {
out = append(out, block.Type+" (not shown)")
continue
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, false
}
certs++
out = append(out, certLines(cert)...)
}
return out, certs > 0
}
func certLines(c *x509.Certificate) []string {
day := func(t time.Time) string { return t.UTC().Format("2006-01-02") }
out := []string{
"subject " + c.Subject.String(),
"issuer " + c.Issuer.String(),
"valid " + day(c.NotBefore) + " to " + day(c.NotAfter),
}
names := append([]string{}, c.DNSNames...)
for _, ip := range c.IPAddresses {
names = append(names, ip.String())
}
names = append(names, c.EmailAddresses...)
if len(names) > 0 {
out = append(out, "names "+strings.Join(names, ", "))
}
sum := sha1.Sum(c.Raw) // what Azure and Windows call the thumbprint
return append(out, "SHA-1 "+fmt.Sprintf("%X", sum[:]))
}
-60
View File
@@ -1,60 +0,0 @@
package panes
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"slices"
"strings"
"testing"
"time"
)
func TestValueViewJSON(t *testing.T) {
lines, note := valueView(`{"user":"app","port":5432,"tags":["a"]}`)
want := []string{"{", ` "user": "app",`, ` "port": 5432,`, ` "tags": [`, ` "a"`, " ]", "}"}
if !slices.Equal(lines, want) || note != "JSON, formatted" {
t.Errorf("lines %q, note %q", lines, note)
}
for _, v := range []string{"plain value", "{not json", "42", "line one\nline two"} {
if lines, note := valueView(v); note != "" || strings.Join(lines, "\n") != v {
t.Errorf("%q shown as %q (%q)", v, lines, note)
}
}
}
func TestValueViewCertificate(t *testing.T) {
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "app.example.com"},
DNSNames: []string{"app.example.com", "www.app.example.com"},
NotBefore: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), NotAfter: time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC),
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
keyDER, _ := x509.MarshalECPrivateKey(key)
bundle := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) +
string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}))
lines, note := valueView(bundle)
text := strings.Join(lines, "\n")
for _, want := range []string{"subject CN=app.example.com", "valid 2026-01-01 to 2027-01-01",
"names app.example.com, www.app.example.com", "SHA-1 ", "EC PRIVATE KEY (not shown)"} {
if !strings.Contains(text, want) {
t.Errorf("missing %q:\n%s", want, text)
}
}
if note != "certificate" || strings.Contains(text, "BEGIN") {
t.Errorf("note %q, or PEM shown:\n%s", note, text)
}
// A private key alone isn't a certificate: shown as it is.
if _, note := valueView(string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}))); note != "" {
t.Errorf("a key alone: note %q", note)
}
}
+2 -31
View File
@@ -3,8 +3,6 @@
package styles package styles
import ( import (
"image/color"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
@@ -34,10 +32,6 @@ type Styles struct {
KindAzure lipgloss.Style KindAzure lipgloss.Style
KindDatabricks lipgloss.Style KindDatabricks lipgloss.Style
KindRepo lipgloss.Style KindRepo lipgloss.Style
// themed marks the selection colours as set by the theme, so
// OnBackground leaves them.
themed, themedBlurred bool
} }
func Default() Styles { func Default() Styles {
@@ -49,12 +43,9 @@ func Default() Styles {
TitleBlurred: lipgloss.NewStyle(), TitleBlurred: lipgloss.NewStyle(),
Footer: lipgloss.NewStyle().Faint(true), Footer: lipgloss.NewStyle().Faint(true),
// A background rather than reverse, so the row keeps its colours; // A background rather than reverse, so the row keeps its colours.
// in every pane, so each shows what's selected in it. The focused
// pane's is bold too. Palette 8 until the terminal says what its
// background is (OnBackground).
Selected: lipgloss.NewStyle().Background(lipgloss.BrightBlack).Bold(true), Selected: lipgloss.NewStyle().Background(lipgloss.BrightBlack).Bold(true),
SelectedBlurred: lipgloss.NewStyle().Background(lipgloss.BrightBlack), SelectedBlurred: lipgloss.NewStyle().Bold(true),
Muted: lipgloss.NewStyle().Faint(true), Muted: lipgloss.NewStyle().Faint(true),
Error: lipgloss.NewStyle().Foreground(lipgloss.Red), Error: lipgloss.NewStyle().Foreground(lipgloss.Red),
Notice: lipgloss.NewStyle().Foreground(lipgloss.Green), Notice: lipgloss.NewStyle().Foreground(lipgloss.Green),
@@ -83,23 +74,3 @@ func (s Styles) Kind(k provider.Kind) lipgloss.Style {
} }
return s.KindAzure return s.KindAzure
} }
// OnBackground shades the selection colours from the terminal's
// background, a little lighter on a dark one and darker on a light one:
// a quiet band that keeps the row's colours readable, where palette 8 is
// often too bright. Colours the theme sets are kept.
func (s Styles) OnBackground(bg color.Color, dark bool) Styles {
shade := func(p float64) color.Color {
if dark {
return lipgloss.Lighten(bg, p)
}
return lipgloss.Darken(bg, p)
}
if !s.themed {
s.Selected = s.Selected.Background(shade(0.09))
}
if !s.themedBlurred {
s.SelectedBlurred = s.SelectedBlurred.Background(shade(0.05))
}
return s
}
-2
View File
@@ -70,10 +70,8 @@ func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
st.BorderBlurred = v.fg(lipgloss.NewStyle()) st.BorderBlurred = v.fg(lipgloss.NewStyle())
case "selectedLineBgColor": case "selectedLineBgColor":
st.Selected = v.bg(lipgloss.NewStyle()).Bold(true) st.Selected = v.bg(lipgloss.NewStyle()).Bold(true)
st.themed = true
case "inactiveViewSelectedLineBgColor": case "inactiveViewSelectedLineBgColor":
st.SelectedBlurred = v.bg(lipgloss.NewStyle()).Bold(true) st.SelectedBlurred = v.bg(lipgloss.NewStyle()).Bold(true)
st.themedBlurred = true
case "defaultFgColor": case "defaultFgColor":
fg = v.color fg = v.color
case "optionsTextColor": case "optionsTextColor":
-30
View File
@@ -1,7 +1,6 @@
package styles package styles
import ( import (
"fmt"
"strings" "strings"
"testing" "testing"
@@ -63,32 +62,3 @@ func TestEmptyThemeIsDefault(t *testing.T) {
t.Errorf("empty theme: %v %v reverse=%v bg=%v", err, fg, st.Selected.GetReverse(), st.Selected.GetBackground()) t.Errorf("empty theme: %v %v reverse=%v bg=%v", err, fg, st.Selected.GetReverse(), st.Selected.GetBackground())
} }
} }
func TestOnBackground(t *testing.T) {
hex := func(c any) string {
r, g, b, _ := c.(interface{ RGBA() (r, g, b, a uint32) }).RGBA()
return fmt.Sprintf("#%02x%02x%02x", r>>8, g>>8, b>>8)
}
st := Default().OnBackground(lipgloss.Color("#282c34"), true)
if got := hex(st.Selected.GetBackground()); got != "#3e424a" {
t.Errorf("dark: selected %s", got)
}
if got := hex(st.SelectedBlurred.GetBackground()); got != "#343840" {
t.Errorf("dark: blurred %s", got)
}
if got := hex(Default().OnBackground(lipgloss.Color("#ffffff"), false).Selected.GetBackground()); got != "#e8e8e8" {
t.Errorf("light: selected %s", got)
}
themed, _, err := FromTheme(map[string][]string{"selectedLineBgColor": {"#2d3f76"}})
if err != nil {
t.Fatal(err)
}
st = themed.OnBackground(lipgloss.Color("#282c34"), true)
if got := hex(st.Selected.GetBackground()); got != "#2d3f76" {
t.Errorf("themed: selected %s (want the theme's)", got)
}
if got := hex(st.SelectedBlurred.GetBackground()); got != "#343840" {
t.Errorf("themed: blurred %s (not themed: shaded)", got)
}
}
+29 -90
View File
@@ -3,9 +3,6 @@
// against the release's checksums.txt, tried (--version), then renamed // against the release's checksums.txt, tried (--version), then renamed
// over the binary. A running lazysecrets keeps its old binary until it // over the binary. A running lazysecrets keeps its old binary until it
// restarts. // restarts.
//
// Releases are published in more than one place; each is tried in turn
// (GitHub, then git.kebcloud.no), so one being down doesn't stop updates.
package update package update
import ( import (
@@ -21,7 +18,6 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"os" "os"
"os/exec" "os/exec"
"path" "path"
@@ -34,20 +30,12 @@ import (
"golang.org/x/mod/semver" "golang.org/x/mod/semver"
) )
// DefaultRepo is lazysecrets' home: its source, docs and releases. // Where releases are published.
// GitHubRepo mirrors it, with the issues and the releases too.
const ( const (
DefaultAPI = "https://git.kebcloud.no/api/v1/repos/keb/lazysecrets"
DefaultRepo = "https://git.kebcloud.no/keb/lazysecrets" DefaultRepo = "https://git.kebcloud.no/keb/lazysecrets"
GitHubRepo = "https://github.com/karleinarb/lazysecrets"
) )
// DefaultServers are where releases are looked for, in order.
var DefaultServers = []string{GitHubRepo, DefaultRepo}
// latestTimeout bounds asking one server for the latest release, so a
// server that's down leaves time for the next.
const latestTimeout = 5 * time.Second
// maxDownload bounds what's read from the server. // maxDownload bounds what's read from the server.
const maxDownload = 200 << 20 const maxDownload = 200 << 20
@@ -72,20 +60,15 @@ func PlainLinks(s string) string { return mdLink.ReplaceAllString(s, "$1") }
// and a terminal shows them as their text. // 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 server.
type Client struct { type Client struct {
// Servers are the repositories' web addresses, on GitHub or a Gitea API string // the repository's API, DefaultAPI when empty
// or Forgejo server, tried in order; DefaultServers when empty. Repo string // the repository's web address, DefaultRepo when empty
Servers []string
HTTP *http.Client HTTP *http.Client
} }
func (c *Client) servers() []string { func (c *Client) api() string { return cmpOr(c.API, DefaultAPI) }
if len(c.Servers) > 0 { func (c *Client) repo() string { return cmpOr(c.Repo, DefaultRepo) }
return c.Servers
}
return DefaultServers
}
func (c *Client) client() *http.Client { func (c *Client) client() *http.Client {
if c.HTTP != nil { if c.HTTP != nil {
@@ -94,50 +77,25 @@ func (c *Client) client() *http.Client {
return &http.Client{Timeout: 2 * time.Minute} return &http.Client{Timeout: 2 * time.Minute}
} }
// API is a repository's API: GitHub's for github.com, else Gitea's (and func cmpOr(a, b string) string {
// Forgejo's). if a != "" {
func API(repo string) (string, error) { return a
u, err := url.Parse(strings.TrimSuffix(repo, "/"))
if err != nil || u.Host == "" || strings.Count(u.Path, "/") != 2 {
return "", fmt.Errorf("%q isn't a repository's address, like %s", repo, GitHubRepo)
} }
if u.Host == "github.com" { return b
return "https://api.github.com/repos" + u.Path, nil
}
return u.Scheme + "://" + u.Host + "/api/v1/repos" + u.Path, nil
} }
// Latest is the newest release (pre-releases aside), from the first // Latest is the newest release (pre-releases aside).
// server that answers.
func (c *Client) Latest(ctx context.Context) (Release, error) { func (c *Client) Latest(ctx context.Context) (Release, error) {
var errs []error b, err := c.get(ctx, c.api()+"/releases/latest", 1<<20)
for _, repo := range c.servers() {
r, err := c.latest(ctx, repo)
if err == nil {
return r, nil
}
errs = append(errs, err)
}
return Release{}, errors.Join(errs...)
}
func (c *Client) latest(ctx context.Context, repo string) (Release, error) {
api, err := API(repo)
if err != nil {
return Release{}, err
}
ctx, cancel := context.WithTimeout(ctx, latestTimeout)
defer cancel()
b, err := c.get(ctx, api+"/releases/latest", 1<<20)
if err != nil { if err != nil {
return Release{}, err return Release{}, err
} }
var r Release var r Release
if err := json.Unmarshal(b, &r); err != nil { if err := json.Unmarshal(b, &r); err != nil {
return Release{}, fmt.Errorf("reading the latest release from %s: %w", repo, err) return Release{}, fmt.Errorf("reading the latest release: %w", err)
} }
if !semver.IsValid(r.Tag) { if !semver.IsValid(r.Tag) {
return Release{}, fmt.Errorf("latest release %q on %s isn't a version", r.Tag, repo) return Release{}, fmt.Errorf("latest release %q isn't a version", r.Tag)
} }
return r, nil return r, nil
} }
@@ -189,10 +147,23 @@ func (c *Client) Install(ctx context.Context, r Release, exe string) error {
} }
version := strings.TrimPrefix(r.Tag, "v") version := strings.TrimPrefix(r.Tag, "v")
name := fmt.Sprintf("lazysecrets_%s_%s_%s", version, runtime.GOOS, runtime.GOARCH) name := fmt.Sprintf("lazysecrets_%s_%s_%s", version, runtime.GOOS, runtime.GOARCH)
archive, err := c.download(ctx, r.Tag, name+".tar.gz") base := c.repo() + "/releases/download/" + r.Tag + "/"
sums, err := c.get(ctx, base+"checksums.txt", 1<<20)
if err != nil { if err != nil {
return err return err
} }
want, err := checksum(sums, name+".tar.gz")
if err != nil {
return err
}
archive, err := c.get(ctx, base+name+".tar.gz", maxDownload)
if err != nil {
return err
}
if got := sha256.Sum256(archive); hex.EncodeToString(got[:]) != want {
return fmt.Errorf("%s.tar.gz doesn't match checksums.txt: damaged, or not the release", name)
}
bin, err := extract(archive, path.Join(name, "lazysecrets")) bin, err := extract(archive, path.Join(name, "lazysecrets"))
if err != nil { if err != nil {
return err return err
@@ -227,38 +198,6 @@ func (c *Client) Install(ctx context.Context, r Release, exe string) error {
return nil return nil
} }
// download fetches a release's file from the first server that has it,
// checked against that server's checksums.txt. A file that doesn't match
// stops there: it's damaged, or not the release.
func (c *Client) download(ctx context.Context, tag, file string) ([]byte, error) {
var errs []error
for _, repo := range c.servers() {
base := strings.TrimSuffix(repo, "/") + "/releases/download/" + tag + "/"
sctx, cancel := context.WithTimeout(ctx, latestTimeout)
sums, err := c.get(sctx, base+"checksums.txt", 1<<20)
cancel()
if err != nil {
errs = append(errs, err)
continue
}
want, err := checksum(sums, file)
if err != nil {
errs = append(errs, fmt.Errorf("%s: %w", repo, err))
continue
}
b, err := c.get(ctx, base+file, maxDownload)
if err != nil {
errs = append(errs, err)
continue
}
if got := sha256.Sum256(b); hex.EncodeToString(got[:]) != want {
return nil, fmt.Errorf("%s from %s doesn't match checksums.txt: damaged, or not the release", file, repo)
}
return b, nil
}
return nil, errors.Join(errs...)
}
func (c *Client) get(ctx context.Context, url string, limit int64) ([]byte, error) { func (c *Client) get(ctx context.Context, url string, limit int64) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil { if err != nil {
+5 -52
View File
@@ -70,13 +70,13 @@ func server(t *testing.T, tamper bool) *httptest.Server {
sum[0] ^= 0xff sum[0] ^= 0xff
} }
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/keb/lazysecrets/releases/latest", func(w http.ResponseWriter, _ *http.Request) { mux.HandleFunc("/api/releases/latest", func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprint(w, `{"tag_name":"v0.2.0","body":"### Added\n\n- x\n\n## Install\n","html_url":"https://example.com/r"}`) fmt.Fprint(w, `{"tag_name":"v0.2.0","body":"### Added\n\n- x\n\n## Install\n","html_url":"https://example.com/r"}`)
}) })
mux.HandleFunc("/keb/lazysecrets/releases/download/v0.2.0/checksums.txt", func(w http.ResponseWriter, _ *http.Request) { mux.HandleFunc("/repo/releases/download/v0.2.0/checksums.txt", func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprintf(w, "%s %s.tar.gz\n", hex.EncodeToString(sum[:]), name) fmt.Fprintf(w, "%s %s.tar.gz\n", hex.EncodeToString(sum[:]), name)
}) })
mux.HandleFunc("/keb/lazysecrets/releases/download/v0.2.0/"+name+".tar.gz", func(w http.ResponseWriter, _ *http.Request) { mux.HandleFunc("/repo/releases/download/v0.2.0/"+name+".tar.gz", func(w http.ResponseWriter, _ *http.Request) {
w.Write(archive.Bytes()) w.Write(archive.Bytes())
}) })
srv := httptest.NewServer(mux) srv := httptest.NewServer(mux)
@@ -89,7 +89,7 @@ func TestInstall(t *testing.T) {
t.Skip("releases are for macOS and Linux") t.Skip("releases are for macOS and Linux")
} }
srv := server(t, false) srv := server(t, false)
c := &Client{Servers: []string{srv.URL + "/keb/lazysecrets"}} c := &Client{API: srv.URL + "/api", Repo: srv.URL + "/repo"}
r, err := c.Latest(context.Background()) r, err := c.Latest(context.Background())
if err != nil || r.Tag != "v0.2.0" { if err != nil || r.Tag != "v0.2.0" {
t.Fatalf("latest %+v %v", r, err) t.Fatalf("latest %+v %v", r, err)
@@ -107,59 +107,12 @@ func TestInstall(t *testing.T) {
} }
} }
// A server that's down, or doesn't have the release, is skipped.
func TestFallsBackToTheNextServer(t *testing.T) {
if runtime.GOOS != "darwin" && runtime.GOOS != "linux" {
t.Skip("releases are for macOS and Linux")
}
down := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "down", http.StatusBadGateway)
}))
t.Cleanup(down.Close)
srv := server(t, false)
c := &Client{Servers: []string{down.URL + "/keb/lazysecrets", srv.URL + "/keb/lazysecrets"}}
r, err := c.Latest(context.Background())
if err != nil || r.Tag != "v0.2.0" {
t.Fatalf("latest %+v %v", r, err)
}
exe := filepath.Join(t.TempDir(), "lazysecrets")
os.WriteFile(exe, []byte("old"), 0o755)
if err := c.Install(context.Background(), r, exe); err != nil {
t.Fatal(err)
}
if b, _ := os.ReadFile(exe); !strings.Contains(string(b), "echo lazysecrets 0.2.0") {
t.Errorf("not replaced: %q", b)
}
// Every server down: both say why.
c = &Client{Servers: []string{down.URL + "/a/b", down.URL + "/c/d"}}
if _, err := c.Latest(context.Background()); err == nil || strings.Count(err.Error(), "502") != 2 {
t.Errorf("all down: %v", err)
}
}
func TestAPI(t *testing.T) {
for repo, want := range map[string]string{
"https://github.com/karleinarb/lazysecrets": "https://api.github.com/repos/karleinarb/lazysecrets",
"https://git.kebcloud.no/keb/lazysecrets/": "https://git.kebcloud.no/api/v1/repos/keb/lazysecrets",
"https://codeberg.org/someone/lazysecrets": "https://codeberg.org/api/v1/repos/someone/lazysecrets",
"https://git.kebcloud.no/keb": "",
"git.kebcloud.no/keb/lazysecrets": "",
"https://github.com/karleinarb/lazysecrets/x": "",
} {
got, err := API(repo)
if got != want || (err != nil) != (want == "") {
t.Errorf("API(%q) = %q, %v", repo, got, err)
}
}
}
func TestInstallRefusesATamperedArchive(t *testing.T) { func TestInstallRefusesATamperedArchive(t *testing.T) {
if runtime.GOOS != "darwin" && runtime.GOOS != "linux" { if runtime.GOOS != "darwin" && runtime.GOOS != "linux" {
t.Skip("releases are for macOS and Linux") t.Skip("releases are for macOS and Linux")
} }
srv := server(t, true) srv := server(t, true)
c := &Client{Servers: []string{srv.URL + "/keb/lazysecrets"}} c := &Client{API: srv.URL + "/api", Repo: srv.URL + "/repo"}
exe := filepath.Join(t.TempDir(), "lazysecrets") exe := filepath.Join(t.TempDir(), "lazysecrets")
os.WriteFile(exe, []byte("old"), 0o755) os.WriteFile(exe, []byte("old"), 0o755)
err := c.Install(context.Background(), Release{Tag: "v0.2.0"}, exe) err := c.Install(context.Background(), Release{Tag: "v0.2.0"}, exe)
-75
View File
@@ -1,75 +0,0 @@
// Copies a release GoReleaser published on Gitea to the GitHub mirror:
// the same notes and the same files (dist/), so lazysecrets can update
// from either (internal/update tries GitHub first). Run by the release
// workflow after GoReleaser; a release already on GitHub for the tag is
// replaced, like GoReleaser's mode: replace on Gitea.
//
// TAG=v0.2.0 GH_TOKEN=… GITEA_TOKEN=… node scripts/github-release.mjs
//
// GH_TOKEN is a fine-grained token that can write the mirror's contents;
// without it, this does nothing. No dependencies: Node 20's fetch.
import { readdir, readFile } from "node:fs/promises";
const github = "karleinarb/lazysecrets";
const { TAG: tag, GH_TOKEN: token, GITEA_TOKEN: giteaToken, GITHUB_SHA: sha } = process.env;
const gitea = `${process.env.GITHUB_SERVER_URL ?? "https://git.kebcloud.no"}/api/v1/repos/${process.env.GITHUB_REPOSITORY ?? "keb/lazysecrets"}`;
if (!token) {
console.log("No GH_TOKEN: not copying the release to GitHub.");
process.exit(0);
}
if (!tag) throw new Error("TAG isn't set");
const gh = (path, init = {}) =>
fetch(path.startsWith("https://") ? path : `https://api.github.com/repos/${github}${path}`, {
...init,
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"X-GitHub-Api-Version": "2022-11-28",
...init.headers,
},
});
async function ok(res, what) {
if (!res.ok) throw new Error(`${what}: ${res.status} ${await res.text()}`);
return res;
}
// The notes as GoReleaser wrote them on Gitea.
const rel = await (
await ok(await fetch(`${gitea}/releases/tags/${tag}`, { headers: giteaToken ? { Authorization: `token ${giteaToken}` } : {} }), "reading the Gitea release")
).json();
// Replace what's there for the tag.
const old = await gh(`/releases/tags/${tag}`);
if (old.ok) {
const { id } = await old.json();
await ok(await gh(`/releases/${id}`, { method: "DELETE" }), "deleting the old GitHub release");
console.log(`Deleted the GitHub release for ${tag}.`);
}
// The mirror may not have the tag's commit yet: it pushes on its own.
// Wait for it a while.
const body = JSON.stringify({ tag_name: tag, target_commitish: sha, name: rel.name || tag, body: rel.body, prerelease: rel.prerelease });
let created;
for (let i = 0; ; i++) {
const res = await gh("/releases", { method: "POST", body });
if (res.ok) {
created = await res.json();
break;
}
if (res.status !== 422 || i === 9) await ok(res, "creating the GitHub release");
console.log("GitHub doesn't have the commit yet; waiting for the mirror…");
await new Promise((r) => setTimeout(r, 30_000));
}
const files = (await readdir("dist")).filter((f) => f.endsWith(".tar.gz") || f === "checksums.txt");
if (files.length === 0) throw new Error("nothing to upload in dist/");
for (const f of files) {
const url = `https://uploads.github.com/repos/${github}/releases/${created.id}/assets?name=${encodeURIComponent(f)}`;
await ok(await gh(url, { method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: await readFile(`dist/${f}`) }), `uploading ${f}`);
console.log(`Uploaded ${f}.`);
}
console.log(`Published ${tag} on GitHub: ${created.html_url}`);