1 Commits
Author SHA1 Message Date
keb edf6e79327 Hello world
Release / Build and publish (push) Successful in 38s
2026-10-12 15:40:46 +02:00
72 changed files with 3112 additions and 391 deletions

No files matched your search

+14 -2
View File
@@ -4,8 +4,9 @@ name: Release
# as a pre-release) tests lazysecrets, then GoReleaser (.goreleaser.yaml)
# 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
# changelog (git-cliff, cliff.toml). Pushing the same tag again (after
# deleting it) replaces the release.
# changelog (git-cliff, cliff.toml), then copies it to the GitHub
# mirror. Pushing the same tag again (after deleting it) replaces the
# release in both places.
#
# scripts/release.sh v0.2.0 # updates CHANGELOG.md, tags and pushes
@@ -81,3 +82,14 @@ jobs:
args: release --clean --release-notes ${{ runner.temp }}/notes.md
env:
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
@@ -0,0 +1,46 @@
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
@@ -0,0 +1,5 @@
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
@@ -0,0 +1,20 @@
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.
## v0.1.0 – 2026-10-10
## v0.1.0 – 2026-10-12
Hello world 👋
+30 -17
View File
@@ -1,29 +1,28 @@
# lazysecrets
A terminal UI for finding, reading and sharing secrets. Inspired by the fantastic lazygit.
A terminal UI for finding, reading and sharing secrets. Inspired by the fantastic [lazygit](https://github.com/jesseduffield/lazygit).
![lazysecrets](docs/assets/lazysecrets.png)
## Main features
- **Read-only**: lazysecrets never creates, changes or deletes a secret, so
it's safe to point at production. That's by design and may stay that way.
(`e` opens a repo's SOPS or `.env` file in your editor, or `sops edit`;
any change is yours.)
- **Read-only**: lazysecrets never creates, changes or deletes a secret.
That's by design and may stay that way.
- **Zero setup**: uses the logins you already have (`az`, the Databricks
CLI, `sops`, `git`). No config file needed.
- **Search everything**: `ctrl+s` searches every source at once, with
fzf-style matching (`'exact`, `^prefix`, `suffix$`, `!not`), and jumps
straight to the result.
- **Share links**: `s` copies a `lazysecrets://` link. A colleague opens it
fzf-style matching.
- **Share links**: Share `lazysecrets://` links with colleagues. A colleague opens it
with their own access, from the terminal or with a click in the browser
or chat.
- **Neovim integration**: via [lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim)
- **Careful with values**: values stay hidden until you reveal them, and
copied values are cleared from the clipboard after 30 seconds.
- **Scriptable**: `lazysecrets get <link>` prints a value for scripts and
pipes.
- **Favourites**: star the secrets you use most and get back to them in one
key.
- **Favourites**: star the secrets you use most for easy access.
- **Expiry warnings**: secrets, keys and certificates that expire soon are
marked.
marked, listed together in the status pane.
- **Your way**: rebind any key, pick a theme, and add custom commands to run
on a secret.
- **Try it first**: `lazysecrets --demo` runs on built-in sample data.
@@ -39,8 +38,9 @@ On macOS or Linux (arm64 or x86_64):
curl -fsSL https://git.kebcloud.no/keb/lazysecrets/raw/branch/main/install.sh | sh
```
It installs the latest [release](https://git.kebcloud.no/keb/lazysecrets/releases)
into `~/.local/bin` after checking it against the release's checksums.
It installs the latest release, from GitHub or else
[git.kebcloud.no](https://git.kebcloud.no/keb/lazysecrets/releases), into
`~/.local/bin` after checking it against the release's checksums.
`sh -s -- v0.2.0` installs a given version, `LAZYSECRETS_INSTALL_DIR`
somewhere else.
@@ -60,12 +60,14 @@ lazysecrets # start lazysecrets
lazysecrets --demo # test with demo data
```
Pick a source on the left, then a vault, then a secret. The keys you'll
Pick a source on the left, then a vault, then a secret. With nothing
signed in yet, the right pane says how to get started. The keys you'll
use most:
| Key | Action |
|---|---|
| `j`/`k`, `h`/`l`, `enter`, `esc` | move, switch panes, drill in, back |
| `j`/`k`, `enter`, `esc` | move, drill in, back |
| `l` / `h` | the details of what's selected / back |
| `/` | filter the list you're in |
| `space` | reveal / hide the value |
| `y` | copy the value (cleared from the clipboard after 30s) |
@@ -96,9 +98,20 @@ 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
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
call of its own is a daily check for a new release on this server, which
`updateCheck: false` turns off.
call of its own is a daily check for a new release, on GitHub or else
git.kebcloud.no, which `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
+10
View File
@@ -0,0 +1,10 @@
// Package lazysecrets holds what the module's root has for the app: the
// changelog, embedded so the status pane can show it offline.
package lazysecrets
import _ "embed"
// Changelog is CHANGELOG.md as of the build: every release up to it.
//
//go:embed CHANGELOG.md
var Changelog string
+5
View File
@@ -25,6 +25,7 @@ Hello world 👋
### {{ group | striptags | trim }}
{% for commit in commits %}
- {% if commit.breaking %}**Breaking:** {% endif %}{{ commit.message | split(pat="\n") | first | upper_first | trim }} \
([{{ commit.id | truncate(length=7, end="") }}](https://git.kebcloud.no/keb/lazysecrets/commit/{{ commit.id }}))\
{% endfor %}
{% endfor %}\n
"""
@@ -33,6 +34,8 @@ trim = true
# Runs on each part (header, every release) separately.
postprocessors = [
{ pattern = "\n{3,}", replace = "\n\n" },
# Squash commits end in the PR's number: link it.
{ pattern = "\\(#([0-9]+)\\)", replace = "([#${1}](https://git.kebcloud.no/keb/lazysecrets/pulls/${1}))" },
]
[git]
@@ -46,6 +49,8 @@ commit_preprocessors = [
# A merge commit (not a squash) says "Merge pull request 'feat: …' (#4)
# from branch into main": keep the title and number.
{ pattern = "^Merge pull request '(.+)' \\((#[0-9]+)\\) from .*", replace = "${1} (${2})" },
# #11 was squash-merged with #10 (the status pane) in it: name both.
{ pattern = "^feat: link each changelog entry to its PR and commit \\(#11\\)", replace = "feat: a status pane (0) with version, updates, accounts, files and links; changelog entries link to their PR and commit (#11)" },
# Titles may be written "[feat] x", "[feat]: x" or "feat:x" (see
# pr-title.yml); all become "feat: x".
{ pattern = "^\\[(feat|fix|perf|refactor|docs|test|ci|chore|build|style|revert)(\\([^)]*\\))?(!?)\\](!?):?\\s*", replace = "${1}${2}${3}${4}: " },
+28 -4
View File
@@ -16,6 +16,7 @@ import (
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets"
"git.kebcloud.no/keb/lazysecrets/internal/commands"
"git.kebcloud.no/keb/lazysecrets/internal/config"
"git.kebcloud.no/keb/lazysecrets/internal/instance"
@@ -154,7 +155,7 @@ Flags:
case len(args) > 0 && args[0] == "launch":
return launch(*configPath, args[1:])
case len(args) > 0 && args[0] == "update":
return updateCmd(args[1:])
return updateCmd(*configPath, args[1:])
case len(args) == 0:
case args[0] == "open" && len(args) == 2:
r, err := ref.Parse(args[1])
@@ -242,9 +243,10 @@ Flags:
if *demo {
// Two Azure accounts, like a personal and an admin login in az.
providers = []provider.Provider{
fake.Azure(400*time.Millisecond).As("Azure:dev@example.com/demo", "Azure · dev"),
fake.AzureOps(700*time.Millisecond).As("Azure:ops@example.com/demo", "Azure · ops"),
fake.Azure(400*time.Millisecond).As("Azure:alice@example.com/demo", "Azure · alice"),
fake.AzureOps(700*time.Millisecond).As("Azure:admin.alice@example.com/demo", "Azure · admin.alice"),
fake.Databricks(600 * time.Millisecond),
fake.Repos(),
}
} else {
pc := cfg.Providers
@@ -362,13 +364,16 @@ Flags:
ExpiryWarning: time.Duration(expiryDays) * 24 * time.Hour,
HideOnBlur: config.On(cfg.HideOnBlur),
HandlerSetup: setup,
Updater: updater(),
Updater: updater(cfg.Releases),
UpdateCheck: config.On(cfg.UpdateCheck),
Keys: &keymap,
Commands: cfg.CustomCommands,
Version: versionString(),
Store: st,
CacheFile: cacheFile,
ConfigFiles: configFiles(*configPath),
StatePath: statePath,
Changelog: lazysecrets.Changelog,
Dir: placeDir,
Open: open,
Styles: &theme,
@@ -381,3 +386,22 @@ Flags:
}
return err
}
// configFiles are the config files given, for the status pane: those
// that exist, or the first if none does (where to create one).
func configFiles(list string) []string {
var all, found []string
for _, f := range strings.Split(list, ",") {
if f = strings.TrimSpace(f); f == "" {
continue
}
all = append(all, f)
if _, err := os.Stat(f); err == nil {
found = append(found, f)
}
}
if len(found) == 0 && len(all) > 0 {
return all[:1]
}
return found
}
+11 -5
View File
@@ -7,14 +7,16 @@ import (
"strings"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/config"
"git.kebcloud.no/keb/lazysecrets/internal/ui/app"
"git.kebcloud.no/keb/lazysecrets/internal/update"
)
// updater is the app's way to find and install releases, replacing the
// binary that's running.
func updater() *app.Updater {
c := &update.Client{}
// binary that's running, from the servers (config releases) or the
// default ones.
func updater(servers []string) *app.Updater {
c := &update.Client{Servers: servers}
return &app.Updater{
Latest: c.Latest,
Install: func(ctx context.Context, r update.Release) error {
@@ -29,15 +31,19 @@ func updater() *app.Updater {
// updateCmd is lazysecrets update [--check]: the newer release's
// changelog, then (unless --check) install it.
func updateCmd(args []string) error {
func updateCmd(configPath string, args []string) error {
check := len(args) == 1 && args[0] == "--check"
if len(args) > 0 && !check {
return errors.New("usage: lazysecrets update [--check]")
}
cfg, err := config.LoadAll(strings.Split(configPath, ","))
if err != nil {
return err
}
current := versionString()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
c := &update.Client{}
c := &update.Client{Servers: cfg.Releases}
r, err := c.Latest(ctx)
if err != nil {
return fmt.Errorf("looking for a newer lazysecrets: %w", err)
Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

+22 -7
View File
@@ -13,12 +13,13 @@ is listed under each, since their permissions can differ.
A service principal is used instead when `AZURE_CLIENT_ID`,
`AZURE_TENANT_ID` and a secret or certificate are set in the environment.
On an account's group row, the Details 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
(built-in ones by name) and its subscriptions. It's read from az's account
list and the token lazysecrets already has, so nothing is fetched for it.
`y` there copies the object ID, the one access policies and role
assignments ask for.
(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
the row copies the object ID, the one access policies and role
assignments ask for; `l` goes into the pane, where `j`/`k` pick any
field, `/` filters them (`/tenant`) and `y` copies the selected one.
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`
@@ -37,8 +38,10 @@ vault resources (e.g. *Reader*). Key Vault data-plane access is separate
| read secret values | *Key Vault Secrets User* |
| read certificate details | *Key Vault Certificate User* |
A missing permission is shown in the pane, including the action that was
denied.
A missing permission is shown in the pane, shortened to what was denied
(the action, or the access policy permission), with what to check: a
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
@@ -61,3 +64,15 @@ Secrets, keys and certificates, each on their own tab of the Secrets pane
key (PEM public key) or certificate; private key material is never
fetched. `v` lists an item's versions. Items expiring within
`expiryWarningDays` (30 by default) are marked `⚠ 12d`, expired ones `✗`.
The status pane (`0`) lists them across every vault loaded, opened or
cached, soonest first, and its line counts them (`⚠ 3 expiring`); `enter`
on one goes to it. That makes no requests of its own: expiry dates come
with the lists. It says how many vaults it covers; `ctrl+a` in search
loads the rest.
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
purge protection, which networks can reach it, private endpoints and tags.
They come with the list of vaults, so they cost no extra requests and are
cached with it.
+15 -8
View File
@@ -15,6 +15,9 @@ loadDelay: 150ms # how long the cursor rests on a row before it loads; 0s
sourcesView: grouped # or flat; ` toggles it
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)
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)
cache: true # keep what was loaded (never values) for the next start
providers:
@@ -56,7 +59,7 @@ theme:
activeBorderColor: ["#ff966c", bold] # focused pane
inactiveBorderColor: ["#589ed7"]
selectedLineBgColor: ["#2d3f76"] # cursor row background (default: palette 8)
inactiveViewSelectedLineBgColor: ["#2f334d"]
inactiveViewSelectedLineBgColor: ["#2f334d"] # in the other panes (default: palette 8, not bold)
defaultFgColor: ["#c8d3f5"]
optionsTextColor: ["#82aaff"] # keys and labels
searchMatchColor: ["#ffc777", bold]
@@ -114,8 +117,9 @@ What lazysecrets has loaded is kept in
you: the Sources lists, vaults and scopes, and secrets' names and metadata
(tags, dates, references), never values. On the next start they show at
once, and anything older than two minutes reloads in the background when
you go to it, or when Go to searches it. Revealing and copying always
fetch the value live.
you go to it, or when Go to searches it. While it does, the pane's
bottom border says how old what it shows is (`cached 3h ago`). Revealing
and copying always fetch the value live.
Lists not loaded for 30 days are dropped, and so is everything from an
account you're no longer signed in to. Repos aren't cached: their files
@@ -124,14 +128,17 @@ are read from disk anyway. Deleting the file is always safe, and
## Updates
Once a day (`updateCheck`), in the background, lazysecrets asks the
release server for the latest release; within the day it reuses the
answer from the state file. Offline, or with the server down, it says
nothing. A newer release shows in the footer, `v0.1.1 ↑ v0.2.0`; `U` (or
Once a day (`updateCheck`), in the background, lazysecrets asks for the
latest release; within the day it reuses the answer from the state file.
Releases are published on GitHub and on git.kebcloud.no, and it asks
them in that order, the next when one doesn't answer within 5 seconds;
`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.
Updating (`u` there, or `lazysecrets update`) downloads the release for
your OS and CPU, checks it against the release's `checksums.txt`, makes
your OS and CPU from the first server that has it, checks it against
that server's `checksums.txt`, makes
sure it runs, and only then replaces the binary, atomically: a
lazysecrets that's running keeps working, and the next start is the new
version. A binary a package manager owns (Homebrew, Nix, a distro
+4
View File
@@ -13,6 +13,10 @@ Secrets in Azure Key Vault-backed scopes are read through Databricks; if
Databricks refuses, lazysecrets reads the backing vault directly with your
az login (which then needs the [Key Vault roles](azure.md#permissions)).
When Databricks refuses, the pane says what to check: access to the
workspace and READ on the scope, or, when the workspace's IP access list
refused, the network you're on.
Hide profiles you don't want in the [config](config.md):
```yaml
+6 -2
View File
@@ -56,8 +56,12 @@ from the pull request titles. The tag runs the release workflow
(`.gitea/workflows/release.yml`): it tests, then
[GoReleaser](https://goreleaser.com) (`.goreleaser.yaml`) builds the
archives and publishes a Gitea release with them and the release's
changelog section. `goreleaser release --snapshot --clean` builds the same
archives locally, into `dist/`, without publishing.
changelog section. Then `scripts/github-release.mjs` copies that release,
notes and files, to the GitHub mirror, with the `GH_RELEASE_TOKEN`
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`
alone.
+38 -10
View File
@@ -13,7 +13,8 @@ The rule: a letter in the lists, ctrl+ that letter where you type a filter
|---|---|
| `j`/`k`, `↑`/`↓` | move |
| `g`/`G` | top / bottom |
| `tab`/`l`, `shift+tab`/`h` | next / previous pane |
| `tab`, `shift+tab`/`h` | next / previous pane |
| `l` | the details of what's selected, on the right (`h` goes back) |
| `1`–`4` | jump to pane |
| `enter` / `esc`, `⌫` | drill in / back (also clears a filter or leaves the versions view) |
| `[` / `]` | previous / next tab of the focused pane: Sources and Favourites in Sources; Secrets, Keys, Certs in Secrets |
@@ -48,7 +49,7 @@ match case exactly.
| Key | Action |
|---|---|
| `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 |
| `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 reference (`@Microsoft.KeyVault(...)`, key/certificate URL, `{{secrets/scope/key}}`) |
| `v` | versions of the selected item (Azure); `space`/`y`/`Y` then act on that version; `esc`, `⌫` or `v` to go back |
| `o` | open the selected item in the browser (over SSH: copy the link) |
@@ -59,6 +60,11 @@ match case exactly.
| `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 |
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
terminal loses focus (`hideOnBlur`); see [config](config.md).
@@ -78,20 +84,24 @@ 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
selected item.
Search has the scope on the left (All; all of Azure, Databricks or repos;
or one source; with `○ ◐ ●` for how much of it is loaded) and what's in
it on the right. All repos searches every repo without asking Azure or
Databricks for anything. It opens on the
source you're in. Typing a search loads what it covers: everything on
All, else the source picked; results appear as lists arrive. That's once
each time Go to opens, and only what isn't loaded yet; `ctrl+a` loads
again, lists that failed included.
Search has the scope on the left and what's in it on the right. The
scopes are All, then grouped like the Sources pane: each account (or
Databricks, or Repos) with its sources under it, with `○ ◐ ●` for how
much of each is loaded. Repos searches every repo without asking Azure
or Databricks for anything. Go to opens on All; `ctrl+s` again narrows
it to the source or group selected in the Sources pane, and once more
goes back to All. Typing a search loads what it covers: everything on
All, else the account or source picked; results appear as lists
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:
| Browsing | Typing | Action |
|---|---|---|
| `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 |
| `[` / `]` | | previous / next tab |
| `f` | `ctrl+f` | star |
@@ -105,6 +115,24 @@ anything loads.
## Layout
The pane on the right describes what's selected in the pane you're in: the
account or subscription in Sources, the vault in Vaults, the secret in
Secrets. Going into it (`l`, or `4`) keeps it on that; there `j`/`k`,
`/` and `y` work on its rows, and `h` goes back. A value you reveal stays in view whichever pane you're in, until
it's hidden.
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
logo (when there's room): the version and updates (`U`), each account
(`L` on one that needs it signs in), what's expiring (`enter` goes to
it), this session's errors, newest first (what a pane showed, or the
footer flashed before you moved on; not kept after quitting), the config,
state and cache files (`e` opens one in your editor), and links to the
docs, source, issues, releases and changelog (`o` opens one; on the
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 |
|---|---|
| `ctrl+←`/`ctrl+→` or `<`/`>` | move the divider between the left panes and the detail pane |
+7
View File
@@ -63,6 +63,13 @@ copying or `lazysecrets get` decrypts one value with your `sops` binary, so
whatever sops uses works (age keys, Azure Key Vault through az, AWS/GCP
KMS, PGP). When sops can't decrypt, its whole error message is shown.
On a file, the right pane shows how it's encrypted and the keys it's
encrypted for (public: age recipients, Key Vault keys, KMS ARNs, PGP
fingerprints), when it was last encrypted, and what git knows of it:
tracked, ignored or neither, and its last commit. A plain `.env` file that
git tracks, or that isn't ignored, is marked in red: its values are in the
repo's history, or easily could be.
## Opening files
`e` opens the selected file, or on a secret, its file at that key's line:
+40 -14
View File
@@ -7,10 +7,12 @@
#
# LAZYSECRETS_INSTALL_DIR installs somewhere else. macOS and Linux, arm64
# 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
repo=https://git.kebcloud.no/keb/lazysecrets
api=https://git.kebcloud.no/api/v1/repos/keb/lazysecrets
repos="${LAZYSECRETS_RELEASES:-https://github.com/karleinarb/lazysecrets https://git.kebcloud.no/keb/lazysecrets}"
dir="${LAZYSECRETS_INSTALL_DIR:-$HOME/.local/bin}"
fail() {
@@ -18,12 +20,13 @@ fail() {
exit 1
}
# Short timeouts, so a server that's down leaves time for the next.
if command -v curl >/dev/null 2>&1; then
fetch() { curl -fsSL "$1"; }
download() { curl -fsSL -o "$2" "$1"; }
fetch() { curl -fsSL --connect-timeout 5 --max-time 15 "$1"; }
download() { curl -fsSL --connect-timeout 5 -o "$2" "$1"; }
elif command -v wget >/dev/null 2>&1; then
fetch() { wget -qO- "$1"; }
download() { wget -qO "$2" "$1"; }
fetch() { wget -qO- -T 15 "$1"; }
download() { wget -qO "$2" -T 30 "$1"; }
else
fail "needs curl or wget"
fi
@@ -39,11 +42,26 @@ arm64 | aarch64) arch=arm64 ;;
*) fail "no release for $(uname -m)" ;;
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:-}"
if [ -z "$tag" ]; then
tag=$(fetch "$api/releases/latest" | sed -n 's/.*"tag_name":"\([^"]*\)".*/\1/p') ||
fail "couldn't look up the latest release"
[ -n "$tag" ] || fail "couldn't look up the latest release"
for repo in $repos; do
tag=$(fetch "$(api "$repo")/releases/latest" 2>/dev/null |
sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n 1) || tag=
[ -n "$tag" ] && break
done
[ -n "$tag" ] || fail "couldn't look up the latest release on $repos"
fi
case "$tag" in
v*) ;;
@@ -55,11 +73,19 @@ name="lazysecrets_${version}_${os}_${arch}"
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
echo "Downloading lazysecrets $tag for $os/$arch"
download "$repo/releases/download/$tag/$name.tar.gz" "$tmp/$name.tar.gz" ||
fail "couldn't download $tag for $os/$arch: see $repo/releases"
download "$repo/releases/download/$tag/checksums.txt" "$tmp/checksums.txt" ||
fail "couldn't download $tag's checksums.txt"
# The archive and its checksums.txt from the first server that has both.
from=
for repo in $repos; do
base="$repo/releases/download/$tag"
if download "$base/checksums.txt" "$tmp/checksums.txt" 2>/dev/null; then
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")
[ -n "$want" ] || fail "$name.tar.gz isn't in checksums.txt"
+10
View File
@@ -14,6 +14,7 @@ import (
"go.yaml.in/yaml/v3"
"git.kebcloud.no/keb/lazysecrets/internal/commands"
"git.kebcloud.no/keb/lazysecrets/internal/update"
)
type Config struct {
@@ -40,6 +41,10 @@ type Config struct {
// background; defaults to true. U and lazysecrets update work either
// way.
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
// show at once and refresh in the background; defaults to true.
Cache *bool `yaml:"cache"`
@@ -211,6 +216,11 @@ func (c Config) validate(path string) error {
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)
}
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 {
if !strings.HasPrefix(v, "https://") {
return fmt.Errorf("%s: providers.azure.vaults[%d]: %q is not an https:// vault URL", path, i, v)
+9
View File
@@ -70,6 +70,8 @@ func TestLoadRejectsMistakes(t *testing.T) {
"old layout": "azure:\n vaults: [https://kv.vault.azure.net/]\n",
"duration": "timeout: soon\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 {
t.Errorf("%s: no error", name)
@@ -113,6 +115,13 @@ 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) {
c, err := Load(write(t, "loadDelay: 300ms\n"))
if err != nil || c.LoadDelay == nil || *c.LoadDelay != 300*time.Millisecond {
+2 -5
View File
@@ -40,11 +40,8 @@ func (p *Provider) Account() (provider.Account, bool) {
user += " (service principal)"
}
add("user", user)
tenant := a.tenant
if a.tenantName != "" {
tenant = a.tenantName + " · " + a.tenant
}
add("tenant", tenant)
add("tenant", a.tenantName)
add("tenant ID", a.tenant)
if oid := str("oid"); oid != "" {
add("object ID", oid)
out.Copy, out.CopyLabel = oid, "object ID"
+2 -2
View File
@@ -25,7 +25,7 @@ func TestAccount(t *testing.T) {
got[f.Label] = f.Value
}
want := map[string]string{
"name": "Alice Example", "user": "alice@contoso.com", "tenant": "Contoso · t1",
"name": "Alice Example", "user": "alice@contoso.com", "tenant": "Contoso", "tenant ID": "t1",
"object ID": "11111111-2222-3333-4444-555555555555", "sign-in": "MFA · password",
"roles": "Global Administrator, role aaaaaaaa", "subs": "3 · default platform",
}
@@ -44,7 +44,7 @@ func TestAccount(t *testing.T) {
// Before any token: what az's list says.
claims = nil
a, _ = p.Account()
if len(a.Fields) != 3 || a.Copy != "" {
if len(a.Fields) != 4 || a.Copy != "" {
t.Errorf("without a token: %+v", a)
}
if _, ok := (&Provider{}).Account(); ok {
+2 -6
View File
@@ -183,10 +183,6 @@ func (p *Provider) tenant(subscriptionID string) string {
return p.tenants[subscriptionID]
}
const vaultQuery = `resources
| where type =~ 'microsoft.keyvault/vaults'
| project id, name, uri = tostring(properties.vaultUri)`
func (p *Provider) graphVaults(ctx context.Context, src provider.Source) ([]provider.Container, error) {
var out []provider.Container
req := armresourcegraph.QueryRequest{
@@ -226,7 +222,7 @@ func graphVault(src provider.Source, row any) (provider.Container, bool) {
if id == "" || name == "" || uri == "" {
return provider.Container{}, false
}
return provider.Container{Source: src, ID: id, Name: name, Detail: uri, Types: objectTypes}, true
return provider.Container{Source: src, ID: id, Name: name, Detail: uri, Types: objectTypes, Attrs: graphInfo(m).attrs()}, true
}
func (p *Provider) armVaults(ctx context.Context, src provider.Source) ([]provider.Container, error) {
@@ -247,7 +243,7 @@ func (p *Provider) armVaults(ctx context.Context, src provider.Source) ([]provid
}
out = append(out, provider.Container{
Source: src, ID: deref(v.ID), Name: deref(v.Name),
Detail: *v.Properties.VaultURI, Types: objectTypes,
Detail: *v.Properties.VaultURI, Types: objectTypes, Attrs: armInfo(v).attrs(),
})
}
}
+59 -2
View File
@@ -19,6 +19,7 @@ import (
"net/http"
"net/http/httptest"
"reflect"
"slices"
"strings"
"testing"
"time"
@@ -174,8 +175,11 @@ func TestForbiddenIsShortened(t *testing.T) {
if err == nil {
t.Fatal("expected error")
}
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)
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) {
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
if !errors.As(err, &re) || re.StatusCode != 403 {
@@ -183,6 +187,13 @@ 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) {
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"
@@ -417,3 +428,49 @@ func ecPoint(t *testing.T, k *ecdsa.PrivateKey) (x, y []byte) {
size := (len(b) - 1) / 2
return b[1 : 1+size], b[1+size:]
}
func TestVaultSettings(t *testing.T) {
want := []string{"group=rg-sec", "location=norwayeast", "SKU=premium", "access=Azure roles (RBAC)",
"soft delete=on, 30 days", "purge prot.=on", "network=selected networks (1 IP ranges, 0 subnets)",
"private=1 endpoint", "tags=env: prod", "=team: platform"}
check := func(name string, attrs []provider.Attr) {
t.Helper()
var got []string
for _, a := range attrs {
got = append(got, a.Label+"="+a.Value)
}
if !slices.Equal(got, want) {
t.Errorf("%s\n got %q\nwant %q", name, got, want)
}
}
// Resource Graph: the query's columns, as JSON gives them.
c, ok := graphVault(provider.Source{ID: "s"}, map[string]any{
"id": "/x/kv", "name": "kv", "uri": "https://kv.vault.azure.net/", "location": "norwayeast",
"resourceGroup": "rg-sec", "tags": map[string]any{"team": "platform", "env": "prod"},
"sku": "premium", "rbac": true, "softDelete": nil, "retention": 30.0, "purge": true,
"publicAccess": "", "defaultAction": "Deny", "ipRules": 1.0, "subnets": 0.0, "endpoints": 1.0, "policies": 0.0,
})
if !ok {
t.Fatal("row not read")
}
check("resource graph", c.Attrs)
// The management API, when Resource Graph fails.
id := "/subscriptions/s/resourceGroups/rg-sec/providers/Microsoft.KeyVault/vaults/kv"
p, _ := fakeAzure(t, staticCred{}, func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"value": []any{map[string]any{"id": id, "name": "kv", "location": "norwayeast",
"tags": map[string]any{"team": "platform", "env": "prod"},
"properties": map[string]any{
"tenantId": "t", "vaultUri": "https://kv.vault.azure.net/", "sku": map[string]any{"family": "A", "name": "premium"},
"enableRbacAuthorization": true, "enablePurgeProtection": true, "softDeleteRetentionInDays": 30,
"networkAcls": map[string]any{"defaultAction": "Deny", "ipRules": []any{map[string]any{"value": "10.0.0.0/8"}}},
"privateEndpointConnections": []any{map[string]any{"id": "pe1"}},
}}}})
})
vaults, err := p.armVaults(context.Background(), provider.Source{ID: "s"})
if err != nil || len(vaults) != 1 {
t.Fatalf("arm: %v %v", vaults, err)
}
check("management API", vaults[0].Attrs)
}
+29 -1
View File
@@ -11,6 +11,7 @@ import (
"fmt"
"math/big"
"net/http"
"regexp"
"strings"
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
@@ -279,13 +280,24 @@ func cleanError(err error) error {
}
}
}
return &serviceError{status: re.StatusCode, msg: shortenDenied(msg), err: err}
se := &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
// sentences plus caller and resource IDs, to its first sentence and the
// missing action.
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: '"
i := strings.Index(msg, marker)
if i < 0 {
@@ -317,4 +329,20 @@ func (e *serviceError) Error() string {
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 }
+158
View File
@@ -0,0 +1,158 @@
package azure
import (
"fmt"
"slices"
"strings"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// A vault's settings come with the listing, from Resource Graph or the
// Key Vault management API, with no requests of their own: where it is,
// how access to it works, and how it's protected.
type vaultInfo struct {
group, location, sku string
rbac, softDelete, purge *bool
retention *int32
publicAccess, defaultAction string
ipRules, subnets, endpoints int
policies int
tags map[string]string
}
// vaultQuery lists vaults with just the settings shown, not their whole
// properties (access policies can be long).
const vaultQuery = `resources
| where type =~ 'microsoft.keyvault/vaults'
| project id, name, uri = tostring(properties.vaultUri), location, resourceGroup, tags,
sku = tostring(properties.sku.name), rbac = properties.enableRbacAuthorization,
softDelete = properties.enableSoftDelete, retention = properties.softDeleteRetentionInDays,
purge = properties.enablePurgeProtection, publicAccess = tostring(properties.publicNetworkAccess),
defaultAction = tostring(properties.networkAcls.defaultAction),
ipRules = array_length(properties.networkAcls.ipRules),
subnets = array_length(properties.networkAcls.virtualNetworkRules),
endpoints = array_length(properties.privateEndpointConnections),
policies = array_length(properties.accessPolicies)`
// graphInfo reads a vaultQuery row's settings.
func graphInfo(m map[string]any) vaultInfo {
str := func(k string) string { s, _ := m[k].(string); return s }
num := func(k string) int { f, _ := m[k].(float64); return int(f) }
flag := func(k string) *bool {
if b, ok := m[k].(bool); ok {
return &b
}
return nil
}
v := vaultInfo{
group: str("resourceGroup"), location: str("location"), sku: str("sku"),
rbac: flag("rbac"), softDelete: flag("softDelete"), purge: flag("purge"),
publicAccess: str("publicAccess"), defaultAction: str("defaultAction"),
ipRules: num("ipRules"), subnets: num("subnets"), endpoints: num("endpoints"), policies: num("policies"),
}
if f, ok := m["retention"].(float64); ok {
r := int32(f)
v.retention = &r
}
if tags, ok := m["tags"].(map[string]any); ok {
v.tags = map[string]string{}
for k, t := range tags {
v.tags[k] = fmt.Sprint(t)
}
}
return v
}
// armInfo reads a vault's settings from the management API.
func armInfo(v *armkeyvault.Vault) vaultInfo {
info := vaultInfo{location: deref(v.Location), tags: map[string]string{}}
if id, err := arm.ParseResourceID(deref(v.ID)); err == nil {
info.group = id.ResourceGroupName
}
for k, t := range v.Tags {
info.tags[k] = deref(t)
}
pr := v.Properties
if pr == nil {
return info
}
if pr.SKU != nil && pr.SKU.Name != nil {
info.sku = string(*pr.SKU.Name)
}
info.rbac, info.softDelete, info.purge = pr.EnableRbacAuthorization, pr.EnableSoftDelete, pr.EnablePurgeProtection
info.retention, info.publicAccess = pr.SoftDeleteRetentionInDays, deref(pr.PublicNetworkAccess)
if acl := pr.NetworkACLs; acl != nil {
if acl.DefaultAction != nil {
info.defaultAction = string(*acl.DefaultAction)
}
info.ipRules, info.subnets = len(acl.IPRules), len(acl.VirtualNetworkRules)
}
info.endpoints, info.policies = len(pr.PrivateEndpointConnections), len(pr.AccessPolicies)
return info
}
func (v vaultInfo) attrs() []provider.Attr {
var out []provider.Attr
add := func(label, value string) {
if value != "" {
out = append(out, provider.Attr{Label: label, Value: value})
}
}
add("group", v.group)
add("location", v.location)
add("SKU", v.sku)
// Who may read secrets is decided by Azure roles or by the vault's
// own access policies, never both.
if on(v.rbac, false) {
add("access", "Azure roles (RBAC)")
} else {
add("access", fmt.Sprintf("access policies (%d)", v.policies))
}
if on(v.softDelete, true) {
days := int32(90)
if v.retention != nil {
days = *v.retention
}
add("soft delete", fmt.Sprintf("on, %d days", days))
} else {
add("soft delete", "off")
}
add("purge prot.", map[bool]string{true: "on", false: "off"}[on(v.purge, false)])
switch {
case strings.EqualFold(v.publicAccess, "disabled"):
add("network", "private endpoints only")
case strings.EqualFold(v.defaultAction, "deny"):
add("network", fmt.Sprintf("selected networks (%d IP ranges, %d subnets)", v.ipRules, v.subnets))
default:
add("network", "all networks")
}
if v.endpoints > 0 {
add("private", fmt.Sprintf("%d endpoint%s", v.endpoints, map[bool]string{true: "s"}[v.endpoints > 1]))
}
keys := make([]string, 0, len(v.tags))
for k := range v.tags {
keys = append(keys, k)
}
slices.Sort(keys)
for i, k := range keys {
label := ""
if i == 0 {
label = "tags"
}
out = append(out, provider.Attr{Label: label, Value: k + ": " + v.tags[k]})
}
return out
}
// on is *b, or def when it isn't set.
func on(b *bool, def bool) bool {
if b == nil {
return def
}
return *b
}
+43 -3
View File
@@ -317,15 +317,52 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
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")
// combine reports both the Databricks and the Key Vault failure. An auth
// error from either stays reachable with errors.As.
// combine reports both the Databricks and the Key Vault failure, with
// both their checks after. An auth error from either stays reachable
// with errors.As.
func combine(dbxErr, kvErr error) error {
if errors.Is(kvErr, errNoFallback) {
return dbxErr
}
return fmt.Errorf("%w; Key Vault fallback: %w", dbxErr, kvErr)
var checks []string
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
@@ -336,6 +373,9 @@ func classify(pr profile, err error) error {
}
var api *apierr.APIError
if errors.As(err, &api) {
if api.StatusCode == 403 {
return &provider.Denied{Err: err, Checks: deniedChecks(pr, api.Message)}
}
if api.StatusCode != 401 {
return err
}
@@ -140,6 +140,8 @@ func workspaceAPI(t *testing.T, status int) *httptest.Server {
map[string]any{"key": "storage-key", "last_updated_timestamp": 1767225600000},
map[string]any{"key": "client-secret"},
}})
case r.URL.Path == "/api/2.0/secrets/list" && q.Get("scope") == "locked":
write(w, 403, map[string]any{"error_code": "PERMISSION_DENIED", "message": "User does not have READ permission on scope locked"})
case r.URL.Path == "/api/2.0/secrets/get" && q.Get("scope") == "app":
write(w, 200, map[string]any{"key": q.Get("key"),
"value": base64.StdEncoding.EncodeToString([]byte("value-of-" + q.Get("key")))})
@@ -291,3 +293,27 @@ func TestClassify(t *testing.T) {
t.Error("cancellation classified as auth")
}
}
func TestDeniedSaysWhatToCheck(t *testing.T) {
p := newTestProvider(t, workspaceAPI(t, 0), nil)
_, err := p.Objects(context.Background(), provider.Container{Source: provider.Source{ID: "prod"}, Name: "locked"}, provider.TypeSecret)
var d *provider.Denied
// The test profile signs in through az: that's the first check.
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") ||
!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") {
t.Errorf("a token profile's checks %q", got)
}
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") {
t.Errorf("IP ACL checks %q", got)
}
// 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)
}
}
+3 -2
View File
@@ -24,6 +24,7 @@ type Provider struct {
errs map[listKey]error
values map[string]string // by object ID
versions map[string][]provider.Object // by object ID
details map[string][]provider.Attr // by container ID
}
type listKey struct {
@@ -101,7 +102,7 @@ func (p *Provider) Versions(ctx context.Context, o provider.Object) ([]provider.
if err := p.wait(ctx); err != nil {
return nil, err
}
if p.kind == provider.KindDatabricks {
if p.kind != provider.KindAzure { // only Key Vault keeps versions
return nil, provider.ErrUnsupported
}
vs := p.versions[o.ID]
@@ -209,7 +210,7 @@ func (p *Provider) addContainer(src provider.Source, name, detail string, types
if p.kind == provider.KindDatabricks {
vault, _ = strings.CutPrefix(detail, "Azure Key Vault-backed: ")
}
if p.kind == provider.KindAzure || vault != detail {
if p.kind == provider.KindAzure || (p.kind == provider.KindDatabricks && vault != detail) {
c.URL = portal + "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/" + vault + "/overview"
}
p.containers[src.ID] = append(p.containers[src.ID], c)
+136
View File
@@ -0,0 +1,136 @@
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"
}
+28
View File
@@ -12,6 +12,7 @@ import (
"context"
"errors"
"fmt"
"strings"
"time"
)
@@ -93,6 +94,8 @@ type Container struct {
// secrets only.
Types []ObjectType
URL string // web page for the container, if any
// Attrs describe it further, from the listing (a vault's settings).
Attrs []Attr `json:",omitempty"`
}
// ObjectTypes returns the container's object types, defaulting to secrets.
@@ -107,6 +110,7 @@ func (c Container) ObjectTypes() []ObjectType {
type Attr struct {
Label string
Value string
Warn bool // worth a look (a plain .env tracked by git): shown in red
}
// Object describes a secret, key or certificate without its value.
@@ -198,3 +202,27 @@ type Account struct {
// Copy is what y copies on the provider's row, named CopyLabel.
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
// container than its listing does: how a file is encrypted and what git
// knows of it. ErrUnsupported means there's nothing more for c.
type ContainerDetails interface {
Details(ctx context.Context, c Container) ([]Attr, error)
}
+62
View File
@@ -0,0 +1,62 @@
package repo
import (
"context"
"os/exec"
"path/filepath"
"strings"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// Details are how a file is encrypted and for whom, and what git knows of
// it: a plain .env file that git tracks, or could, is marked.
func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provider.Attr, error) {
r, ok := p.repo(c.Source.ID)
if !ok {
return nil, provider.ErrUnsupported
}
rel, err := filepath.Rel(r.root, c.ID)
if err != nil {
return nil, err
}
var out []provider.Attr
meta, isSops := readMeta(c.ID)
if isSops {
out = append(out, provider.Attr{Label: "encryption", Value: "sops · " + strings.Join(meta.methods, ", ")})
for i, k := range meta.recipients {
label := ""
if i == 0 {
label = "keys"
}
out = append(out, provider.Attr{Label: label, Value: k})
}
if !meta.modified.IsZero() {
out = append(out, provider.Attr{Label: "encrypted", Value: meta.modified.Local().Format("2006-01-02 15:04")})
}
} else {
out = append(out, provider.Attr{Label: "encryption", Value: "none: plain text"})
}
git := func(args ...string) (string, bool) {
b, err := exec.CommandContext(ctx, "git", append([]string{"-C", r.root}, args...)...).Output()
return strings.TrimSpace(string(b)), err == nil
}
switch _, tracked := git("ls-files", "--error-unmatch", "--", rel); {
case tracked && !isSops:
out = append(out, provider.Attr{Label: "git", Value: "tracked: its values are in the repo's history", Warn: true})
case tracked:
out = append(out, provider.Attr{Label: "git", Value: "tracked"})
default:
if _, ignored := git("check-ignore", "-q", "--", rel); ignored {
out = append(out, provider.Attr{Label: "git", Value: "ignored"})
} else {
out = append(out, provider.Attr{Label: "git", Value: "untracked and not ignored: easy to commit by mistake", Warn: !isSops})
}
return out, nil
}
if last, ok := git("log", "-1", "--format=%h · %ar", "--", rel); ok && last != "" {
out = append(out, provider.Attr{Label: "last commit", Value: last})
}
return out, nil
}
+61
View File
@@ -816,9 +816,31 @@ func anyMatch(res []*regexp.Regexp, f string) bool {
type sopsMeta struct {
methods []string // age, azure_kv, …
recipients []string // "age age1…", "azure_kv https://kv…/keys/sops"
modified time.Time
}
// recipient names a key a file is encrypted for, from its sops metadata:
// public, never a secret.
func recipient(method string, f map[string]string) string {
var v string
switch method {
case "age":
v = f["recipient"]
case "pgp":
v = f["fp"]
case "kms":
v = f["arn"]
case "gcp_kms":
v = f["resource_id"]
case "azure_kv":
v = strings.TrimSuffix(f["vault_url"], "/") + "/keys/" + f["name"]
case "hc_vault":
v = strings.TrimSuffix(f["vault_address"], "/") + " " + f["engine_path"] + "/" + f["key_name"]
}
return method + " " + v
}
const maxFileSize = 4 << 20
// readMeta reads a file's sops metadata; ok is false if it isn't a sops
@@ -848,6 +870,15 @@ func readMeta(file string) (sopsMeta, bool) {
for _, k := range known {
if v, ok := doc.Sops[k].([]any); ok && len(v) > 0 {
m.methods = append(m.methods, k)
for _, e := range v {
fields := map[string]string{}
if em, ok := e.(map[string]any); ok {
for fk, fv := range em {
fields[fk] = fmt.Sprint(fv)
}
}
m.recipients = append(m.recipients, recipient(k, fields))
}
}
}
if s, ok := doc.Sops["lastmodified"].(string); ok {
@@ -864,6 +895,7 @@ func readMeta(file string) (sopsMeta, bool) {
for _, k := range known {
if strings.Contains(text, "sops_"+k+"__") {
m.methods = append(m.methods, k)
m.recipients = append(m.recipients, envRecipients(text, k)...)
}
}
if i := strings.Index(text, "sops_lastmodified"); i >= 0 {
@@ -881,6 +913,35 @@ func readMeta(file string) (sopsMeta, bool) {
return m, true
}
// envRecipients are method's keys in a dotenv or ini sops file, whose
// metadata is flattened: sops_age__list_0__map_recipient=age1….
func envRecipients(text, method string) []string {
prefix := "sops_" + method + "__list_"
var order []string
keys := map[string]map[string]string{}
for line := range strings.Lines(text) {
k, v, ok := strings.Cut(strings.TrimSpace(line), "=")
rest, found := strings.CutPrefix(strings.TrimSpace(k), prefix)
if !ok || !found {
continue
}
n, field, ok := strings.Cut(rest, "__map_")
if !ok {
continue
}
if keys[n] == nil {
keys[n] = map[string]string{}
order = append(order, n)
}
keys[n][field] = strings.TrimSpace(v)
}
out := make([]string, len(order))
for i, n := range order {
out[i] = recipient(method, keys[n])
}
return out
}
func formatOf(file string) string {
switch strings.ToLower(filepath.Ext(file)) {
case ".yaml", ".yml":
+44
View File
@@ -477,3 +477,47 @@ func TestFile(t *testing.T) {
t.Errorf("lines %v\nwant %v", lines, want)
}
}
func TestFileDetails(t *testing.T) {
root := gitRepo(t, map[string]string{
".gitignore": ".env\n",
"secrets/app.yaml": sopsYAML,
"prod.env": sopsEnv,
"tracked.env": "TOKEN=x\n", // a plain .env in git: worth a look
})
os.WriteFile(filepath.Join(root, ".env"), []byte("TOKEN=x\n"), 0o600)
os.WriteFile(filepath.Join(root, "local.env"), []byte("TOKEN=x\n"), 0o600)
p := New(Settings{Dir: root})
ctx := context.Background()
srcs, _ := p.Sources(ctx)
details := func(name string) map[string]provider.Attr {
t.Helper()
attrs, err := p.Details(ctx, provider.Container{Source: srcs[0], ID: filepath.Join(root, name), Name: name})
if err != nil {
t.Fatal(err)
}
out := map[string]provider.Attr{}
for _, a := range attrs {
out[a.Label] = a
}
return out
}
d := details("secrets/app.yaml")
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 == "" {
t.Errorf("sops yaml: %+v", d)
}
if d := details("prod.env"); d["keys"].Value != "age age1xxxx" {
t.Errorf("sops env: %+v", d)
}
if d := details("tracked.env"); d["encryption"].Value != "none: plain text" || !d["git"].Warn {
t.Errorf("tracked plain env: %+v", d)
}
if d := details(".env"); d["git"].Value != "ignored" || d["git"].Warn {
t.Errorf("ignored env: %+v", d)
}
if d := details("local.env"); !d["git"].Warn || !strings.Contains(d["git"].Value, "not ignored") {
t.Errorf("untracked env: %+v", d)
}
}
+165 -20
View File
@@ -18,7 +18,6 @@ import (
"charm.land/bubbles/v2/spinner"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/browser"
"git.kebcloud.no/keb/lazysecrets/internal/clipboard"
@@ -40,6 +39,9 @@ const (
paneContainers
paneSecrets
paneDetail
// paneStatus is on top of the left panes; after the right pane in
// the tab order (status.go).
paneStatus
paneCount
)
@@ -54,6 +56,7 @@ const (
slotDescribe // extra metadata for the selected object
slotCopy // fetch a value straight to the clipboard
slotVersions // count the selected object's versions
slotDetails // a file's details
slotCount
)
@@ -69,6 +72,12 @@ type Options struct {
ClipboardClear time.Duration
// OpenURL opens a web page; defaults to the system browser.
OpenURL func(url string) error
// ConfigFiles and StatePath are where lazysecrets reads its config
// and keeps its state, for the status pane.
ConfigFiles []string
StatePath string
// Changelog is CHANGELOG.md, for the status pane.
Changelog string
// CacheFile keeps what was loaded between runs; "" keeps it for this
// run only.
CacheFile string
@@ -163,12 +172,26 @@ type Model struct {
clip Clipboard
clipClear time.Duration
openURL func(string) error
// fields is the fourth pane for anything but a secret; lastLeft is
// the left pane it describes while the fourth has focus (fields.go).
fields panes.Fields
lastLeft pane
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
nvimOpen func(server, path string, line int) error // edit.go
now func() time.Time
// The cache file, whether the cache changed since it was saved, and
// when each provider's Sources list was loaded (persist.go).
cachePath string
// configFiles and statePath are for the status pane (status.go).
configFiles []string
statePath string
statusW int // the status pane's width: the left column's, zoomed or not
changelog []panes.Field
cacheDirty bool
sourcesAt map[string]time.Time
placeDir string
@@ -356,6 +379,8 @@ func New(providers []provider.Provider, opts Options) Model {
getenv: os.Getenv,
nvimOpen: nvimOpen,
cache: newCache(),
details: map[string]detailsState{},
fields: panes.NewFields(),
}
if m.clip == nil {
m.clip = clipboard.New()
@@ -389,7 +414,7 @@ func New(providers []provider.Provider, opts Options) Model {
if opts.Keys != nil {
m.keys = *opts.Keys
}
m.detail.RevealKey, m.detail.VersionsKey = keys.Name(m.keys.Reveal), keys.Name(m.keys.Versions)
m.detail.RevealKey, m.detail.VersionsKey, m.detail.CopyKey = keys.Name(m.keys.Reveal), keys.Name(m.keys.Versions), keys.Name(m.keys.Copy)
m.revealTimeout, m.hideOnBlur = opts.RevealTimeout, opts.HideOnBlur
m.expiryWarning = opts.ExpiryWarning
m.handlerSetup = opts.HandlerSetup
@@ -430,10 +455,13 @@ func New(providers []provider.Provider, opts Options) Model {
m.spinning = true
m.cachePath, m.placeDir, m.sourcesAt = opts.CacheFile, opts.Dir, map[string]time.Time{}
m.configFiles, m.statePath, m.changelog = opts.ConfigFiles, opts.StatePath, changelogFields(opts.Changelog)
if m.cachePath != "" {
m.loadCacheFile()
if len(m.sourceResults) > 0 {
m.rebuildSources()
m.sources.Loading = true // reloading what the file had
m.sources.Age = m.cachedAge(m.oldestSources())
}
}
if m.placeDir != "" && opts.Open == nil {
@@ -461,7 +489,7 @@ func (m *Model) setVersions(items []provider.Object) {
}
func (m Model) Init() tea.Cmd {
cmds := []tea.Cmd{m.sourcesCmd(), m.spin.Tick, m.checkClipboard()}
cmds := []tea.Cmd{m.sourcesCmd(), m.spin.Tick, m.checkClipboard(), tea.RequestBackgroundColor}
if m.cachePath != "" || m.placeDir != "" {
cmds = append(cmds, tea.Tick(persistEvery, func(time.Time) tea.Msg { return persistTickMsg{} }))
}
@@ -486,6 +514,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.BackgroundColorMsg:
m.st = m.st.OnBackground(msg.Color, msg.IsDark())
return m, nil
case tea.WindowSizeMsg:
m.w, m.h = msg.Width, msg.Height
@@ -563,6 +594,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case persistedMsg:
m.handlePersisted(msg)
case detailsMsg:
if msg.req == m.reqID[slotDetails] {
m.done(slotDetails)
}
if errors.Is(msg.err, context.Canceled) || errors.Is(msg.err, context.DeadlineExceeded) {
delete(m.details, msg.key) // tried again when shown again
} else {
m.details[msg.key] = detailsState{attrs: msg.attrs, err: msg.err}
}
case crawlMsg:
cmds = append(cmds, m.handleCrawl(msg))
@@ -589,6 +630,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.done(slotContainers)
if msg.err != nil {
m.noteErr(msg.err)
if src, ok := m.selectedSource(); ok {
m.logErrOf(src.Name, msg.err)
}
delete(m.cache.containers, msg.key)
m.containers.SetError(msg.err)
} else {
@@ -607,6 +651,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.closeVersions()
} else if msg.err != nil {
m.noteErr(msg.err)
if c, ok := m.containers.Selected(); ok {
m.logErrOf(c.Name, msg.err)
}
delete(m.cache.objects, msg.key)
m.secrets.SetError(msg.err)
} else {
@@ -629,6 +676,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.done(slotValue)
if msg.err != nil {
m.noteErr(msg.err)
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
m.detail.SetValueError(msg.err)
} else {
m.detail.SetValue(msg.value)
@@ -655,6 +703,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.done(slotDescribe)
if msg.err != nil {
m.noteErr(msg.err)
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
m.detail.SetDescribeError(msg.err)
} else {
m.cache.details[msg.key] = entry[provider.Object]{msg.obj, m.now()}
@@ -722,9 +771,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.waiting = 0
m.askHandlerSetup()
m.keys.Login.SetEnabled(m.loginFor() != nil)
m.detail.SetMessage(m.selectedProblem())
acct, _ := m.selectedAccount()
m.detail.SetAccount(acct.Fields)
if m.focus != paneDetail {
m.lastLeft = m.focus
}
m.logFlash()
cmds = append(cmds, m.syncFields())
if m.anyLoading() && !m.spinning {
m.spinning = true
cmds = append(cmds, m.spin.Tick)
@@ -790,9 +841,19 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
case key.Matches(msg, k.Bottom):
m.jump(false)
case key.Matches(msg, k.NextPane):
m.focus = (m.focus + 1) % paneCount
// The four main panes, round; the status pane (0) leads into them.
m.focus = (m.focus + 1) % paneStatus
if r, ok := m.sources.Selected(); ok && m.focus == paneContainers && r.kind == rowGroup {
m.focus = paneDetail // an account's row: nothing between
}
case key.Matches(msg, k.Details):
m.focus = paneDetail // what's selected, on the right
case key.Matches(msg, k.PrevPane):
m.focus = (m.focus + paneCount - 1) % paneCount
if m.focus == paneDetail {
m.focus = m.lastLeft // back to what it describes
} else {
m.focus = (m.focus + paneStatus - 1) % paneStatus
}
case key.Matches(msg, k.Pane1):
m.focus = paneSources
case key.Matches(msg, k.Pane2):
@@ -801,8 +862,18 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
m.focus = paneSecrets
case key.Matches(msg, k.Pane4):
m.focus = paneDetail
case key.Matches(msg, k.Pane0):
m.focus = paneStatus
case m.focus == paneStatus && key.Matches(msg, k.Enter):
m.focus = paneDetail // its rows are on the right
case m.focus == paneSources && (key.Matches(msg, k.Enter) || key.Matches(msg, k.Reveal)) && m.toggleGroup():
// folded or unfolded a provider group
case m.focus == paneDetail && key.Matches(msg, k.Enter) && m.fieldLink() != "":
r, err := ref.Parse(m.fieldLink())
if err == nil {
f, _ := m.fields.Selected()
m.startNav(r, actGo, f.Row)
}
case key.Matches(msg, k.Enter):
if m.focus < paneDetail {
m.focus++
@@ -858,8 +929,10 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
case key.Matches(msg, k.Back):
if f := m.focusedFilter(); f != nil && f.HasFilter() {
f.ClearFilter()
} else if m.versionsOf != nil && m.focus >= paneSecrets {
} else if m.versionsOf != nil && (m.focus == paneSecrets || m.focus == paneDetail) {
m.closeVersions()
} else if m.focus == paneStatus {
m.focus = paneSources
} else if m.focus > paneSources {
m.focus--
}
@@ -914,6 +987,10 @@ func (m *Model) focusedFilter() filterable {
return &m.containers
case paneSecrets:
return &m.secrets
case paneDetail:
if m.fields.Shown() {
return &m.fields
}
}
return nil
}
@@ -928,9 +1005,13 @@ func (m *Model) move(delta int) {
case paneSecrets:
m.secrets.Move(delta)
case paneDetail:
if m.fields.Shown() {
m.fields.Move(delta)
} else {
m.detail.Scroll(delta)
}
}
}
func (m *Model) jump(top bool) {
switch {
@@ -948,6 +1029,10 @@ func (m *Model) jump(top bool) {
m.secrets.Top()
case m.focus == paneSecrets:
m.secrets.Bottom()
case m.focus == paneDetail && m.fields.Shown() && top:
m.fields.Top()
case m.focus == paneDetail && m.fields.Shown():
m.fields.Bottom()
case m.focus == paneDetail && top:
m.detail.ScrollTop()
case m.focus == paneDetail:
@@ -980,6 +1065,7 @@ func (m *Model) refresh() tea.Cmd {
case paneContainers:
if src, ok := m.selectedSource(); ok {
m.containers.Refresh()
m.details = map[string]detailsState{}
return m.loadContainers(src, m.selSource)
}
case paneSecrets:
@@ -1102,6 +1188,7 @@ func (m *Model) showContainers(src provider.Source, key string) tea.Cmd {
return nil
}
m.containers.Refresh()
m.containers.Age = m.cachedAge(e.at)
return m.loadContainers(src, key)
}
@@ -1118,6 +1205,7 @@ func (m *Model) showObjects(c provider.Container, key string) tea.Cmd {
return nil
}
m.secrets.Refresh()
m.secrets.Age = m.cachedAge(e.at)
return m.loadObjects(c, m.objType, key)
}
@@ -1247,6 +1335,26 @@ func (m *Model) detailStale() bool {
return hasSecret || shown != m.containers.SelectedID()
}
// cachedAge is how old a list loaded at at is, if it's older than
// cacheTTL: the panes say it while it reloads.
func (m Model) cachedAge(at time.Time) string {
if d := m.now().Sub(at); d >= cacheTTL {
return ago(d)
}
return ""
}
// oldestSources is when the oldest of the cached Sources lists loaded.
func (m Model) oldestSources() time.Time {
var oldest time.Time
for _, at := range m.sourcesAt {
if oldest.IsZero() || at.Before(oldest) {
oldest = at
}
}
return oldest
}
func (m *Model) handleSources(msg sourcesMsg) tea.Cmd {
if msg.gen != m.sourcesGen {
return nil
@@ -1261,6 +1369,7 @@ func (m *Model) handleSources(msg sourcesMsg) tea.Cmd {
for _, p := range m.providers {
if r, ok := m.sourceResults[p.ID()]; ok && r.err != nil {
m.noteErr(r.err)
m.logErrOf(p.Label(), r.err)
errs = append(errs, fmt.Sprintf("%s: %v", p.Label(), r.err))
}
}
@@ -1453,11 +1562,17 @@ func (m *Model) describe(o provider.Object) tea.Cmd {
// copyValue copies the selected object's value, fetching it without
// revealing it if it isn't shown already.
func (m *Model) copyValue() tea.Cmd {
o, ok := m.detail.Secret()
if !ok {
if m.focus == paneDetail && m.fields.Shown() {
if f, ok := m.fields.Selected(); ok {
return m.copyText(fieldName(f), cmpOr(f.URL, f.Value), false)
}
return nil
}
if a, ok := m.selectedAccount(); ok && a.Copy != "" && m.focus == paneSources {
return m.copyText(a.CopyLabel, a.Copy, false)
}
o, ok := m.detail.Secret()
if !ok {
return nil
}
if v, ok := m.detail.Value(); ok {
@@ -1510,6 +1625,25 @@ func (m *Model) cancelClipClear() {
func (m *Model) openSelected() tea.Cmd {
var name, url string
var used func()
switch {
case m.focus == paneDetail && m.fields.Shown():
f, _ := m.fields.Selected()
if f.URL == "" {
m.flash = warning("nothing to open in the browser here")
return nil
}
name, url, used = f.Label, f.URL, func() {}
default:
name, url, used = m.openTarget()
}
if name == "" {
return nil
}
return m.openPage(name, url, used)
}
// openTarget is the focused pane's selected item, for the browser.
func (m *Model) openTarget() (name, url string, used func()) {
switch m.focus {
case paneSources:
s, _ := m.selectedSource()
@@ -1521,9 +1655,11 @@ func (m *Model) openSelected() tea.Cmd {
o, _ := m.detail.Secret()
name, url, used = o.Name, o.URL, func() { m.usedObject(o) }
}
if name == "" {
return nil
return name, url, used
}
// openPage opens url in the browser, or copies it over SSH.
func (m *Model) openPage(name, url string, used func()) tea.Cmd {
if url == "" {
m.flash = warning("no web page for " + name)
return nil
@@ -1583,7 +1719,8 @@ func (m Model) footerView() string {
} else if l != nil && l.Filtering() {
help = m.helpBar(keys.FilterHelp{Map: m.keys}.ShortHelp(), 2)
} else {
help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, commands: len(m.commandsHere()) > 0}.ShortHelp(), 2)
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(), revealed: &revealed}.ShortHelp(), 2)
}
var lines []string
if !m.clipDeadline.IsZero() {
@@ -1593,12 +1730,6 @@ func (m Model) footerView() string {
if m.flash.text != "" {
lines = append(lines, m.flashLine(m.flash))
}
// The version at the right end, one column in, like lazygit.
if v := m.versionLabel(); v != "" {
up := m.updateLabel() // a newer release, in colour
gap := max(1, m.w-1-ansi.StringWidth(help)-ansi.StringWidth(v+up)-1)
help += strings.Repeat(" ", gap) + m.st.Muted.Render(v) + m.st.Notice.Render(up) + " "
}
return strings.Join(append(lines, " "+help), "\n")
}
@@ -1665,9 +1796,23 @@ func (m Model) problemText(providerID, err string) string {
return err
}
// fieldLink is the link of the selected Fields row, if it has one.
func (m Model) fieldLink() string {
if f, ok := m.fields.Selected(); ok && m.fields.Shown() {
return f.Link
}
return ""
}
// loginFor is the sign-in L runs: the selected account's, if it needs
// one, else the last one asked for.
func (m Model) loginFor() *provider.AuthError {
if f, ok := m.fields.Selected(); ok && m.focus == paneDetail && m.fields.Shown() && f.Provider != "" {
var auth *provider.AuthError
if res := m.sourceResults[f.Provider]; errors.As(res.err, &auth) && len(auth.Login) > 0 {
return auth
}
}
if r, ok := m.sources.Selected(); ok {
var auth *provider.AuthError
if res := m.sourceResults[r.provider]; errors.As(res.err, &auth) && len(auth.Login) > 0 {
+254 -11
View File
@@ -19,6 +19,7 @@ import (
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
)
func TestMain(m *testing.M) {
@@ -57,7 +58,7 @@ func newDriverOpts(t *testing.T, opts Options, providers ...provider.Provider) *
m := New(providers, opts)
m.detail.Now = func() time.Time { return time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) }
d := &driver{t: t, m: m}
d.send(tea.WindowSizeMsg{Width: 100, Height: 30})
d.send(tea.WindowSizeMsg{Width: 100, Height: 33}) // 30 for the main panes, 3 for status
d.run(m.Init())
return d
}
@@ -185,8 +186,9 @@ func TestIcons(t *testing.T) {
if !strings.Contains(screen, azure+" Azure") || !strings.Contains(screen, dbx+" Databricks") {
t.Errorf("icons missing:\n%s", screen)
}
if !strings.Contains(screen, "source "+azure+" Azure / platform-prod") {
t.Errorf("detail source line missing icon:\n%s", screen)
d.keys("3")
if !strings.Contains(d.screen(), "source "+azure+" Azure / platform-prod") {
t.Errorf("detail source line missing icon:\n%s", d.screen())
}
}
@@ -204,7 +206,7 @@ func TestObjectTypeTabs(t *testing.T) {
t.Fatalf("selected = %q", s)
}
screen := d.screen()
for _, want := range []string{"key type RSA 2048", "ops sign, verify", "space to show public key"} {
for _, want := range []string{"key type RSA 2048", "ops sign, verify", "space to show the public key"} {
if !strings.Contains(screen, want) {
t.Errorf("missing %q:\n%s", want, screen)
}
@@ -400,6 +402,7 @@ func (describing) Describe(_ context.Context, o provider.Object) (provider.Objec
func TestDescribeUpdatesDetail(t *testing.T) {
t.Parallel()
d := newDriver(t, describing{fake.Azure(0)})
d.keys("3") // the fourth pane follows focus: the secret
if !strings.Contains(d.screen(), "described db-password") {
t.Fatalf("describe result not shown:\n%s", d.screen())
}
@@ -487,10 +490,11 @@ func TestVersions(t *testing.T) {
func TestVersionHistoryInDetail(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("3") // the fourth pane follows focus: the secret
screen := d.screen()
for _, want := range []string{
"versions 3 · v to browse",
"▸ 3f9c2a 2026-09-01 current", // the version shown above
" 3f9c2a 2026-09-01 current",
" 91d3e0 2026-03-01 expired",
" 0a77c4 2025-09-01 disabled",
} {
@@ -498,6 +502,9 @@ func TestVersionHistoryInDetail(t *testing.T) {
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
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())
@@ -594,7 +601,7 @@ func TestPermissionErrorShownInPane(t *testing.T) {
if !strings.Contains(d.screen(), "403 Forbidden") {
t.Error("error not rendered")
}
if !strings.Contains(d.screen(), "Select a secret") {
if !strings.Contains(d.screen(), "name kv-restricted") {
t.Error("detail should show the container summary")
}
}
@@ -754,7 +761,9 @@ func TestFocusKeys(t *testing.T) {
}{
{"tab", paneContainers}, {"enter", paneSecrets}, {"enter", paneDetail},
{"enter", paneDetail}, {"esc", paneSecrets}, {"shift+tab", paneContainers},
{"4", paneDetail}, {"l", paneSources}, {"h", paneDetail},
{"4", paneDetail}, {"tab", paneSources}, {"shift+tab", paneDetail},
// l: what's selected, on the right; h: back to it.
{"2", paneContainers}, {"l", paneDetail}, {"h", paneContainers}, {"h", paneSources}, {"l", paneDetail},
} {
d.keys(tc.key)
if d.m.focus != tc.want {
@@ -979,12 +988,17 @@ func TestRebindingShowsEverywhere(t *testing.T) {
d := newDriverOpts(t, Options{Keys: &km})
d.keys("3")
scr := d.screen()
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)
}
if !strings.Contains(lastLine(scr), "z reveal/hide") {
if !strings.Contains(lastLine(scr), "z show") {
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") {
t.Errorf("hint for unbound versions:\n%s", scr)
}
@@ -1037,7 +1051,7 @@ func TestSignInMessageOnceThenInDetails(t *testing.T) {
t.Parallel()
d := newDriverOpts(t, Options{CollapseGroups: false}, fake.Azure(0), signedOut{fake.Azure(0)})
hint := "Azure · admin: sign-in required"
if !strings.Contains(lastLines(d.screen(), 2), hint) {
if !strings.Contains(lastLines(d.screen(), 2), hint) { // the footer: one line
t.Fatalf("not said at start:\n%s", d.screen())
}
d.keys("j")
@@ -1050,7 +1064,7 @@ func TestSignInMessageOnceThenInDetails(t *testing.T) {
if r, _ := d.m.sources.Selected(); r.provider != "Azure:admin@contoso.com/t1" {
t.Fatalf("selected %+v", r)
}
if !strings.Contains(d.screen(), hint) || !strings.Contains(d.screen(), "press L to run az login --tenant t1") {
if scr := d.screen(); !strings.Contains(scr, "problem") || !strings.Contains(scr, hint) || !strings.Contains(scr, "press L to run az login") {
t.Errorf("not in Details:\n%s", d.screen())
}
if a := d.m.loginFor(); a == nil || a.Account != "Azure · admin" {
@@ -1099,3 +1113,232 @@ func TestAccountInDetails(t *testing.T) {
t.Errorf("still shown on a subscription:\n%s", d.screen())
}
}
func TestAccountPane(t *testing.T) {
t.Parallel()
d := newDriver(t, described{fake.Azure(0)})
d.keys("g", "l") // the account's row, then its pane
if d.m.focus != paneDetail {
t.Fatalf("focus %v, want the fourth pane", d.m.focus)
}
scr := d.screen()
if !strings.Contains(scr, "Alice Example") {
t.Errorf("account not shown:\n%s", scr)
}
bar := lastLine(scr)
if !strings.Contains(bar, "copy") || strings.Contains(bar, "reveal") || strings.Contains(bar, "versions") {
t.Errorf("help bar: %q", bar)
}
d.keys("j", "y")
if got := d.clip().content; got != "oid-123" {
t.Errorf("y on the object ID copied %q", got)
}
// / filters it, labels too.
d.keys("/")
d.typeText("object")
d.keys("enter")
if scr := d.screen(); !strings.Contains(scr, "oid-123") || strings.Contains(scr, "Alice Example") {
t.Errorf("filtered:\n%s", scr)
}
d.keys("esc")
if !strings.Contains(d.screen(), "Alice Example") {
t.Errorf("filter not cleared:\n%s", d.screen())
}
d.keys("h")
if d.m.focus != paneSources {
t.Errorf("back to %v, want Sources", d.m.focus)
}
}
func TestDetailsFollowFocus(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("3") // a secret
if !strings.Contains(d.screen(), "version 3f9c2a") {
t.Fatalf("secret not shown:\n%s", d.screen())
}
d.keys("2") // its vault
if scr := d.screen(); !strings.Contains(scr, "name kv-app-prod") || strings.Contains(scr, "version 3f9c2a") {
t.Errorf("vault not described:\n%s", scr)
}
d.keys("1") // its subscription
if scr := d.screen(); !strings.Contains(scr, "name platform-prod") || !strings.Contains(scr, "vaults 3") {
t.Errorf("subscription not described:\n%s", scr)
}
// Into the fourth pane: it keeps describing the subscription, and
// shift+tab goes back there.
d.keys("4")
if !strings.Contains(d.screen(), "name platform-prod") {
t.Errorf("lost the subscription:\n%s", d.screen())
}
d.keys("shift+tab")
if d.m.focus != paneSources {
t.Errorf("back to %v", d.m.focus)
}
// A value revealed from a left pane stays in view until hidden.
d.keys("space")
if !strings.Contains(d.screen(), "correct-horse-battery-staple") {
t.Errorf("value not shown:\n%s", d.screen())
}
}
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) {
t.Parallel()
var opened []string
d := newDriverOpts(t, Options{Version: "0.1.0", ConfigFiles: []string{"/cfg/config.yaml"}, StatePath: "/state/state.json",
OpenURL: func(u string) error { opened = append(opened, u); return nil }},
fake.Azure(0), signedOut{fake.Azure(0)})
if !strings.Contains(d.screen(), "2 accounts · 1 need sign-in") {
t.Errorf("status line:\n%s", d.screen())
}
d.keys("0")
if d.m.focus != paneStatus {
t.Fatalf("focus %v", d.m.focus)
}
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",
"/cfg/config.yaml", "/state/state.json", "lazysecrets/…/docs"} {
if !strings.Contains(scr, want) {
t.Errorf("missing %q:\n%s", want, scr)
}
}
// Into its rows: L signs in to the failing account, y copies a path,
// o opens a link.
d.keys("enter")
if d.m.focus != paneDetail {
t.Fatalf("enter: focus %v", d.m.focus)
}
d.m.fields.SelectWhere(func(f panes.Field) bool { return f.Provider == "Azure:admin@contoso.com/t1" })
if a := d.m.loginFor(); a == nil || a.Account != "Azure · admin" {
t.Errorf("L signs in to %+v", a)
}
d.m.fields.SelectWhere(func(f panes.Field) bool { return f.Label == "config" })
d.keys("y")
if got := d.clip().content; got != "/cfg/config.yaml" {
t.Errorf("y copied %q", got)
}
d.m.fields.SelectWhere(func(f panes.Field) bool { return f.Label == "docs" })
d.keys("o")
if len(opened) != 1 || !strings.HasSuffix(opened[0], "/src/branch/main/docs") {
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")
if d.m.focus != paneStatus {
t.Errorf("back to %v", d.m.focus)
}
}
// empty is an account with nothing in it, like Databricks without profiles.
type empty struct{ provider.Provider }
func (empty) Sources(context.Context) ([]provider.Source, error) { return nil, nil }
func TestGetStarted(t *testing.T) {
t.Parallel()
d := newDriverOpts(t, Options{}, signedOut{fake.Azure(0)}, empty{fake.Databricks(0)})
scr := d.screen()
for _, want := range []string{"get started", "Azure az login", "L runs it here",
"databricks auth login --host", "lazysecrets --demo"} {
if !strings.Contains(scr, want) {
t.Errorf("missing %q:\n%s", want, scr)
}
}
d.keys("l", "j")
if a := d.m.loginFor(); a == nil || a.Account != "Azure · admin" {
t.Errorf("L signs in to %+v", a)
}
d.keys("y")
if got := d.clip().content; got != "az login" {
t.Errorf("y copied %q", got)
}
// Once an account has something, an empty one says what it needs.
d = newDriverOpts(t, Options{}, fake.Azure(0), empty{fake.Databricks(0)})
d.m.sources.SelectWhere(func(r sourceRow) bool { return r.kind == rowGroup && r.provider == fake.Databricks(0).ID() })
d.send(nil)
if scr := d.screen(); strings.Contains(scr, "get started") || !strings.Contains(scr, "databricks auth login") {
t.Errorf("empty Databricks:\n%s", scr)
}
}
// detailed is an account whose vaults have details.
type detailed struct{ provider.Provider }
func (detailed) Details(_ context.Context, c provider.Container) ([]provider.Attr, error) {
return []provider.Attr{{Label: "access", Value: "Azure roles (RBAC)"}, {Label: "git", Value: "tracked", Warn: true}}, nil
}
func TestContainerDetails(t *testing.T) {
t.Parallel()
d := newDriverOpts(t, Options{}, detailed{fake.Azure(0)})
d.keys("2")
if scr := d.screen(); !strings.Contains(scr, "access Azure roles (RBAC)") || !strings.Contains(scr, "URI") {
t.Errorf("details:\n%s", scr)
}
if st := d.m.details[d.m.containers.SelectedID()]; st.loading || len(st.attrs) != 2 {
t.Errorf("state %+v", st)
}
}
func TestExpiringInStatus(t *testing.T) {
t.Parallel()
d := newDriverOpts(t, Options{ExpiryWarning: 30 * 24 * time.Hour}, fake.Azure(0))
d.keys("3", "]", "]") // load kv-app-prod's certificates
if !strings.Contains(d.screen(), "⚠ 1 expiring") {
t.Errorf("status line:\n%s", d.screen())
}
d.keys("0")
scr := d.screen()
for _, want := range []string{"expiring ✗ expired old-tls · kv-app-prod", "in 1 of 3 vaults (ctrl+a in search loads all)"} {
if !strings.Contains(scr, want) {
t.Errorf("missing %q:\n%s", want, scr)
}
}
d.keys("3", "[", "[", "0", "enter") // elsewhere, then to it from the status pane
d.m.fields.SelectWhere(func(f panes.Field) bool { return f.Label == "expiring" })
d.keys("enter")
if _, v, o := d.selected(); v != "kv-app-prod" || o != "old-tls" {
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)
}
}
+3
View File
@@ -53,6 +53,9 @@ func paneContext(p pane) string {
// commandsHere are the custom commands for the focused pane.
func (m *Model) commandsHere() []commands.Command {
if m.focus == paneStatus {
return nil
}
var out []commands.Command
for _, c := range m.commands {
if c.Context == commands.Global || c.Context == paneContext(m.focus) {
+40 -24
View File
@@ -32,6 +32,15 @@ const sopsUnchanged = 200
// edit opens the selected file, or the file of the selected secret.
func (m *Model) edit() tea.Cmd {
if m.focus == paneDetail && m.fields.Shown() {
f, ok := m.fields.Selected()
if !ok || f.Path == "" {
m.flash = warning("nothing to open in an editor here")
return nil
}
name := filepath.Base(f.Path)
return m.openInEditor(provider.File{Path: f.Path}, func(err error) tea.Msg { return editDoneMsg{name: name, err: err} })
}
var c provider.Container
var o *provider.Object
switch m.focus {
@@ -81,30 +90,7 @@ func (m *Model) edit() tea.Cmd {
m.flash = flash{}
return tea.ExecProcess(cmd, finished)
}
if server := m.getenv("NVIM"); server != "" {
open := m.nvimOpen
return func() tea.Msg {
if err := open(server, f.Path, f.Line); err != nil {
return finished(err)
}
return tea.QuitMsg{}
}
}
if editor := cmpOr(m.getenv("VISUAL"), m.getenv("EDITOR")); editor != "" {
// Through the shell, so EDITOR can carry arguments ("code -w").
args := append([]string{"-c", editor + ` "$@"`, "lazysecrets"}, editorArgs(editor, f)...)
m.flash = flash{}
return tea.ExecProcess(exec.Command("/bin/sh", args...), finished)
}
switch err := m.openURL(f.Path); {
case errors.Is(err, browser.ErrRemote):
m.flash = warning("set $EDITOR to open files over SSH")
case err != nil:
m.flash = flash{text: "open: " + err.Error(), isErr: true}
default:
m.flash = done("opened " + name)
}
return nil
return m.openInEditor(f, finished)
}
// plusLine are editors that open at a line with +N before the file.
@@ -206,3 +192,33 @@ func nvimOpen(server, path string, line int) error {
}
return nil
}
// openInEditor opens f: in the nvim lazysecrets runs in, quitting; else
// in $VISUAL or $EDITOR, coming back after; else in the default app.
func (m *Model) openInEditor(f provider.File, finished func(error) tea.Msg) tea.Cmd {
name := filepath.Base(f.Path)
if server := m.getenv("NVIM"); server != "" {
open := m.nvimOpen
return func() tea.Msg {
if err := open(server, f.Path, f.Line); err != nil {
return finished(err)
}
return tea.QuitMsg{}
}
}
if editor := cmpOr(m.getenv("VISUAL"), m.getenv("EDITOR")); editor != "" {
// Through the shell, so EDITOR can carry arguments ("code -w").
args := append([]string{"-c", editor + ` "$@"`, "lazysecrets"}, editorArgs(editor, f)...)
m.flash = flash{}
return tea.ExecProcess(exec.Command("/bin/sh", args...), finished)
}
switch err := m.openURL(f.Path); {
case errors.Is(err, browser.ErrRemote):
m.flash = warning("set $EDITOR to open files over SSH")
case err != nil:
m.flash = flash{text: "open: " + err.Error(), isErr: true}
default:
m.flash = done("opened " + name)
}
return nil
}
+64
View File
@@ -0,0 +1,64 @@
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
}
+75
View File
@@ -0,0 +1,75 @@
package app
import (
"cmp"
"fmt"
"slices"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
)
// The status pane lists what has expired or will soon, across every vault
// loaded (opened, searched, or from the cache): expiry dates come with
// the lists, so this makes no requests. enter on one goes to it.
type expiringItem struct {
o provider.Object
row string // its source's row key, to go to it
}
// expiringItems are the loaded objects that have expired or will within
// the warning period, soonest first.
func (m Model) expiringItems() []expiringItem {
var out []expiringItem
for _, p := range m.providers {
for _, src := range m.sourceResults[p.ID()].sources {
row := sourceRowKey(p.ID(), src.ID)
for _, c := range m.cache.containers[row].value {
for _, t := range c.ObjectTypes() {
for _, o := range m.cache.objects[containerKey(c)+"|"+string(t)].value {
if state, _ := m.expiry(o); state != notExpiring {
out = append(out, expiringItem{o, row})
}
}
}
}
}
}
slices.SortFunc(out, func(a, b expiringItem) int {
return cmp.Or(a.o.Expires.Compare(b.o.Expires), cmp.Compare(a.o.Name, b.o.Name))
})
return out
}
// expiringFields are the status pane's rows for them, and how much of
// everything they cover.
func (m Model) expiringFields() []panes.Field {
items := m.expiringItems()
var out []panes.Field
for _, it := range items {
state, left := m.expiry(it.o)
mark := "⚠ " + daysLeft(left)
if state == expired {
mark = "✗ expired"
}
f := panes.Field{Value: mark + " " + it.o.Name + " · " + it.o.Container.Name, Err: state == expired, Row: it.row}
if r, err := ref.ForObject(it.o, ""); err == nil {
f.Link = r.String()
}
out = append(out, f)
}
cov := m.coverageOf(ofKind(provider.KindAzure))
switch {
case cov.vaults == 0 && len(items) == 0:
return nil
case !cov.complete():
out = append(out, panes.Field{Value: fmt.Sprintf("in %d of %d vaults", cov.vaultsLoaded, cov.vaults) +
withKey(m.keys.SearchLoadAll, " (%s in search loads all)"), Info: true})
case len(items) == 0:
out = append(out, panes.Field{Value: fmt.Sprintf("nothing within %d days", int(m.expiryWarning.Hours()/24)), Info: true})
}
out[0].Label = "expiring"
return out
}
+3 -2
View File
@@ -421,8 +421,9 @@ func TestStarIsPerAccount(t *testing.T) {
d.keys("n")
d.typeText("shared note")
d.keys("enter")
if n := strings.Count(d.screen(), "shared note"); n != 2 {
t.Errorf("note shown %d times, want 2:\n%s", n, d.screen())
// On both accounts' rows, and in the selected one's details.
if n := strings.Count(d.screen(), "shared note"); n != 3 {
t.Errorf("note shown %d times, want 3:\n%s", n, d.screen())
}
d.keys("F")
var labels []string
+224
View File
@@ -0,0 +1,224 @@
package app
import (
"errors"
"fmt"
"strings"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
)
// The fourth pane shows Fields for anything but a secret. On a provider's
// row: its account, from what the provider knows locally.
// A file's details beyond its listing load once a
// 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.
type detailsState struct {
attrs []provider.Attr
err error
loading bool
}
type detailsMsg struct {
req int
key string
attrs []provider.Attr
err error
}
// detailOf is the pane the fourth pane describes: the one in focus, or,
// with the fourth in focus, the one it was entered from.
func (m Model) detailOf() pane {
if m.focus != paneDetail {
return m.focus
}
return m.lastLeft
}
// 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
// the secret pane shows, as it does a revealed value whatever the focus.
func (m *Model) syncFields() tea.Cmd {
if m.detail.State() != panes.ValueHidden {
// A value revealed (or being fetched) from any pane stays in
// view until it's hidden.
m.fields.Hide()
return nil
}
m.fields.Header, m.fields.HeaderShort = nil, ""
switch m.detailOf() {
case paneSources:
r, ok := m.sources.Selected()
switch {
case m.nothingFound():
m.fields.Show("get started", m.getStartedFields())
case !ok:
m.fields.Show("", nil)
case m.selectedProblem() != "":
m.fields.Show(cmpOr(r.label, r.source.Name), m.problemFields(m.selectedProblem()))
case r.kind == rowGroup:
m.showAccount(r)
case r.kind == rowSource:
m.fields.Show(r.source.Name, m.sourceFields(r.source))
default:
m.fields.Show("", nil)
}
case paneStatus:
m.fields.Header, m.fields.HeaderShort = logo, strings.TrimSpace("lazysecrets "+m.versionLabel())
m.fields.Show("lazysecrets", m.statusFields())
case paneContainers:
if c, ok := m.containers.Selected(); ok {
cmd := m.loadDetails(c)
m.fields.Show(c.Name, m.containerFields(c))
return cmd
} else {
m.fields.Show("", nil)
}
default:
m.fields.Hide()
}
return nil
}
// showAccount shows a provider's account.
func (m *Model) showAccount(r sourceRow) {
p := m.byID[r.provider]
var fields []panes.Field
if a, ok := p.(provider.Accounts); ok {
if acct, ok := a.Account(); ok {
for _, f := range acct.Fields {
fields = append(fields, panes.Field{Label: f.Label, Value: f.Value})
}
}
}
if res, done := m.sourceResults[r.provider]; done && res.err == nil && len(res.sources) == 0 {
hint := emptyHint(p.Kind())
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)
}
// sourceFields describe a subscription, workspace or repo.
func (m Model) sourceFields(src provider.Source) []panes.Field {
var out []panes.Field
add := func(label, value string) {
if value != "" {
out = append(out, panes.Field{Label: label, Value: value})
}
}
// Azure's detail is "subscription <ID>": the ID row says it.
id, detail := "ID", ""
switch src.Kind {
case provider.KindDatabricks:
id, detail = "profile", "host"
case provider.KindRepo:
id, detail = "path", "remote"
}
add("name", src.Name)
add("note", m.store.Node(sourceKey(src)).Note)
if src.ID != src.Name {
add(id, src.ID)
}
if detail != "" {
add(detail, src.Detail)
}
if src.ProviderLabel != string(src.Kind) {
add("account", src.ProviderLabel)
}
add("web", src.URL)
if e, ok := m.cache.containers[sourceRowKey(src.Provider, src.ID)]; ok {
label := map[provider.Kind]string{provider.KindDatabricks: "scopes", provider.KindRepo: "files"}[src.Kind]
add(cmpOr(label, "vaults"), fmt.Sprint(len(e.value)))
}
return out
}
// containerFields describe a vault, scope or file.
func (m Model) containerFields(c provider.Container) []panes.Field {
var out []panes.Field
add := func(label, value string) {
if value != "" {
out = append(out, panes.Field{Label: label, Value: value})
}
}
add("name", c.Name)
add("note", m.store.Node(storeKey(c)).Note)
// The vault's URI, the scope's backend, the file's format.
switch c.Source.Kind {
case provider.KindAzure:
add("URI", c.Detail)
case provider.KindDatabricks:
add("backend", c.Detail)
case provider.KindRepo:
add("format", c.Detail)
}
add("source", cmpOr(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
add("web", c.URL)
for _, t := range c.ObjectTypes() {
if e, ok := m.cache.objects[containerKey(c)+"|"+string(t)]; ok {
add(strings.ToLower(string(t))+"s", fmt.Sprint(len(e.value)))
}
}
for _, a := range c.Attrs {
out = append(out, panes.Field{Label: a.Label, Value: a.Value, Err: a.Warn})
}
switch st := m.details[containerKey(c)]; {
case st.loading:
out = append(out, panes.Field{Label: "details", Value: "loading…", Info: true})
case errors.Is(st.err, provider.ErrUnsupported):
case st.err != nil:
out = append(out, panes.Field{Label: "details", Value: "couldn't load them: " + st.err.Error(), Info: true, Err: true})
}
for _, a := range m.details[containerKey(c)].attrs {
out = append(out, panes.Field{Label: a.Label, Value: a.Value, Err: a.Warn})
}
return out
}
// loadDetails loads c's details, if its provider has them and they
// aren't loaded yet.
func (m *Model) loadDetails(c provider.Container) tea.Cmd {
d, ok := m.providerFor(c.Source).(provider.ContainerDetails)
key := containerKey(c)
if _, seen := m.details[key]; !ok || seen {
return nil
}
// One at a time: the previous one, if it's still loading, is
// cancelled and forgotten, to load when it's shown again.
for k, st := range m.details {
if st.loading {
delete(m.details, k)
}
}
m.details[key] = detailsState{loading: true}
ctx, req := m.begin(slotDetails)
return m.settled(ctx, func() tea.Msg {
attrs, err := d.Details(ctx, c)
if ctx.Err() != nil {
err = ctx.Err()
}
return detailsMsg{req: req, key: key, attrs: attrs, err: err}
})
}
// problemFields show why an account has nothing to show, wrapped to the
// pane, in red.
func (m Model) problemFields(problem string) []panes.Field {
var out []panes.Field
for _, l := range strings.Split(ansi.Wrap(problem, max(20, m.fields.Width()-16), " "), "\n") {
out = append(out, panes.Field{Value: l, Info: true, Err: true})
}
out[0].Label = "problem"
return out
}
// fieldName names a copied field: its label, or a row of several under
// one label (a tag), its value.
func fieldName(f panes.Field) string {
return cmpOr(f.Label, f.Value)
}
+66
View File
@@ -0,0 +1,66 @@
package app
import (
"errors"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
"git.kebcloud.no/keb/lazysecrets/internal/update"
)
// When no account has anything to show, as on a first run, the right
// pane says how to get started: the sign-in for each kind of account
// (y copies it, L runs az login), and the demo.
// nothingFound reports whether every account has loaded, with nothing.
func (m Model) nothingFound() bool {
if m.sourcesPending > 0 {
return false
}
for _, p := range m.providers {
if len(m.sourceResults[p.ID()].sources) > 0 {
return false
}
}
return true
}
// getStartedFields are the rows saying how to get started.
func (m Model) getStartedFields() []panes.Field {
out := []panes.Field{{Value: "Nothing to show yet: sign in where your secrets are.", Info: true}}
seen := map[provider.Kind]bool{}
for _, p := range m.providers {
if seen[p.Kind()] {
continue
}
seen[p.Kind()] = true
f := emptyHint(p.Kind())
var auth *provider.AuthError
switch err := m.sourceResults[p.ID()].err; {
case errors.As(err, &auth) && len(auth.Login) > 0:
f.Provider = p.ID() // L signs in
out = append(out, f, panes.Field{Value: withKey(m.keys.Login, "%s runs it here"), Info: true})
case err != nil:
out = append(out, f, panes.Field{Value: err.Error(), Info: true, Err: true})
default:
out = append(out, f)
}
}
return append(out,
panes.Field{Label: "try", Value: "lazysecrets --demo"},
panes.Field{Value: "built-in sample data, to look around", Info: true},
panes.Field{Label: "docs", Value: "getting started", URL: update.DefaultRepo + "#get-started"},
)
}
// emptyHint is how an account of kind gets something to show.
func emptyHint(kind provider.Kind) panes.Field {
switch kind {
case provider.KindDatabricks:
return panes.Field{Label: "Databricks", Value: "databricks auth login --host https://<workspace>"}
case provider.KindRepo:
return panes.Field{Label: "repos", Value: "SOPS and .env files in your git repos",
URL: update.DefaultRepo + "/src/branch/main/docs/repos.md"}
}
return panes.Field{Label: string(kind), Value: "az login"}
}
+25 -4
View File
@@ -36,6 +36,12 @@ type contextHelp struct {
k keys.Map
focus pane
commands bool // custom commands apply here
account bool // the fourth pane shows Fields (fields.go)
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 {
@@ -44,13 +50,28 @@ func (c contextHelp) ShortHelp() []key.Binding {
if k.Login.Enabled() {
b = append(b, k.Login)
}
copyField := key.NewBinding(key.WithKeys(k.Copy.Keys()...), key.WithHelp(k.Copy.Help().Key, "copy"))
switch {
case c.focus == paneStatus:
details := key.NewBinding(key.WithKeys(k.Enter.Keys()...), key.WithHelp(k.Enter.Help().Key, "details"))
return append(b, details, k.Update, k.Help, k.Quit)
case c.focus == paneDetail && c.account && c.of == paneStatus:
open := key.NewBinding(key.WithKeys(k.Open.Keys()...), key.WithHelp(k.Open.Help().Key, "open link"))
return append(b, k.Up, k.Down, copyField, open, k.Edit, k.Filter, k.Help, k.Quit)
case c.focus == paneDetail && c.account:
return append(b, k.Up, k.Down, copyField, k.Filter, k.Help, k.Quit)
}
switch c.focus {
case paneSources:
b = append(b, k.Enter, k.Search, k.Share, k.ShareMenu, k.Favourite, k.Favourites, k.Note, k.Filter)
b = append(b, k.Enter, k.Search, k.Share, k.ShareMenu, k.Favourite, k.Favourites, k.Filter)
case paneContainers:
b = append(b, k.Enter, k.Share, k.ShareMenu, k.Favourite, k.Note, k.Open, k.Filter)
b = append(b, k.Enter, k.Share, k.ShareMenu, k.Favourite, k.Open, k.Filter)
default:
b = append(b, k.Reveal, k.Copy, k.Share, k.ShareMenu, k.Versions, k.Favourite, k.Note, k.Filter)
reveal := k.Reveal
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 {
b = append(b, k.Commands)
@@ -60,7 +81,7 @@ func (c contextHelp) ShortHelp() []key.Binding {
func (c contextHelp) FullHelp() [][]key.Binding { return [][]key.Binding{c.ShortHelp()} }
var paneNames = [paneCount]string{"Sources", "Vaults", "Secrets", "Detail"}
var paneNames = [paneCount]string{"Sources", "Vaults", "Secrets", "Detail", "Status"}
// showKeybindings opens the ? menu, like lazygit's: every binding with
// what it does, the focused pane's first, filterable by key, description
+2 -2
View File
@@ -147,8 +147,8 @@ func sourceMatches(r ref.Ref, src provider.Source) bool {
return src.ID == r.Subscription
}
// accountOf is the account part of a source's provider label ("dev" in
// "Azure · dev"), or "" when there's a single account.
// accountOf is the account part of a source's provider label ("alice" in
// "Azure · alice"), or "" when there's a single account.
func accountOf(src provider.Source) string {
account, ok := strings.CutPrefix(src.ProviderLabel, string(src.Kind)+" · ")
if !ok {
+22 -9
View File
@@ -101,7 +101,7 @@ func (m *Model) resizeFocused(delta int) bool {
if m.focus > paneSecrets {
return false
}
avail := m.leftAvail()
avail := m.listAvail()
h := m.leftHeights(avail)
i := int(m.focus)
j := i + 1
@@ -128,6 +128,9 @@ func (m *Model) saveLayout() {
func (m Model) leftAvail() int { return m.h - lipgloss.Height(m.footerView()) }
// listAvail is the rows the three list panes share, under the status pane.
func (m Model) listAvail() int { return m.leftAvail() - statusH }
func (m *Model) cycleZoom(delta int) {
m.zoom = (m.zoom + zoom(delta) + zoomCount) % zoomCount
}
@@ -138,22 +141,22 @@ func (m *Model) layout() {
}
m.help.SetWidth(max(0, m.w-1)) // one column in, like messages
avail := m.leftAvail()
leftW, h := m.leftWidth(), m.leftHeights(avail)
leftW, h := m.leftWidth(), m.leftHeights(avail-statusH)
switch m.zoom {
case zoomHalf:
if m.focus == paneDetail {
if m.focus == paneDetail || m.focus == paneStatus {
leftW = minLeftW
} else {
leftW = max(m.leftWidth(), m.w/2)
h = [3]int{tinyPaneH, tinyPaneH, tinyPaneH}
h[m.focus] = avail
h = fitHeights(h, avail, tinyPaneH)
h[m.focus] = avail - statusH
h = fitHeights(h, avail-statusH, tinyPaneH)
}
case zoomFull:
// Only the focused pane is drawn; it gets the whole screen.
switch m.focus {
case paneDetail:
case paneDetail, paneStatus:
leftW = 0
default:
leftW = m.w
@@ -162,10 +165,12 @@ func (m *Model) layout() {
}
}
m.statusW = leftW
m.sources.SetSize(leftW, h[0])
m.containers.SetSize(leftW, h[1])
m.secrets.SetSize(leftW, h[2])
m.detail.SetSize(m.w-leftW, avail)
m.fields.SetSize(m.w-leftW, avail)
}
// active reports whether p is drawn focused: it has focus and the terminal
@@ -175,15 +180,23 @@ func (m Model) active(p pane) bool { return m.focus == p && !m.termBlurred }
func (m Model) panesView() string {
spin := m.spin.View()
views := [paneCount]func() string{
views := [paneStatus]func() string{
func() string { return m.sources.View(m.st, m.active(paneSources), spin) },
func() string { return m.containers.View(m.st, m.active(paneContainers), spin) },
func() string { return m.secrets.View(m.st, m.active(paneSecrets), spin) },
func() string { return m.detail.View(m.st, m.active(paneDetail), spin) },
func() string {
if m.fields.Shown() {
return m.fields.View(m.st, m.active(paneDetail), spin)
}
return m.detail.View(m.st, m.active(paneDetail), spin)
},
}
if m.zoom == zoomFull {
if m.focus == paneStatus {
return views[paneDetail]() // its rows
}
return views[m.focus]()
}
left := lipgloss.JoinVertical(lipgloss.Left, views[0](), views[1](), views[2]())
left := lipgloss.JoinVertical(lipgloss.Left, m.statusView(), views[0](), views[1](), views[2]())
return lipgloss.JoinHorizontal(lipgloss.Top, left, views[3]())
}
+7 -8
View File
@@ -136,11 +136,11 @@ func TestLayoutIsSaved(t *testing.T) {
func TestContextHelpBar(t *testing.T) {
t.Parallel()
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 reveal") {
if bar := lastLine(d.screen()); !strings.Contains(bar, "S share") || !strings.HasSuffix(strings.TrimSpace(bar), "? help • q quit") || strings.Contains(bar, "space show") {
t.Errorf("Sources help bar: %q", bar)
}
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)
}
}
@@ -258,14 +258,13 @@ func TestNoHighlightWhileTerminalBlurred(t *testing.T) {
}
}
func TestVersionBottomRight(t *testing.T) {
func TestVersionInStatus(t *testing.T) {
t.Parallel()
d := newDriverOpts(t, Options{Version: "0.1.0"})
bar := lastLine(d.screen())
if !strings.HasSuffix(bar, "v0.1.0 ") || ansi.StringWidth(bar) != 100 {
t.Errorf("help line %q (%d wide)", bar, ansi.StringWidth(bar))
if !strings.Contains(d.screen(), "[0] lazysecrets v0.1.0") {
t.Errorf("no version in the status pane:\n%s", d.screen())
}
if !strings.Contains(bar, "? help • q quit") {
t.Errorf("help squeezed out by the version: %q", bar)
if bar := lastLine(d.screen()); strings.Contains(bar, "v0.1.0") || !strings.Contains(bar, "? help • q quit") {
t.Errorf("help line %q", bar)
}
}
+1 -1
View File
@@ -42,7 +42,7 @@ func TestNotesOnRows(t *testing.T) {
d.note("main prod")
screen := d.screen()
for _, want := range []string{"stripe live", "app secrets", "main prod", "note stripe live"} {
for _, want := range []string{"stripe live", "app secrets", "main prod", "note main prod"} {
if !strings.Contains(screen, want) {
t.Errorf("missing %q:\n%s", want, screen)
}
+1 -2
View File
@@ -3,7 +3,6 @@ package app
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
@@ -244,6 +243,6 @@ func (m *Model) restorePlace() {
func (m *Model) handlePersisted(msg persistedMsg) {
if msg.err != nil {
m.noteErr(fmt.Errorf("saving the cache or place: %w", msg.err))
m.logErrOf("saving the cache or place", msg.err)
}
}
+26
View File
@@ -39,6 +39,9 @@ func TestCacheFileShowsLastRunAtOnce(t *testing.T) {
// Next run, with a backend too slow to answer during the test: the
// lists show from the file, and the cursor is where it was.
d2 := newDriverOpts(t, Options{CacheFile: file}, fake.Azure(time.Minute))
if strings.Contains(d2.screen(), "cached") {
t.Errorf("a fresh cache said its age:\n%s", d2.screen())
}
d2.keys("2")
if _, v, _ := d2.selected(); v != vault {
t.Errorf("vaults from the cache: selected %q, want %q\n%s", v, vault, d2.screen())
@@ -49,6 +52,29 @@ func TestCacheFileShowsLastRunAtOnce(t *testing.T) {
}
}
// Lists from an older cache say how old they are while they reload.
func TestCacheFileSaysItsAge(t *testing.T) {
t.Parallel()
file := filepath.Join(t.TempDir(), "cache.json")
d := newDriverOpts(t, Options{CacheFile: file}, fake.Azure(0))
d.keys("3")
at := time.Now().Add(-3 * time.Hour)
for k, e := range d.m.cache.containers {
d.m.cache.containers[k] = entry[[]provider.Container]{e.value, at}
}
for id := range d.m.sourcesAt {
d.m.sourcesAt[id] = at
}
if err := d.m.Persist(); err != nil {
t.Fatal(err)
}
d2 := newDriverOpts(t, Options{CacheFile: file}, fake.Azure(time.Minute))
d2.keys("2")
if n := strings.Count(d2.screen(), "cached 3h ago"); n != 2 {
t.Errorf("Sources and Vaults should say they're from 3h ago (%d do):\n%s", n, d2.screen())
}
}
func TestCacheFileDropsOldAndSignedOut(t *testing.T) {
t.Parallel()
file := filepath.Join(t.TempDir(), "cache.json")
+3 -3
View File
@@ -206,9 +206,9 @@ func TestSearchAllRepos(t *testing.T) {
for d.m.search.src > 0 {
d.send(tea.KeyPressMsg{Code: tea.KeyUp})
}
for d.m.search.scope().id != kindScope+string(provider.KindRepo) {
for d.m.search.scope().id != accountScope+"Repos" {
if d.m.search.src == len(d.m.search.scopes)-1 {
t.Fatalf("no All repos scope: %v", d.m.search.scopes)
t.Fatalf("no Repos scope: %v", d.m.search.scopes)
}
d.send(tea.KeyPressMsg{Code: tea.KeyDown})
}
@@ -235,7 +235,7 @@ func TestSearchAllRepos(t *testing.T) {
if azure() != azureBefore || d.m.crawl.running {
t.Errorf("searching all repos loaded Azure: %d → %d lists", azureBefore, azure())
}
if !strings.Contains(d.screen(), "Results in all repos") {
if !strings.Contains(d.screen(), "Results in Repos") {
t.Errorf("title:\n%s", d.screen())
}
}
+20 -8
View File
@@ -131,13 +131,25 @@ func (c coverage) complete() bool {
return c.sourcesListed == c.sources && c.vaultsLoaded == c.vaults
}
func (m *Model) searchCoverage() coverage { return m.coverageOf("") }
func (m *Model) searchCoverage() coverage { return m.coverageOf(nil) }
// coverageOf is searchCoverage for one kind of provider ("" for all).
func (m *Model) coverageOf(kind provider.Kind) coverage {
// providerFilter picks the providers a search or a load covers; nil is
// all of them.
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
for _, p := range m.providers {
if kind != "" && p.Kind() != kind {
if in != nil && !in(p) {
continue
}
for _, src := range m.sourceResults[p.ID()].sources {
@@ -197,12 +209,12 @@ func plural(n int, word string) string {
// startCrawl loads every unlisted subscription's vaults and every vault's
// secrets not loaded yet.
func (m *Model) startCrawl() tea.Cmd { return m.startCrawlOf("") }
func (m *Model) startCrawl() tea.Cmd { return m.startCrawlOf(nil) }
// startCrawlOf is startCrawl for one kind of provider ("" for all).
func (m *Model) startCrawlOf(kind provider.Kind) tea.Cmd {
// startCrawlOf is startCrawl for the providers in.
func (m *Model) startCrawlOf(in providerFilter) tea.Cmd {
for _, p := range m.providers {
if kind != "" && p.Kind() != kind {
if in != nil && !in(p) {
continue
}
for _, src := range m.sourceResults[p.ID()].sources {
+76 -14
View File
@@ -42,11 +42,15 @@ func TestSearchLoaded(t *testing.T) {
t.Fatalf("missing %q:\n%s", want, d.screen())
}
}
d.typeText("api-key")
if !strings.Contains(d.screen(), "no matches here · All searches everything") {
d.typeText("api-key") // on All: found at once
if !strings.Contains(d.screen(), "ctrl+s only platform-dev") {
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())
}
d.searchAll()
d.send(ctrl('s'))
if !strings.Contains(d.screen(), "platform-prod / kv-app-prod / api-key") {
t.Fatalf("loaded secret not found:\n%s", d.screen())
}
@@ -62,7 +66,8 @@ func TestSearchLoaded(t *testing.T) {
func TestSearchLoadsAsYouType(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.openSearch() // on the source the cursor is on: platform-prod
d.openSearch()
d.send(ctrl('s')) // only the source the cursor is on: platform-prod
// Typing loads the scope: that source, without ctrl+a.
d.typeText("smtp")
if !strings.Contains(d.screen(), "platform-prod / kv-shared-prod / smtp-password") {
@@ -76,10 +81,7 @@ func TestSearchLoadsAsYouType(t *testing.T) {
}
// On All: everything.
d.keys("tab") // to the scopes
for d.m.search.src > 0 {
d.send(tea.KeyPressMsg{Code: tea.KeyUp})
}
d.send(ctrl('s'))
d.typeText("-")
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())
@@ -183,19 +185,61 @@ func TestSearchStartsWhereYouAre(t *testing.T) {
d.keys("2", "j") // platform-prod / kv-shared-prod
d.openSearch()
v := d.m.search
if got := ansiStrip(v.scopes[v.src].label); !strings.Contains(got, "platform-prod") {
t.Errorf("source not preselected: %q", got)
scope := func() string { return ansiStrip(v.scopes[v.src].label) }
if scope() != "All" {
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") {
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 the Sources pane: just the source.
d.keys("esc", "esc", "1", "j") // stop typing, close; platform-dev
// From Favourites: to Search, on All.
d.keys("esc", "]", "]") // stop typing; Favourites
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()
v = d.m.search
if got := ansiStrip(v.scopes[v.src].label); !strings.Contains(got, "platform-dev") {
t.Errorf("source not preselected: %q", got)
d.send(ctrl('s'))
if !strings.Contains(scope(), "platform-dev") {
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))
}
}
@@ -239,6 +283,24 @@ func TestGoToTabsAndModes(t *testing.T) {
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.typeText("shared")
if !v.typing || len(v.shown) == 0 {
+139 -62
View File
@@ -20,16 +20,17 @@ import (
)
// Go to is one popup for finding a place and going there. Tabs at the
// top: Favourites and Recent (from the store, so they work before
// anything loads) are one list each; Search has two lists side by side,
// the scope on the left (All, or one source with how much of it is
// loaded: ○ ◐ ● ◌ ✗) and what's in it on the right.
// top: Search has two lists side by side, the scope on the left (All, or
// one source with how much of it is loaded: ○ ◐ ● ◌ ✗) and what's in it
// on the right; Favourites and Recent (from the store, so they work
// before anything loads) are one list each.
//
// 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
// 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,
// typing.
// typing, on All. search again narrows to the source you were in, and
// back to All.
//
// 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
@@ -39,7 +40,7 @@ import (
type searchView struct {
input textinput.Model
typing bool // the text field has the keyboard
tab int // tabFavs, tabRecent or tabSearch
tab int // tabSearch, tabFavs or tabRecent
results bool // in Search, the results have the cursor (else the scopes)
scopes []option // All, then the sources (Search)
@@ -49,6 +50,7 @@ type searchView struct {
shown []option // the tab's (or scope's) entries matching the input
src int // cursor in scopes
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:
// typing loads the scope once, so lists that fail aren't asked for
@@ -57,25 +59,25 @@ type searchView struct {
}
const (
tabFavs = iota
tabSearch = iota
tabFavs
tabRecent
tabSearch
tabCount
)
var tabNames = [tabCount]string{"Favourites", "Recent", "Search"}
var tabNames = [tabCount]string{"Search", "Favourites", "Recent"}
const (
scopeFavs = "#favs"
scopeRecent = "#recent"
allSources = "#all"
kindScope = "#kind:" // + a provider kind: all of Azure, Databricks or repos
accountScope = "#account:" // + a provider ID: an account's sources, or Databricks', or the repos
)
const goToTitle = "Go to"
// showSearch opens Go to on Search, typing, in the source you're in
// (else All), with the selected vault under the cursor.
// showSearch opens Go to on Search, typing, on All, with the selected
// vault under the cursor.
func (m *Model) showSearch() tea.Cmd {
cmd := m.openGoTo(tabSearch, true)
m.preselect()
@@ -131,17 +133,20 @@ func (v *searchView) scope() option {
// onSource reports whether the results come from one source.
func (v *searchView) onSource() bool { return v.tab == tabSearch && v.scope().group != "" }
// preselect starts where the user is: the selected source picked on the
// left, and the selected vault or scope under the cursor on the right.
// preselect starts where the user is: the selected source is the one
// search narrows to, and the selected vault or scope is under the cursor.
func (m *Model) preselect() {
v := m.search
row := m.sources.SelectedID()
i := slices.IndexFunc(v.scopes, func(o option) bool { return o.group != "" && o.group == row })
account := ""
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 {
return
}
v.src = i
m.fillSearch(false)
v.here = v.scopes[i].id
if m.focus == paneSources {
return
}
@@ -171,25 +176,36 @@ func (m *Model) fillSearch(keep bool) {
}
sources, items := m.searchEntries()
all := option{label: "All", id: allSources, mark: m.coverageMark("")}
scopes := []option{all}
// All of one kind, so a search can stay on repos (local, quick)
// without asking Azure for every vault; only with several kinds.
var kinds []provider.Kind
all := option{label: "All", id: allSources, mark: m.coverageMark(nil)}
// 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 && !slices.Contains(kinds, p.Kind()) {
kinds = append(kinds, p.Kind())
if len(m.sourceResults[p.ID()].sources) > 0 {
groups = append(groups, p)
}
}
if len(kinds) > 1 {
for _, k := range kinds {
scopes := []option{all}
if len(groups) < 2 {
scopes = append(scopes, sources...)
} else {
for _, p := range groups {
k := p.Kind()
scopes = append(scopes, option{
label: m.st.Kind(k).Render(m.icons.Prefix(k) + kindScopeLabel(k)),
id: kindScope + string(k), kind: k, mark: m.coverageMark(k),
label: m.st.Kind(k).Render(m.icons.Prefix(k) + p.Label()),
id: accountScope + p.ID(), kind: k, mark: m.coverageMark(ofProvider(p.ID())),
})
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.shown = v.match()
@@ -202,17 +218,9 @@ func (m *Model) fillSearch(keep bool) {
v.res = max(0, min(v.res, len(v.shown)-1))
}
// kindScopeLabel names the scope of all of a kind's sources.
func kindScopeLabel(k provider.Kind) string {
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); {
// coverageMark is ○ ◐ ● for how much of the providers in is loaded.
func (m *Model) coverageMark(in providerFilter) string {
switch c := m.coverageOf(in); {
case c.complete():
return m.st.Success.Render("●")
case c.vaultsLoaded > 0:
@@ -221,15 +229,28 @@ func (m *Model) coverageMark(kind provider.Kind) string {
return m.st.Muted.Render("○")
}
// scopeKind is the kind a scope covers all of, if it's one of those.
func (v *searchView) scopeKind() (provider.Kind, bool) {
k, ok := strings.CutPrefix(v.scope().id, kindScope)
// scopeAccount is the provider whose sources the scope covers, if it's
// an account's.
func (v *searchView) scopeAccount() (string, bool) {
id, ok := strings.CutPrefix(v.scope().id, accountScope)
if !ok || v.tab != tabSearch {
return "", false
}
return provider.Kind(k), true
return id, 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.
func (v *searchView) match() []option {
var cand []option
@@ -239,9 +260,9 @@ func (v *searchView) match() []option {
case scopeRecent:
cand = v.recent
default:
kind, ofKind := v.scopeKind()
account, ofAccount := v.scopeAccount()
for _, o := range v.items {
if sc.id == allSources || ofKind && o.kind == kind || sc.group != "" && o.group == sc.group {
if sc.id == allSources || ofAccount && providerOf(o.group) == account || sc.group != "" && o.group == sc.group {
cand = append(cand, o)
}
}
@@ -320,6 +341,9 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd {
// 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 }
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 {
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) {
@@ -338,6 +362,22 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd {
return nil
case key.Matches(msg, km.GoFilter):
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):
return m.showKeybindingsFor(keys.GoTo)
case key.Matches(msg, km.GoNextTab), key.Matches(msg, km.GoPrevTab):
@@ -401,11 +441,9 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd {
if v.onSource() {
return v.scope().reload(m)
}
kind, _ := v.scopeKind()
return m.startCrawlOf(kind)
return m.startCrawlOf(v.scopeFilter())
case inSearch && key.Matches(msg, k.loadAll):
kind, _ := v.scopeKind()
return m.startCrawlOf(kind)
return m.startCrawlOf(v.scopeFilter())
case key.Matches(msg, k.star):
if o, ok := v.selected(); ok && o.star != "" {
m.toggleStarKey(o.star, o.starAccount)
@@ -436,6 +474,22 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.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
// load-all key: everything on All, else the source picked. Only what
// isn't loaded yet, once a popup; repos load by themselves anyway.
@@ -452,8 +506,8 @@ func (m *Model) searchLoads() tea.Cmd {
v.loaded = map[string]bool{}
}
v.loaded[sc.id] = true
if kind, ok := v.scopeKind(); ok {
return m.startCrawlOf(kind)
if _, ok := v.scopeAccount(); ok {
return m.startCrawlOf(v.scopeFilter())
}
if !v.onSource() {
return m.startCrawl()
@@ -497,6 +551,35 @@ func (m *Model) toggleStarKey(key, account string) {
// searchHint tops the results when what they come from isn't all
// loaded, saying how to load it.
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
if m.crawl.running {
return m.st.Muted.Render(fmt.Sprintf("◌ loading… %d lists left", len(m.crawl.queue)+m.crawl.active))
@@ -504,8 +587,8 @@ func (m Model) searchHint() string {
if v.tab != tabSearch {
return "" // from the store: nothing to load
}
if kind, ok := v.scopeKind(); ok || v.scope().id == allSources {
c := m.coverageOf(kind)
if _, ok := v.scopeAccount(); ok || v.scope().id == allSources {
c := m.coverageOf(v.scopeFilter())
if c.complete() {
return ""
}
@@ -637,14 +720,8 @@ func (m Model) searchViewBox() string {
}
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]
if v.onSource() {
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"
}
if _, ok := v.scopeAccount(); ok || v.onSource() {
resultsTitle = "Results in " + scopeName(v.scope())
}
var lists string
if v.tab == tabSearch {
+204
View File
@@ -0,0 +1,204 @@
package app
import (
"errors"
"fmt"
"strings"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
"git.kebcloud.no/keb/lazysecrets/internal/update"
)
// The status pane, like lazygit's: a line on top of the left panes with
// the version, a newer release and how the accounts are doing. Focused
// (0), the right pane has the rest: updates, each account, the files
// 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.
const statusH = 3
// statusLine is what the status pane says while it isn't focused.
func (m Model) statusLine() string {
var parts []string
switch {
case m.updated != "":
parts = append(parts, m.st.Notice.Render("→ "+m.updated+" (restart)"))
case m.newer != nil:
parts = append(parts, m.st.Notice.Render("↑ "+m.newer.Tag+withKey(m.keys.Update, " %s")))
}
accounts, failing, repos := 0, 0, 0
for _, p := range m.providers {
if p.Kind() == provider.KindRepo {
repos += len(m.sourceResults[p.ID()].sources) // those with secret files
continue
}
accounts++
if r, ok := m.sourceResults[p.ID()]; ok && r.err != nil {
failing++
}
}
if n := len(m.expiringItems()); n > 0 {
parts = append(parts, m.st.Warning.Render(fmt.Sprintf("⚠ %d expiring", n)))
}
if accounts > 0 {
s := plural(accounts, "account")
if failing > 0 {
s += " · " + m.st.Error.Render(fmt.Sprintf("%d need sign-in", failing))
}
parts = append(parts, s)
}
if repos > 0 {
parts = append(parts, plural(repos, "repo"))
}
return " " + strings.Join(parts, m.st.Muted.Render(" · "))
}
func (m Model) statusView() string {
title := "[0] lazysecrets"
if v := m.versionLabel(); v != "" {
title += " " + v
}
return panes.Box(m.st, panes.TitleStyle(m.st, m.active(paneStatus)).Render(title), "",
[]string{m.statusLine()}, m.statusW, statusH, m.active(paneStatus))
}
// statusFields are the status pane's rows on the right.
func (m Model) statusFields() []panes.Field {
var out []panes.Field
add := func(f panes.Field) {
if f.Value != "" {
out = append(out, f)
}
}
// A release's rows open its release page.
version := panes.Field{Label: "version", Value: cmpOr(m.versionLabel(), "dev")}
if strings.HasPrefix(version.Value, "v") {
version.URL = update.DefaultRepo + "/releases/tag/" + version.Value
}
add(version)
upd := panes.Field{Label: "update", Value: m.updateStatus(), Info: m.newer == nil}
if m.newer != nil {
upd.URL = update.DefaultRepo + "/releases/tag/" + m.newer.Tag
}
add(upd)
add(panes.Field{Label: "license", Value: "GPL-3.0-or-later · © 2026 Karl-Einar Bergstrøm", Info: true})
for i, p := range m.providers {
f := panes.Field{Value: p.Label() + " · " + m.accountStatus(p), Provider: p.ID()}
if i == 0 {
f.Label = "accounts"
}
var auth *provider.AuthError
f.Err = errors.As(m.sourceResults[p.ID()].err, &auth)
add(f)
}
out = append(out, m.expiringFields()...)
out = append(out, m.errorFields()...)
for i, path := range m.configFiles {
label := ""
if i == 0 {
label = "config"
}
add(panes.Field{Label: label, Value: path, Path: path})
}
add(panes.Field{Label: "state", Value: m.statePath, Path: m.statePath})
add(panes.Field{Label: "cache", Value: m.cachePath, Path: m.cachePath})
// Short, without https://; y copies and o opens the whole URL.
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: "source", Value: repo, URL: update.DefaultRepo})
// Issues are on the GitHub mirror.
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: "changelog", Value: repo + "/…/CHANGELOG.md", URL: update.DefaultRepo + "/src/branch/main/CHANGELOG.md"})
return append(out, m.changelog...)
}
// updateStatus says whether a newer release is out, and when it was
// last checked.
func (m Model) updateStatus() string {
switch {
case m.updated != "":
return "updated to " + m.updated + ": restart to use it"
case m.newer != nil:
return m.newer.Tag + " is out" + withKey(m.keys.Update, " · %s updates")
}
at := m.store.UpdateCheck().At
if at.IsZero() {
return ""
}
return "up to date · checked " + ago(m.now().Sub(at))
}
// accountStatus says whether an account's sources loaded.
func (m Model) accountStatus(p provider.Provider) string {
r, done := m.sourceResults[p.ID()]
var auth *provider.AuthError
switch {
case !done:
return "loading…"
case errors.As(r.err, &auth) && len(auth.Login) > 0:
return "sign-in required" + withKey(m.keys.Login, " · %s signs in")
case r.err != nil:
return r.err.Error()
}
noun := map[provider.Kind]string{provider.KindAzure: "subscription", provider.KindDatabricks: "workspace", provider.KindRepo: "repo"}[p.Kind()]
return plural(len(r.sources), cmpOr(noun, "source"))
}
// ago is a duration as people say it: "just now", "5m ago", "2h ago".
func ago(d time.Duration) string {
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%dm ago", int(d.Minutes()))
case d < 48*time.Hour:
return fmt.Sprintf("%dh ago", int(d.Hours()))
}
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
}
// changelogFields are CHANGELOG.md as rows: each release's version and
// date, then its entries, marked by what kind they are.
func changelogFields(md string) []panes.Field {
var out []panes.Field
kind := ""
for _, line := range strings.Split(md, "\n") {
line = strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "## "):
version, date, _ := strings.Cut(strings.TrimPrefix(line, "## "), " – ")
out = append(out, panes.Field{Label: version, Value: date, Info: true})
kind = ""
case strings.HasPrefix(line, "### "):
kind = strings.ToLower(strings.TrimPrefix(line, "### "))
case strings.HasPrefix(line, "- ") && len(out) > 0:
text := update.PlainLinks(strings.TrimPrefix(line, "- "))
if kind != "" {
text = kind + ": " + text
}
out = append(out, panes.Field{Value: text})
case line != "" && len(out) > 0 && !strings.HasPrefix(line, "<!--"):
// A release's own words ("Hello world 👋").
out = append(out, panes.Field{Value: update.PlainLinks(line)})
}
}
return out
}
+30
View File
@@ -0,0 +1,30 @@
package app
import (
"testing"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
)
func TestChangelogFields(t *testing.T) {
t.Parallel()
md := "# Changelog\n\nEvery release of lazysecrets, newest first.\n\n## v0.2.0 – 2026-10-12\n\n### Added\n\n" +
"- Search ([#11](https://x/pulls/11)) ([65dc2d7](https://x/commit/65dc2d7))\n\n### Fixed\n\n- A fix (#3)\n\n" +
"## v0.1.0 – 2026-10-10\n\nHello world 👋\n\n<!-- generated by git-cliff (cliff.toml) -->\n"
got := changelogFields(md)
want := []panes.Field{
{Label: "v0.2.0", Value: "2026-10-12", Info: true},
{Value: "added: Search (#11) (65dc2d7)"},
{Value: "fixed: A fix (#3)"},
{Label: "v0.1.0", Value: "2026-10-10", Info: true},
{Value: "Hello world 👋"},
}
if len(got) != len(want) {
t.Fatalf("got %+v", got)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("row %d: %+v, want %+v", i, got[i], want[i])
}
}
}
+22 -19
View File
@@ -1,22 +1,25 @@
╭─ [1] Sources - Favourites ────╮╭─ Secret ────────────────────────────────────────────────────────╮
│ ▾ Azure (2) ││ name db-password │
│ platform-prod ││ vault kv-app-prod │
│ platform-dev ││ source Azure / platform-prod │
│ ▾ Databricks (2) ││ version 3f9c2a │
│ prod ││ enabled yes │
╰────────────────────── 2 of 6 ─╯│ updated 2026-09-01 09:30 │
╭─ [2] Vaults ──────────────────╮│ expires 2027-09-01 09:30 │
│ kv-app-prod ││ content text/plain │
│ kv-shared-prod ││ tags env=prod, owner=data │
│ kv-restricted ││ ref @Microsoft.KeyVault(SecretUri=https://kv-app-prod.vau│
│ ││ lt.azure.net/secrets/db-password) │
╭─ [0] lazysecrets ─────────────╮╭─ platform-prod ─────────────────────────────────────────────────╮
│ 2 accounts ││ name platform-prod │
╰───────────────────────────────╯│ ID sub-prod │
╭─ [1] Sources - Favourites ────╮│ web https://portal.azure.com/#resource/subscriptions/sub…│
│ ▾ Azure (2) ││ vaults 3 │
│ platform-prod ││ │
│ platform-dev ││ │
│ ▾ Databricks (2) ││ │
│ prod ││ │
╰────────────────────── 2 of 6 ─╯│ │
╭─ [2] Vaults ──────────────────╮│ │
│ kv-app-prod ││ │
│ kv-shared-prod ││ │
│ kv-restricted ││ │
│ ││ │
│ ││ value •••••••• (space to reveal) │
│ ││ │
╰────────────────────── 1 of 3 ─╯│ versions 3 · v to browse │
╭─ [3] Secrets - Keys - Certs ──╮│ ▸ 3f9c2a 2026-09-01 current │
│ db-password ││ 91d3e0 2026-03-01 expired │
│ api-key ││ 0a77c4 2025-09-01 disabled │
│ ││ │
│ ││ │
╰────────────────────── 1 of 3 ─╯│ │
╭─ [3] Secrets - Keys - Certs ──╮│ │
│ db-password ││ │
│ api-key ││ │
│ legacy-token (disabled) ││ │
│ ││ │
│ ││ │
@@ -26,5 +29,5 @@
│ ││ │
│ ││ │
│ ││ │
╰────────────────────── 1 of 3 ─╯╰─────────────────────────────────────────────────────────────────╯
enter open • ctrl+s search • s copy link • S share… • f star • F favourites • … • ? help • q quit
╰────────────────────── 1 of 3 ─╯╰──────────────────────────────────────────────────────── 1 of 4 ─╯
enter open • ctrl+s search • s copy link • S share • f star • F favourites • … • ? help • q quit
-12
View File
@@ -155,15 +155,3 @@ func (m *Model) handleInstalled(msg installedMsg) {
m.updated = msg.release.Tag
m.flash = done("updated to lazysecrets " + msg.release.Tag + " · restart lazysecrets to use it")
}
// updateLabel is the version's suffix in the footer: a newer release to
// update to, or the one installed and waiting for a restart.
func (m Model) updateLabel() string {
switch {
case m.updated != "":
return " → " + m.updated + " (restart)"
case m.newer != nil:
return " ↑ " + m.newer.Tag + withKey(m.keys.Update, " %s")
}
return ""
}
+2 -2
View File
@@ -47,8 +47,8 @@ func TestUpdate(t *testing.T) {
if f.asked != 1 {
t.Fatalf("asked %d times at startup", f.asked)
}
if !strings.Contains(d.screen(), "v0.1.1 ↑ v0.2.0 U") {
t.Errorf("no update in the footer:\n%s", d.screen())
if scr := d.screen(); !strings.Contains(scr, "[0] lazysecrets v0.1.1") || !strings.Contains(scr, "↑ v0.2.0 U") {
t.Errorf("no update in the status pane:\n%s", scr)
}
// U: the changelog, without the install notes; u installs.
+15 -10
View File
@@ -36,8 +36,8 @@ const (
type Map struct {
// lists
Up, Down, Top, Bottom key.Binding
NextPane, PrevPane key.Binding
Pane1, Pane2, Pane3, Pane4 key.Binding
NextPane, PrevPane, Details key.Binding
Pane0, Pane1, Pane2, Pane3, Pane4 key.Binding
NextTab, PrevTab key.Binding
Enter, Back, Filter key.Binding
Reveal, Copy, CopyRef, Open, Edit key.Binding
@@ -61,7 +61,8 @@ type Map struct {
SearchSources, SearchResults, SearchSwitch key.Binding
SearchLoad, SearchLoadAll key.Binding
// goto
GoDown, GoUp, GoPrevTab, GoNextTab key.Binding
GoDown, GoUp, GoTop, GoBottom key.Binding
GoPrevTab, GoNextTab key.Binding
GoSources, GoResults, GoSwitch key.Binding
GoStar, GoLoad, GoLoadAll, GoFilter key.Binding
GoSelect, GoClose key.Binding
@@ -87,11 +88,13 @@ func (m *Map) Actions() []Action {
a(Lists, "top", &m.Top, "top", "move", "Jump to the top of the focused list."),
a(Lists, "bottom", &m.Bottom, "bottom", "move", "Jump to the bottom of the focused list."),
a(Lists, "nextPane", &m.NextPane, "next pane", "move", "Focus the next pane: Sources, Vaults, Secrets, detail."),
a(Lists, "prevPane", &m.PrevPane, "prev pane", "move", "Focus the previous pane."),
a(Lists, "prevPane", &m.PrevPane, "prev pane", "move", "Focus the previous pane; from the detail pane, the one it describes."),
a(Lists, "details", &m.Details, "details", "move", "Focus the detail pane on the right, on what's selected: a source, vault, file or secret."),
a(Lists, "pane1", &m.Pane1, "Sources pane", "move", "Focus the Sources pane."),
a(Lists, "pane2", &m.Pane2, "Vaults pane", "move", "Focus the Vaults (or Scopes) pane."),
a(Lists, "pane3", &m.Pane3, "Secrets pane", "move", "Focus the Secrets pane."),
a(Lists, "pane4", &m.Pane4, "detail pane", "move", "Focus the detail pane."),
a(Lists, "pane0", &m.Pane0, "status", "move", "Focus the status pane: version and updates, accounts, config files, links and the changelog, on the right."),
a(Lists, "enter", &m.Enter, "open", "move", "Go into the selected item: from a subscription to its vaults, from a vault to its secrets. On a provider header, fold or unfold it."),
a(Lists, "back", &m.Back, "back", "move", "Go back: clear an applied filter, leave the versions view, or focus the previous pane."),
@@ -100,7 +103,7 @@ func (m *Map) Actions() []Action {
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, "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, 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, "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, "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, "commands", &m.Commands, "commands", "lists", "List the custom commands for the focused pane (customCommands in the config) and run one."),
@@ -114,7 +117,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, "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, "dividerLeft", &m.SplitLeft, "divider left", "layout", "Move the divider between the left panes and the detail pane to the left."),
@@ -158,7 +161,9 @@ func (m *Map) Actions() []Action {
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, "prevTab", &m.GoPrevTab, "prev tab", "go to", "Previous tab of Go to: Favourites, Recent, Search."),
a(GoTo, "top", &m.GoTop, "top", "go to", "Jump to the top of the list in Go to while browsing."),
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, "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)."),
@@ -177,8 +182,8 @@ func (m *Map) Actions() []Action {
var defaults = map[string][]string{
"lists.up": {"k", "up", "ctrl+k", "ctrl+p"}, "lists.down": {"j", "down", "ctrl+j", "ctrl+n"},
"lists.top": {"g", "home"}, "lists.bottom": {"G", "end"},
"lists.nextPane": {"tab", "l", "right"}, "lists.prevPane": {"shift+tab", "h", "left"},
"lists.pane1": {"1"}, "lists.pane2": {"2"}, "lists.pane3": {"3"}, "lists.pane4": {"4"},
"lists.nextPane": {"tab"}, "lists.prevPane": {"shift+tab", "h", "left"}, "lists.details": {"l", "right"},
"lists.pane1": {"1"}, "lists.pane2": {"2"}, "lists.pane3": {"3"}, "lists.pane4": {"4"}, "lists.pane0": {"0"},
"lists.enter": {"enter"}, "lists.back": {"esc", "backspace"},
"lists.filter": {"/"}, "lists.prevTab": {"["}, "lists.nextTab": {"]"}, "lists.sourcesView": {"`"},
"lists.star": {"f"}, "lists.favourites": {"F"}, "lists.recent": {"R"}, "lists.search": {"ctrl+s"}, "lists.note": {"n"},
@@ -204,7 +209,7 @@ var defaults = map[string][]string{
"search.sources": {"ctrl+h"}, "search.results": {"ctrl+l"}, "search.switchList": {"tab", "shift+tab"},
"search.load": {"ctrl+r"}, "search.loadAll": {"ctrl+a"},
"goto.down": {"j", "down"}, "goto.up": {"k", "up"}, "goto.prevTab": {"["}, "goto.nextTab": {"]"},
"goto.down": {"j", "down"}, "goto.up": {"k", "up"}, "goto.top": {"g", "home"}, "goto.bottom": {"G", "end"}, "goto.prevTab": {"["}, "goto.nextTab": {"]"},
"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.select": {"enter"}, "goto.close": {"esc", "q"},
+47 -49
View File
@@ -31,7 +31,7 @@ type Detail struct {
Icons icons.Set
// RevealKey and VersionsKey are the keys hints mention ("space to
// reveal"); a hint is left out when its key is "" (unbound).
RevealKey, VersionsKey string
RevealKey, VersionsKey, CopyKey string
secret *provider.Object
container *provider.Container
@@ -46,8 +46,6 @@ type Detail struct {
versions []provider.Object // newest first; nil while unknown
note string // the user's note on what's shown
message string // shown instead of the hint when nothing is selected: an error
account []provider.Attr // shown instead of the hint on a provider's row: who it signs in as
offset int
w, h int
@@ -70,17 +68,9 @@ func (d *Detail) ShowContainer(c provider.Container) {
// Clear drops the selection and any fetched value.
func (d *Detail) Clear() {
*d = Detail{Now: d.Now, Icons: d.Icons, RevealKey: d.RevealKey, VersionsKey: d.VersionsKey, w: d.w, h: d.h} // drops the note too
*d = Detail{Now: d.Now, Icons: d.Icons, RevealKey: d.RevealKey, VersionsKey: d.VersionsKey, CopyKey: d.CopyKey, w: d.w, h: d.h} // drops the note too
}
// SetMessage sets what's shown instead of the hint when nothing is
// selected, such as why an account has nothing to show; "" for the hint.
func (d *Detail) SetMessage(s string) { d.message = s }
// SetAccount sets the account shown on a provider's row when nothing is
// selected; nil for the hint.
func (d *Detail) SetAccount(fields []provider.Attr) { d.account = fields }
// SetDescribing marks extra metadata as being fetched.
func (d *Detail) SetDescribing() { d.describing, d.describeErr = true, nil }
@@ -196,18 +186,6 @@ func (d Detail) lines(st styles.Styles) []string {
muted := st.Muted.Render
if d.container == nil {
if d.message == "" && len(d.account) > 0 {
for _, a := range d.account {
field(a.Label, a.Value)
}
return out
}
if d.message != "" {
for _, l := range strings.Split(ansi.Hardwrap(d.message, max(1, inner-2), true), "\n") {
out = append(out, " "+st.Error.Render(l))
}
return out
}
return []string{muted(" Select a vault or scope to browse its secrets.")}
}
c := *d.container
@@ -284,24 +262,35 @@ func (d Detail) lines(st styles.Styles) []string {
}
// Secrets are masked; keys and certificates only expose public material.
keyHint := func(k, what string) string {
if k == "" {
return ""
// What the keys do with the value, on the line under it.
hints := func(reveal string, more ...string) string {
var h []string
if d.RevealKey != "" {
h = append(h, d.RevealKey+" to "+reveal)
}
return "(" + k + " to " + what + ")"
if d.CopyKey != "" {
h = append(h, d.CopyKey+" to copy")
}
label, hidden := "value", st.Masked.Render("••••••••")+muted(" "+keyHint(d.RevealKey, "reveal"))
return muted(strings.Join(append(h, more...), " · "))
}
label, hidden, reveal := "value", st.Masked.Render("••••••••"), "reveal"
switch s.Type {
case provider.TypeKey:
label, hidden = "public", muted(keyHint(d.RevealKey, "show public key"))
label, hidden, reveal = "public", "", "show the public key"
case provider.TypeCertificate:
label, hidden = "cert", muted(keyHint(d.RevealKey, "show certificate"))
label, hidden, reveal = "cert", "", "show the certificate"
}
indent := strings.Repeat(" ", 12)
out = append(out, "")
switch d.state {
case ValueHidden:
if hidden != "" {
field(label, hidden)
out = append(out, indent+hints(reveal))
} else {
field(label, hints(reveal))
}
case ValueLoading:
field(label, spinnerPlaceholder+" fetching…")
case ValueFailed:
@@ -322,23 +311,30 @@ func (d Detail) lines(st styles.Styles) []string {
}
}
case ValueShown:
var hints []string
if d.RevealKey != "" {
hints = append(hints, d.RevealKey+" to hide")
// Where the dots were, continuing under itself; the keys below.
first := true
lines, note := valueView(d.value.Reveal())
for _, line := range lines {
for _, w := range strings.Split(ansi.Hardwrap(line, max(1, inner-11), true), "\n") {
if first {
out = append(out, " "+st.Label.Render(fmt.Sprintf("%-10s", label))+" "+st.Value.Render(w))
first = false
} else {
out = append(out, indent+st.Value.Render(w))
}
}
}
var more []string
if note != "" && d.CopyKey != "" {
note += ": " + d.CopyKey + " copies it as stored"
}
if note != "" {
more = append(more, note)
}
if d.hidesIn != "" {
hints = append(hints, "hides in "+d.hidesIn)
}
line := " " + st.Label.Render(label)
if len(hints) > 0 {
line += muted(" (" + strings.Join(hints, " · ") + ")")
}
out = append(out, line)
for _, line := range strings.Split(d.value.Reveal(), "\n") {
for _, w := range strings.Split(ansi.Hardwrap(line, inner, true), "\n") {
out = append(out, " "+st.Value.Render(w))
}
more = append(more, "hides in "+d.hidesIn)
}
out = append(out, indent+hints("hide", more...))
}
return append(out, d.versionLines(st)...)
}
@@ -366,11 +362,13 @@ func (d Detail) versionLines(st styles.Styles) []string {
out = append(out, muted(fmt.Sprintf(" … %d more", len(d.versions)-i)))
break
}
marker := " "
if d.secret.Version != "" && v.Version == d.secret.Version {
marker = "▸ " // the version shown above
// A list to read, not to move in (v browses them): no cursor. The
// version shown above, when it isn't the current one, is named.
line := " " + VersionLine(st, v, d.Now(), current)
if d.secret.Version != "" && v.Version == d.secret.Version && v.Version != current {
line += muted(" · shown above")
}
out = append(out, " "+marker+VersionLine(st, v, d.Now(), current))
out = append(out, line)
}
return out
}
+79
View File
@@ -0,0 +1,79 @@
package panes
import (
"fmt"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
)
// Fields is the fourth pane for anything but a secret (an account, and
// what describes vaults, scopes and files): labelled values, one per row,
// a label on the first of several values, to move through, filter and
// copy from. It's a List, so / filters it like the other panes. Secrets
// have Detail, for revealing and versions.
type Fields struct {
List[Field]
shown bool
}
// Field is a row: a value under a label. Info rows (loading, errors)
// can't be copied; Err ones are errors. A row can also be opened: URL in
// the browser (o), Path in the editor (e), Provider signed in to (L), or
// Link gone to (enter).
type Field struct {
Label, Value string
Info, Err bool
URL, Path, Provider string
// Link is a lazysecrets link enter goes to, in the source of row Row.
Link, Row string
}
func NewFields() Fields {
l := NewList("", Item[Field]{
ID: func(f Field) string { return f.Label + "\x00" + f.Value },
Filter: func(f Field) string { return f.Value },
// "/tenant" finds the tenant's rows.
FilterExtra: func(f Field) string { return f.Label },
Render: func(st styles.Styles, f Field, width int) string {
value := f.Value
switch {
case f.Err:
value = st.Error.Render(value)
case f.Info:
value = st.Muted.Render(value)
}
return ansi.Truncate(st.Label.Render(fmt.Sprintf("%-10s", f.Label))+" "+value, width, "…")
},
})
l.Empty = "nothing to show"
return Fields{List: l}
}
// Show shows fields under title, instead of the secret pane. Another
// title (another account or vault) starts over, without a filter.
func (s *Fields) Show(title string, fields []Field) {
if title != s.Title {
s.Reset()
s.Title = title
}
s.shown = true
s.SetItems(fields)
}
// Hide gives the fourth pane back to the secret pane.
func (s *Fields) Hide() { s.shown = false }
// Shown reports whether the fourth pane shows Fields.
func (s Fields) Shown() bool { return s.shown }
// Selected is the field under the cursor, unless it's an info row.
func (s Fields) Selected() (Field, bool) {
f, ok := s.List.Selected()
if !ok || f.Info {
return Field{}, false
}
return f, true
}
+62 -1
View File
@@ -34,6 +34,14 @@ type List[T any] struct {
Empty string // shown when loaded with no items
Loading bool
Err error
// Age says how old the items are ("2h ago") while they reload, by
// the counter.
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]
items []T
@@ -88,6 +96,7 @@ func (l *List[T]) SetError(err error) {
func (l *List[T]) Refresh() {
l.Loading = true
l.Err = nil
l.Age = ""
}
func (l *List[T]) SetLoading() {
@@ -159,9 +168,36 @@ func (l List[T]) rows() int {
if l.filtering {
r--
}
r -= len(l.header())
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.
func (l List[T]) Filtering() bool { return l.filtering }
@@ -325,12 +361,15 @@ func (l List[T]) View(st styles.Styles, focused bool, spin string) string {
if l.filtering {
body = append(body, l.input.View())
}
for _, line := range l.header() {
body = append(body, st.Notice.Render(" "+line))
}
switch {
case l.Loading && len(l.items) == 0:
body = append(body, " "+spin+" loading…")
case l.Err != nil:
for _, line := range strings.Split(ansi.Wrap(l.Err.Error(), inner-1, " -/"), "\n") {
for _, line := range wrapLines(l.Err.Error(), inner-1) {
body = append(body, st.Error.Render(" "+line))
}
case len(l.visible) == 0 && len(l.items) > 0:
@@ -369,6 +408,9 @@ func (l List[T]) View(st styles.Styles, focused bool, spin string) string {
if len(l.visible) > 0 {
counter = fmt.Sprintf("%d of %d", l.cursor+1, len(l.visible))
}
if l.Loading && l.Age != "" && len(l.items) > 0 {
counter = "cached " + l.Age + " · " + counter
}
return BoxCounted(st, title, "", counter, body, l.w, l.h, focused)
}
@@ -386,3 +428,22 @@ func (l *List[T]) scroll() {
}
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,3 +177,27 @@ func TestHighlightKeepsColours(t *testing.T) {
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
@@ -0,0 +1,79 @@
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
@@ -0,0 +1,60 @@
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)
}
}
+31 -2
View File
@@ -3,6 +3,8 @@
package styles
import (
"image/color"
"charm.land/lipgloss/v2"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
@@ -32,6 +34,10 @@ type Styles struct {
KindAzure lipgloss.Style
KindDatabricks 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 {
@@ -43,9 +49,12 @@ func Default() Styles {
TitleBlurred: lipgloss.NewStyle(),
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),
SelectedBlurred: lipgloss.NewStyle().Bold(true),
SelectedBlurred: lipgloss.NewStyle().Background(lipgloss.BrightBlack),
Muted: lipgloss.NewStyle().Faint(true),
Error: lipgloss.NewStyle().Foreground(lipgloss.Red),
Notice: lipgloss.NewStyle().Foreground(lipgloss.Green),
@@ -74,3 +83,23 @@ func (s Styles) Kind(k provider.Kind) lipgloss.Style {
}
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,8 +70,10 @@ func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
st.BorderBlurred = v.fg(lipgloss.NewStyle())
case "selectedLineBgColor":
st.Selected = v.bg(lipgloss.NewStyle()).Bold(true)
st.themed = true
case "inactiveViewSelectedLineBgColor":
st.SelectedBlurred = v.bg(lipgloss.NewStyle()).Bold(true)
st.themedBlurred = true
case "defaultFgColor":
fg = v.color
case "optionsTextColor":
+30
View File
@@ -1,6 +1,7 @@
package styles
import (
"fmt"
"strings"
"testing"
@@ -62,3 +63,32 @@ func TestEmptyThemeIsDefault(t *testing.T) {
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)
}
}
+99 -30
View File
@@ -3,6 +3,9 @@
// against the release's checksums.txt, tried (--version), then renamed
// over the binary. A running lazysecrets keeps its old binary until it
// 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
import (
@@ -18,10 +21,12 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path"
"path/filepath"
"regexp"
"runtime"
"strings"
"time"
@@ -29,12 +34,20 @@ import (
"golang.org/x/mod/semver"
)
// Where releases are published.
// DefaultRepo is lazysecrets' home: its source, docs and releases.
// GitHubRepo mirrors it, with the issues and the releases too.
const (
DefaultAPI = "https://git.kebcloud.no/api/v1/repos/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.
const maxDownload = 200 << 20
@@ -49,18 +62,30 @@ type Release struct {
// every release ends with.
func (r Release) Notes() string {
notes, _, _ := strings.Cut(r.Body, "## Install")
return strings.TrimSpace(notes)
return PlainLinks(strings.TrimSpace(notes))
}
// Client talks to the release server.
// PlainLinks shows Markdown links as their text, for a terminal.
func PlainLinks(s string) string { return mdLink.ReplaceAllString(s, "$1") }
// mdLink is a Markdown link, [text](url): the notes link PRs and commits,
// and a terminal shows them as their text.
var mdLink = regexp.MustCompile(`\[([^\]]+)\]\((https?://[^)\s]+)\)`)
// Client talks to the release servers.
type Client struct {
API string // the repository's API, DefaultAPI when empty
Repo string // the repository's web address, DefaultRepo when empty
// Servers are the repositories' web addresses, on GitHub or a Gitea
// or Forgejo server, tried in order; DefaultServers when empty.
Servers []string
HTTP *http.Client
}
func (c *Client) api() string { return cmpOr(c.API, DefaultAPI) }
func (c *Client) repo() string { return cmpOr(c.Repo, DefaultRepo) }
func (c *Client) servers() []string {
if len(c.Servers) > 0 {
return c.Servers
}
return DefaultServers
}
func (c *Client) client() *http.Client {
if c.HTTP != nil {
@@ -69,25 +94,50 @@ func (c *Client) client() *http.Client {
return &http.Client{Timeout: 2 * time.Minute}
}
func cmpOr(a, b string) string {
if a != "" {
return a
// API is a repository's API: GitHub's for github.com, else Gitea's (and
// Forgejo's).
func API(repo string) (string, error) {
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)
}
return b
if u.Host == "github.com" {
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).
// Latest is the newest release (pre-releases aside), from the first
// server that answers.
func (c *Client) Latest(ctx context.Context) (Release, error) {
b, err := c.get(ctx, c.api()+"/releases/latest", 1<<20)
var errs []error
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 {
return Release{}, err
}
var r Release
if err := json.Unmarshal(b, &r); err != nil {
return Release{}, fmt.Errorf("reading the latest release: %w", err)
return Release{}, fmt.Errorf("reading the latest release from %s: %w", repo, err)
}
if !semver.IsValid(r.Tag) {
return Release{}, fmt.Errorf("latest release %q isn't a version", r.Tag)
return Release{}, fmt.Errorf("latest release %q on %s isn't a version", r.Tag, repo)
}
return r, nil
}
@@ -139,23 +189,10 @@ func (c *Client) Install(ctx context.Context, r Release, exe string) error {
}
version := strings.TrimPrefix(r.Tag, "v")
name := fmt.Sprintf("lazysecrets_%s_%s_%s", version, runtime.GOOS, runtime.GOARCH)
base := c.repo() + "/releases/download/" + r.Tag + "/"
sums, err := c.get(ctx, base+"checksums.txt", 1<<20)
archive, err := c.download(ctx, r.Tag, name+".tar.gz")
if err != nil {
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"))
if err != nil {
return err
@@ -190,6 +227,38 @@ func (c *Client) Install(ctx context.Context, r Release, exe string) error {
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) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
+59 -5
View File
@@ -70,13 +70,13 @@ func server(t *testing.T, tamper bool) *httptest.Server {
sum[0] ^= 0xff
}
mux := http.NewServeMux()
mux.HandleFunc("/api/releases/latest", func(w http.ResponseWriter, _ *http.Request) {
mux.HandleFunc("/api/v1/repos/keb/lazysecrets/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"}`)
})
mux.HandleFunc("/repo/releases/download/v0.2.0/checksums.txt", func(w http.ResponseWriter, _ *http.Request) {
mux.HandleFunc("/keb/lazysecrets/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)
})
mux.HandleFunc("/repo/releases/download/v0.2.0/"+name+".tar.gz", func(w http.ResponseWriter, _ *http.Request) {
mux.HandleFunc("/keb/lazysecrets/releases/download/v0.2.0/"+name+".tar.gz", func(w http.ResponseWriter, _ *http.Request) {
w.Write(archive.Bytes())
})
srv := httptest.NewServer(mux)
@@ -89,7 +89,7 @@ func TestInstall(t *testing.T) {
t.Skip("releases are for macOS and Linux")
}
srv := server(t, false)
c := &Client{API: srv.URL + "/api", Repo: srv.URL + "/repo"}
c := &Client{Servers: []string{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)
@@ -107,12 +107,59 @@ 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) {
if runtime.GOOS != "darwin" && runtime.GOOS != "linux" {
t.Skip("releases are for macOS and Linux")
}
srv := server(t, true)
c := &Client{API: srv.URL + "/api", Repo: srv.URL + "/repo"}
c := &Client{Servers: []string{srv.URL + "/keb/lazysecrets"}}
exe := filepath.Join(t.TempDir(), "lazysecrets")
os.WriteFile(exe, []byte("old"), 0o755)
err := c.Install(context.Background(), Release{Tag: "v0.2.0"}, exe)
@@ -123,3 +170,10 @@ func TestInstallRefusesATamperedArchive(t *testing.T) {
t.Error("replaced anyway")
}
}
func TestNotesShowLinksAsText(t *testing.T) {
r := Release{Body: "### Added\n\n- Search ([#11](https://git.example.com/pulls/11)) ([65dc2d7](https://git.example.com/commit/65dc2d7abc))\n\n## Install\n\ncurl …"}
if got, want := r.Notes(), "### Added\n\n- Search (#11) (65dc2d7)"; got != want {
t.Errorf("notes %q, want %q", got, want)
}
}
+75
View File
@@ -0,0 +1,75 @@
// 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}`);