9 Commits
Author SHA1 Message Date
keb 637bf6a253 chore(release): v0.2.1
Release / Build and publish (push) Successful in 51s
2026-10-15 10:52:09 +02:00
keb 29b0c76f40 chore: updated main doc image 2026-10-15 10:49:28 +02:00
keb df429002c9 refactor: one copy of the UI's repeated helpers (#77)
- Text inputs come from newTextInput; the link and folder prompts share one width.
- One starred-first sort for sources, vaults and secrets; accountOf where the account was cut from the label by hand.
- One helper each for the reveal and clipboard countdowns' tick and seconds, the "Open with" choices, the vaults-loaded count, and Go to's and the panes' tabs.
- The details pane builds its label prefix, indent, note and source once; list rows mark matches with MarkMatches.
- The release page opens through openPage, so it now says it opened, as other pages do.
- The cache's account filter is built once per load or save, not per key.

Reviewed-on: #77
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-15 10:43:42 +02:00
keb 0ff5204d4d refactor: one place for Key Vault collections and paging in the providers (#75)
- ObjectType.Collection names a type's segment in Key Vault URLs and links (secrets, keys, certificates), instead of four copies in ref, azure and fake.
- Azure lists through one pager helper instead of eight hand-written page loops; Value is VersionedValue without the version, rather than the two calling each other.
- Databricks' Key Vault fallback check lives in fallbackVault.
- The repo provider reads .lazysecrets.yaml in one place, builds plain and sops objects in one loop, and parses a file's sops metadata from the bytes Objects already read.

Reviewed-on: #75
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-15 10:41:43 +02:00
keb 6842c72662 refactor: tidy the command's setup, the update command and instance reach (#73)
- main: the subcommand is a name rather than nil-or-empty argument slices; the cache file and start directory are worked out once; config values with a default go through one valueOr helper.
- The link handler's --config argument comes from one configArgs helper.
- lazysecrets update parses its flags like the other subcommands, so -check and --check=true work too, and tells a development build by its dev version.
- Instance reach is named (unreachable, switchable, inView), tmux client activity is compared as a number, and the best client loop starts from the first.
- update: serverTimeout (it bounds the checksums request too), and parentheses in the platform check.

Reviewed-on: #73
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-15 10:40:40 +02:00
keb fae8e1cbb4 refactor: drop dead code and use the standard library where it already does the job (#76)
No behaviour change.

- Deleted, unused or one-line wrappers: panes.RuneIndexes, Detail.Refresh (SetDescribed does it), SecretValue.Len, Query.Empty, Clipboard.Native, config.Load, get's value, Model.done, providerOf (cachefile.ProviderOf), contextWithTimeout, searchCoverage, startCrawl, showKeybindings, the expiry aliases and daysLeft.
- cmp.Or instead of the two cmpOr copies; slices.Sorted(maps.Keys(…)), maps.Copy, wg.Go, utf8.RuneCountInString and new(true) instead of loops and helpers that do the same.
- A container's pane title is set once, not twice; a redundant length check in share links and a no-op assignment in linkhandler.Launch are gone.
- Tests call ansi.Strip and lastLines directly; gofmt -s on links.go.

Reviewed-on: #76
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-15 10:39:38 +02:00
keb 1753630c73 chore: tidy comments that repeat the code or no longer match it (#72)
- Comments that only restate a name (sgr, fg, mdLink, the repo provider's starred and current-repo notes) are gone.
- Stale ones match the code again: the repo package covers plain env files too, Launch runs argv, closeVersions no longer claims to move the cursor, and Check's fallback says why rather than naming a state no release had.
- The doc for versionString was attached to pseudo; the backtick in SourcesView's doc was a string-concatenation leftover; openFailed's doc was garbled.
- recentReleases sits next to latest, its user.
- .gitignore drops the Go template's boilerplate and patterns for files this project doesn't make.

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

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

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

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

No files matched your search

+8 -28
View File
@@ -1,33 +1,13 @@
# ---> Go
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
go.work.sum
# env file
.env
# Build output # Build output
/lazysecrets /lazysecrets
# Release builds (GoReleaser, see .goreleaser.yaml) # Release builds (GoReleaser, see .goreleaser.yaml)
/dist/ /dist/
# go test -c, coverage profiles
*.test
*.out
go.work
go.work.sum
.env
+6
View File
@@ -2,6 +2,12 @@
Every release of lazysecrets, newest first. Every release of lazysecrets, newest first.
## v0.2.1 – 2026-10-15
### Fixed
- A link opens lazysecrets with your cache and next to your running instances ([#71](https://git.kebcloud.no/keb/lazysecrets/pulls/71)) ([bb41767](https://git.kebcloud.no/keb/lazysecrets/commit/bb41767cbad00bab4f1824f4e0754cc7abd98c59))
## v0.2.0 – 2026-10-14 ## v0.2.0 – 2026-10-14
### Added ### Added
+9 -21
View File
@@ -7,24 +7,23 @@ A terminal UI for finding, reading and sharing secrets. Inspired by the fantasti
## Main features ## Main features
- **Read-only**: lazysecrets never creates, changes or deletes a secret. - **Read-only**: lazysecrets never creates, changes or deletes a secret.
That's by design and may stay that way.
- **Zero setup**: uses the logins you already have (`az`, the Databricks - **Zero setup**: uses the logins you already have (`az`, the Databricks
CLI, `sops`, `git`). No config file needed. CLI, `sops`, `git`). No config file needed.
- **Search everything**: `ctrl+s` searches every source at once, with - **Search everything**: `ctrl+s` searches every source at once, with
fzf-style matching. fzf-style matching.
- **Share links**: Share `lazysecrets://` links with colleagues. - **Share links**: Share `lazysecrets://` links with colleagues.
- **Neovim integration**: via [lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim)
- **Careful with values**: values stay hidden until you reveal them, and - **Careful with values**: values stay hidden until you reveal them, and
copied values are cleared from the clipboard after 30 seconds. copied values are cleared from the clipboard after 30 seconds.
- **Scriptable**: `lazysecrets get <link>` prints a value for scripts and - **Scriptable**: `get`, `list` and `open` for scripts, fzf and editor
pipes. pickers; [lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim)
for Neovim.
- **Favourites**: star the secrets you use most for easy access. - **Favourites**: star the secrets you use most for easy access.
- **Expiry warnings**: secrets, keys and certificates that expire soon are - **Expiry warnings**: secrets, keys and certificates that expire soon are
marked, listed together in the status pane. marked, listed together in the status pane.
- **Your way**: rebind any key, pick a theme, and add custom commands to run - **Your way**: rebind any key, pick a theme, and add custom commands to run
on a secret. on a secret.
- **Try it first**: `lazysecrets --demo` runs on built-in sample data. - **Try it first**: `lazysecrets --demo` runs on built-in sample data.
- **Supported platforms**: Azure Key Vault, Databricks secret scopes, and SOPS and `.env` files in your git repos. - **Sources**: Azure Key Vault, Databricks secret scopes, and SOPS and `.env` files in your git repos.
> **Early development:** expect rough edges. See [CHANGELOG.md](CHANGELOG.md) for what's new. > **Early development:** expect rough edges. See [CHANGELOG.md](CHANGELOG.md) for what's new.
@@ -45,9 +44,8 @@ Both approaches will install the latest release from [GitHub](https://github.com
`sh -s -- v0.2.0` installs a given version, `LAZYSECRETS_INSTALL_DIR` `sh -s -- v0.2.0` installs a given version, `LAZYSECRETS_INSTALL_DIR`
somewhere else. somewhere else.
lazysecrets looks for a newer release once a day: the footer then shows `U` (or `lazysecrets update`) updates in place when there's a newer
`↑ v0.2.0`, and `U` shows its changelog and updates in place (or run release.
`lazysecrets update`).
Or build it yourself: Or build it yourself:
`go install git.kebcloud.no/keb/lazysecrets/cmd/lazysecrets@latest`. `go install git.kebcloud.no/keb/lazysecrets/cmd/lazysecrets@latest`.
@@ -70,30 +68,20 @@ use most:
| `l` / `h` | the details of what's selected / back | | `l` / `h` | the details of what's selected / back |
| `/` | filter the list you're in | | `/` | filter the list you're in |
| `space` | reveal / hide the value | | `space` | reveal / hide the value |
| `y` | copy the value (cleared from the clipboard after 30s) | | `y` | copy the value |
| `ctrl+s` | Go to: search everything and jump there | | `ctrl+s` | Go to: search everything and jump there |
| `f` / `F` | star / your favourites | | `f` / `F` | star / your favourites |
| `s` | copy a link to share | | `s` | copy a link to share |
| `?` | every key, searchable | | `?` | every key, searchable |
| `q` | quit | | `q` | quit |
## Integration ## Integration
Lazysecret let you integrate it into your workflows by `lazysecrets` `list`/`get`/`open`
Here is an example of implementing a picker in `fzf`: `lazysecrets list`, `get` and `open` plug lazysecrets into fzf, editor
```sh pickers and scripts: see [Integration](docs/integration.md#an-fzf-picker).
lazysecrets list | fzf --ansi --delimiter='\t' --with-nth='2..-3' --nth='2..' --accept-nth=-1 \
--header 'enter: link · ctrl-o: open · ctrl-y: copy value · ctrl-x: copy and close' \
--bind 'ctrl-o:become(lazysecrets open --action go --account {-2} {-1})' \
--bind 'ctrl-y:execute-silent(lazysecrets get --account {-2} {-1} | pbcopy)+change-header(Copied the value of {3})' \
--bind 'ctrl-x:become(lazysecrets get --account {-2} {-1} | pbcopy)'
```
![fzf-example](docs/assets/fzf-example.png) ![fzf-example](docs/assets/fzf-example.png)
More defails in the [Integration](docs/integration.md) docs
## Docs ## Docs
- [Keys](docs/keys.md): every key, Go to, the layout, and rebinding keys - [Keys](docs/keys.md): every key, Go to, the layout, and rebinding keys
+10 -8
View File
@@ -69,11 +69,7 @@ func installHandler(cfg config.Config, configPath, term string) (string, string,
if _, err := launchCommand(cfg, configPath, term, exe, "lazysecrets://check"); err != nil { if _, err := launchCommand(cfg, configPath, term, exe, "lazysecrets://check"); err != nil {
return "", "", err return "", "", err
} }
launch := []string{exe} launch := append(append([]string{exe}, configArgs(configPath)...), "launch")
if configPath != defaultConfigPath() {
launch = append(launch, "--config", configPath)
}
launch = append(launch, "launch")
if term != "" { if term != "" {
launch = append(launch, "--terminal", term) launch = append(launch, "--terminal", term)
} }
@@ -188,8 +184,14 @@ func launchCommand(cfg config.Config, configPath, term, exe, link string) ([]str
// innerCommand runs lazysecrets on link, with the environment it needs. // innerCommand runs lazysecrets on link, with the environment it needs.
func innerCommand(configPath, exe, link string) []string { func innerCommand(configPath, exe, link string) []string {
inner := append(append([]string{"/usr/bin/env"}, linkhandler.Env(os.Getenv)...), exe) inner := append(append([]string{"/usr/bin/env"}, linkhandler.Env(os.Getenv)...), exe)
if configPath != defaultConfigPath() { inner = append(inner, configArgs(configPath)...)
inner = append(inner, "--config", configPath)
}
return append(inner, "open", link) return append(inner, "open", link)
} }
// configArgs passes the config files on, unless they're the default.
func configArgs(configPath string) []string {
if configPath == defaultConfigPath() {
return nil
}
return []string{"--config", configPath}
}
+48 -64
View File
@@ -143,6 +143,7 @@ func run() error {
for scripts: VAR=$(lazysecrets get <ref>) for scripts: VAR=$(lazysecrets get <ref>)
lazysecrets [flags] list [--kind secret,…] [--cached] [--max-age 2m] [--format text|json] lazysecrets [flags] list [--kind secret,…] [--cached] [--max-age 2m] [--format text|json]
[--color always|never] [--icons=false] [--errors=false] [--color always|never] [--icons=false] [--errors=false]
[--stars=false] [--notes=false] [--recent] [--expiry=false]
print every source, vault and secret it can print every source, vault and secret it can
find, one per line, for fzf and pickers: find, one per line, for fzf and pickers:
what's cached at once, then what loads what's cached at once, then what loads
@@ -170,26 +171,22 @@ Flags:
return nil return nil
} }
var open, getRef *ref.Ref sub := "" // the subcommand; "" browses
var subArgs []string
var open *ref.Ref
var getRef ref.Ref
var openAction string var openAction string
var shareArgs, listArgs []string
printKeys := false
switch args := flag.Args(); { switch args := flag.Args(); {
case len(args) > 0 && args[0] == "share": case len(args) > 0 && (args[0] == "share" || args[0] == "list"):
shareArgs = args[1:] sub, subArgs = args[0], args[1:]
if len(shareArgs) == 0 {
shareArgs = []string{} // still sharing: says how
}
case len(args) > 0 && args[0] == "list":
listArgs = append([]string{}, args[1:]...) // not nil: listing
case len(args) == 1 && args[0] == "keybindings": case len(args) == 1 && args[0] == "keybindings":
printKeys = true sub = "keybindings"
case len(args) > 0 && args[0] == "get": case len(args) > 0 && args[0] == "get":
r, _, err := refArg("get", args[1:]) r, _, err := refArg("get", args[1:])
if err != nil { if err != nil {
return err return err
} }
getRef = &r sub, getRef = "get", r
case len(args) > 0 && args[0] == "handler": case len(args) > 0 && args[0] == "handler":
return handler(*configPath, args[1:]) return handler(*configPath, args[1:])
case len(args) > 0 && args[0] == "launch": case len(args) > 0 && args[0] == "launch":
@@ -235,7 +232,7 @@ Flags:
if err := checkCommandKeys(&keymap, cfg.CustomCommands); err != nil { if err := checkCommandKeys(&keymap, cfg.CustomCommands); err != nil {
return err return err
} }
if printKeys { if sub == "keybindings" {
fmt.Print(keymap.YAML()) fmt.Print(keymap.YAML())
return nil return nil
} }
@@ -263,25 +260,13 @@ Flags:
} }
// timeoutFor is how long a request to a provider of kind k may take. // timeoutFor is how long a request to a provider of kind k may take.
timeoutFor := func(k provider.Kind) time.Duration { return cmp.Or(timeouts[k], *timeout) } timeoutFor := func(k provider.Kind) time.Duration { return cmp.Or(timeouts[k], *timeout) }
if !set["clipboard-clear"] && cfg.Values.ClipboardClear != nil { if !set["clipboard-clear"] {
*clipClear = *cfg.Values.ClipboardClear *clipClear = valueOr(cfg.Values.ClipboardClear, *clipClear)
}
expiryDays := 30
if cfg.UI.ExpiryWarningDays != nil {
expiryDays = *cfg.UI.ExpiryWarningDays
}
revealTimeout := 60 * time.Second
if cfg.Values.RevealTimeout != nil {
revealTimeout = *cfg.Values.RevealTimeout
}
loadDelay := 150 * time.Millisecond
if cfg.UI.LoadDelay != nil {
loadDelay = *cfg.UI.LoadDelay
}
var updateEvery time.Duration // 0: the app's default, a day
if cfg.Updates.AutoCheckInterval != nil {
updateEvery = *cfg.Updates.AutoCheckInterval
} }
expiryWarning := time.Duration(valueOr(cfg.UI.ExpiryWarningDays, 30)) * 24 * time.Hour
revealTimeout := valueOr(cfg.Values.RevealTimeout, 60*time.Second)
loadDelay := valueOr(cfg.UI.LoadDelay, 150*time.Millisecond)
updateEvery := valueOr(cfg.Updates.AutoCheckInterval, 0) // 0: the app's default, a day
if !set["icons"] { if !set["icons"] {
switch { switch {
case os.Getenv("LAZYSECRETS_ICONS") != "": case os.Getenv("LAZYSECRETS_ICONS") != "":
@@ -291,7 +276,17 @@ Flags:
} }
} }
recentIcon := cfg.UI.RecentIcon != nil && *cfg.UI.RecentIcon recentIcon := valueOr(cfg.UI.RecentIcon, false)
startDir := *dir
if startDir == "" {
startDir, _ = os.Getwd()
}
// What was loaded last time.
cacheFile := ""
if d, err := config.CacheDir(); err == nil && !*demo && config.On(cfg.Storage.Cache) {
cacheFile = filepath.Join(d, "cache.json")
}
// Stars, notes and the layout. Demo mode keeps them in memory, so // Stars, notes and the layout. Demo mode keeps them in memory, so
// sample vaults never end up in the real file. // sample vaults never end up in the real file.
@@ -349,17 +344,9 @@ Flags:
providers = append(providers, dbx) providers = append(providers, dbx)
} }
if config.On(pc.Repos.Enabled) { if config.On(pc.Repos.Enabled) {
start := *dir
if start == "" {
start, _ = os.Getwd()
}
roots := repo.DefaultRoots
if pc.Repos.Roots != nil {
roots = *pc.Repos.Roots
}
repos := repo.New(repo.Settings{ repos := repo.New(repo.Settings{
Dir: start, Paths: pc.Repos.Paths, Roots: roots, Starred: starredRepos(st), Dir: startDir, Paths: pc.Repos.Paths, Roots: valueOr(pc.Repos.Roots, repo.DefaultRoots),
Include: pc.Repos.Include, Exclude: pc.Repos.Exclude, Starred: starredRepos(st), Include: pc.Repos.Include, Exclude: pc.Repos.Exclude,
}) })
// The repo it started in (shown on top as Repo) and the // The repo it started in (shown on top as Repo) and the
// others, checked for secret files in the background like // others, checked for secret files in the background like
@@ -375,19 +362,14 @@ Flags:
// the standard logger, which would draw over the TUI. // the standard logger, which would draw over the TUI.
log.SetOutput(io.Discard) log.SetOutput(io.Discard)
if getRef != nil { switch sub {
case "get":
ctx, cancel := context.WithTimeout(context.Background(), timeoutFor(getRef.Kind)) ctx, cancel := context.WithTimeout(context.Background(), timeoutFor(getRef.Kind))
defer cancel() defer cancel()
return get(ctx, providers, st, *getRef) return get(ctx, providers, st, getRef)
} case "list":
if listArgs != nil { return listCmd(providers, st, cacheFile, timeoutFor, theme, *icons, recentIcon, expiryWarning, subArgs, os.Stdout)
cachePath := "" case "share":
if d, err := config.CacheDir(); err == nil && !*demo && config.On(cfg.Storage.Cache) {
cachePath = filepath.Join(d, "cache.json")
}
return listCmd(providers, st, cachePath, timeoutFor, theme, *icons, recentIcon, time.Duration(expiryDays)*24*time.Hour, listArgs, os.Stdout)
}
if shareArgs != nil {
// The files from a repo, the key from whichever provider it's // The files from a repo, the key from whichever provider it's
// in: the longest timeout. // in: the longest timeout.
d := *timeout d := *timeout
@@ -396,7 +378,7 @@ Flags:
} }
ctx, cancel := context.WithTimeout(context.Background(), d) ctx, cancel := context.WithTimeout(context.Background(), d)
defer cancel() defer cancel()
return shareFiles(ctx, providers, st, cfg.Sharing.Key, shareRules(cfg), shareArgs, os.Stdout) return shareFiles(ctx, providers, st, cfg.Sharing.Key, shareRules(cfg), subArgs, os.Stdout)
} }
// Links opened elsewhere (lazysecrets launch) can come here. // Links opened elsewhere (lazysecrets launch) can come here.
@@ -430,16 +412,10 @@ Flags:
onActive = srv.Touch onActive = srv.Touch
} }
// What was loaded last time, and where it was left in this folder. // Where it was left in this folder.
var cacheFile, placeDir string placeDir := ""
if !*demo { if !*demo {
if d, err := config.CacheDir(); err == nil && config.On(cfg.Storage.Cache) { placeDir = startDir
cacheFile = filepath.Join(d, "cache.json")
}
placeDir = *dir
if placeDir == "" {
placeDir, _ = os.Getwd()
}
if abs, err := filepath.Abs(placeDir); err == nil { if abs, err := filepath.Abs(placeDir); err == nil {
placeDir = abs placeDir = abs
} }
@@ -456,7 +432,7 @@ Flags:
NoHistory: !config.On(cfg.Storage.History), NoHistory: !config.On(cfg.Storage.History),
OnActive: onActive, OnActive: onActive,
RevealTimeout: revealTimeout, RevealTimeout: revealTimeout,
ExpiryWarning: time.Duration(expiryDays) * 24 * time.Hour, ExpiryWarning: expiryWarning,
HideOnBlur: config.On(cfg.Values.HideOnBlur), HideOnBlur: config.On(cfg.Values.HideOnBlur),
HandlerSetup: setup, HandlerSetup: setup,
Updater: updater(cfg.Updates.Sources), Updater: updater(cfg.Updates.Sources),
@@ -489,6 +465,14 @@ Flags:
return err return err
} }
// valueOr is *p, or def when p is nil (not set in the config).
func valueOr[T any](p *T, def T) T {
if p == nil {
return def
}
return *p
}
// configFiles are the config files given, for the status pane: those // configFiles are the config files given, for the status pane: those
// that exist, or the first if none does (where to create one). // that exist, or the first if none does (where to create one).
func configFiles(list string) []string { func configFiles(list string) []string {
+9 -4
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"context" "context"
"errors" "errors"
"flag"
"fmt" "fmt"
"strings" "strings"
"time" "time"
@@ -32,8 +33,12 @@ func updater(servers []string) *app.Updater {
// updateCmd is lazysecrets update [--check]: the newer release's // updateCmd is lazysecrets update [--check]: the newer release's
// changelog, then (unless --check) install it. // changelog, then (unless --check) install it.
func updateCmd(configPath string, args []string) error { func updateCmd(configPath string, args []string) error {
check := len(args) == 1 && args[0] == "--check" fs := flag.NewFlagSet("update", flag.ContinueOnError)
if len(args) > 0 && !check { check := fs.Bool("check", false, "just say if there's a newer release")
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() != 0 {
return errors.New("usage: lazysecrets update [--check]") return errors.New("usage: lazysecrets update [--check]")
} }
cfg, err := config.LoadAll(strings.Split(configPath, ",")) cfg, err := config.LoadAll(strings.Split(configPath, ","))
@@ -49,14 +54,14 @@ func updateCmd(configPath string, args []string) error {
return fmt.Errorf("looking for a newer lazysecrets: %w", err) return fmt.Errorf("looking for a newer lazysecrets: %w", err)
} }
if !update.Newer(current, r.Tag) { if !update.Newer(current, r.Tag) {
if !update.Newer(current, "v999.0.0") { if strings.HasPrefix(current, "dev") {
return fmt.Errorf("this is a development build (%s); the latest release is %s: install it with install.sh, or go install …@%s", current, r.Tag, r.Tag) return fmt.Errorf("this is a development build (%s); the latest release is %s: install it with install.sh, or go install …@%s", current, r.Tag, r.Tag)
} }
fmt.Printf("lazysecrets %s is the latest (%s)\n", current, r.Tag) fmt.Printf("lazysecrets %s is the latest (%s)\n", current, r.Tag)
return nil return nil
} }
fmt.Printf("lazysecrets %s is out (you have %s):\n\n%s\n\n", r.Tag, current, indent(r.Notes(current))) fmt.Printf("lazysecrets %s is out (you have %s):\n\n%s\n\n", r.Tag, current, indent(r.Notes(current)))
if check { if *check {
fmt.Println("lazysecrets update installs it.") fmt.Println("lazysecrets update installs it.")
return nil return nil
} }
+4 -5
View File
@@ -3,7 +3,6 @@ package main
import ( import (
"regexp" "regexp"
"runtime/debug" "runtime/debug"
"strings"
) )
// version is set by release builds: // version is set by release builds:
@@ -11,13 +10,13 @@ import (
// go build -ldflags "-X main.version=1.2.3" ./cmd/lazysecrets // go build -ldflags "-X main.version=1.2.3" ./cmd/lazysecrets
var version string var version string
// versionString is the release version, or for other builds what Go
// recorded: the module version (go install …@v1.2.3), else the commit
// (dev-c1a4f90, -dirty with changes not committed).
// pseudo matches a Go pseudo-version (v0.1.3-0.20261014174204-1c31a75d0507, // pseudo matches a Go pseudo-version (v0.1.3-0.20261014174204-1c31a75d0507,
// maybe +dirty): its commit. // maybe +dirty): its commit.
var pseudo = regexp.MustCompile(`[-.]\d{14}-([0-9a-f]{12})(\+.*)?$`) var pseudo = regexp.MustCompile(`[-.]\d{14}-([0-9a-f]{12})(\+.*)?$`)
// versionString is the release version, or for other builds what Go
// recorded: the module version (go install …@v1.2.3), else the commit
// (dev-c1a4f90, -dirty with changes not committed).
func versionString() string { func versionString() string {
if version != "" { if version != "" {
return version return version
@@ -31,7 +30,7 @@ func versionString() string {
// go install …@<commit>. // go install …@<commit>.
if m := pseudo.FindStringSubmatch(v); m != nil { if m := pseudo.FindStringSubmatch(v); m != nil {
dirty := "" dirty := ""
if strings.HasSuffix(v, "+dirty") { if m[2] != "" {
dirty = "-dirty" dirty = "-dirty"
} }
return "dev-" + m[1][:7] + dirty return "dev-" + m[1][:7] + dirty
Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

After

Width:  |  Height:  |  Size: 113 KiB

+3 -4
View File
@@ -16,7 +16,7 @@ A service principal is used instead when `AZURE_CLIENT_ID`,
On an account's group row, the fourth pane shows who it signs in as: name, On an account's group row, the fourth pane shows who it signs in as: name,
user, tenant, object ID, whether the session used MFA, directory roles user, tenant, object ID, whether the session used MFA, directory roles
(built-in ones by name) and its subscriptions, read from az's account list (built-in ones by name) and its subscriptions, read from az's account list
and the token lazysecrets already has: no requests of their own. `y` on and the token lazysecrets already has. `y` on
the row copies the object ID, the one access policies and role the row copies the object ID, the one access policies and role
assignments ask for; `l` goes into the pane, where `j`/`k` pick any assignments ask for; `l` goes into the pane, where `j`/`k` pick any
field, `/` filters them (`/tenant`) and `y` copies the selected one. field, `/` filters them (`/tenant`) and `y` copies the selected one.
@@ -67,9 +67,8 @@ fetched. `v` lists an item's versions. Items expiring within
The status pane (`0`) lists them across every vault loaded, opened or The status pane (`0`) lists them across every vault loaded, opened or
cached, soonest first, and its line counts them (`⚠ 3 expiring`); `enter` cached, soonest first, and its line counts them (`⚠ 3 expiring`); `enter`
on one goes to it. That makes no requests of its own: expiry dates come on one goes to it. Expiry dates come with the lists. It says how many
with the lists. It says how many vaults it covers; `ctrl+a` in search vaults it covers; `ctrl+a` in search loads the rest.
loads the rest.
On a vault, the right pane shows its settings: resource group, location, On a vault, the right pane shows its settings: resource group, location,
SKU, whether access is by Azure roles or access policies, soft delete and SKU, whether access is by Azure roles or access policies, soft delete and
+20 -37
View File
@@ -1,10 +1,11 @@
# Config # Config
Optional, at `~/.config/lazysecrets/config.yaml` (or Optional, at `~/.config/lazysecrets/config.yaml` (or
`$XDG_CONFIG_HOME/lazysecrets/config.yaml`, or `--config`). Flags override `$XDG_CONFIG_HOME/lazysecrets/config.yaml`, or `--config`). Flags (noted
it. Unknown keys are an error, so typos don't go unnoticed. `e` on the below) override it. Unknown keys are an error, so typos don't go
status pane (`0`) opens it in your editor, as in lazygit (making it if unnoticed. `e` on the status pane (`0`) opens it in your editor, as in
it isn't there yet); changes apply when lazysecrets starts again. lazygit (making it if it isn't there yet); changes apply when lazysecrets
starts again.
```yaml ```yaml
ui: ui:
@@ -17,7 +18,7 @@ ui:
values: values:
revealTimeout: 60s # hide a revealed value after this long; 0s keeps it shown revealTimeout: 60s # hide a revealed value after this long; 0s keeps it shown
hideOnBlur: true # hide a revealed value when the terminal loses focus (tmux: focus-events on) hideOnBlur: true # hide a revealed value when the terminal loses focus (tmux: focus-events on)
clipboardClear: 30s # clear copied values from the clipboard after this long; 0s disables clipboardClear: 30s # clear copied values from the clipboard after this long; 0s disables; also --clipboard-clear
storage: storage:
cache: true # keep what was loaded (never values) for the next start cache: true # keep what was loaded (never values) for the next start
history: true # count uses of items for Recent (R) history: true # count uses of items for Recent (R)
@@ -28,32 +29,15 @@ updates:
- https://github.com/karleinarb/lazysecrets - https://github.com/karleinarb/lazysecrets
- https://git.kebcloud.no/keb/lazysecrets - https://git.kebcloud.no/keb/lazysecrets
providers: providers:
timeout: 30s # per backend request; a provider's own timeout overrides it timeout: 30s # per backend request; a provider's own timeout overrides it; also --timeout
azure: # applies to every az account azure: # likewise databricks and repos; azure covers every az account
enabled: true # default enabled: true # default
timeout: 30s timeout: 30s
hideSubscriptions: [platform-mgmt] # by name or ID
vaults: # listed under "Pinned vaults", for vaults
- https://kv-foo.vault.azure.net/ # you can read but not discover
databricks:
enabled: true
timeout: 30s
hideProfiles: [test]
repos:
enabled: true
timeout: 30s
roots: [~/git]
paths: [~/work/infra]
include: ["secrets/**"]
exclude: ["**/testdata/**"]
sharing: # sharing env files as encrypted links
key: https://kv-team.vault.azure.net/secrets/env-share # or its lazysecrets:// link; for repos naming none
keyRules: # what a key's value must be
minLength: 32 # characters, 16 at least
minClasses: 2 # kinds: lowercase, uppercase, digits, symbols
``` ```
More, each on its own page: [repos](repos.md) (`providers.repos`), More, each on its own page: [Azure](azure.md#vaults-you-cant-discover)
(`providers.azure`), [Databricks](databricks.md) (`providers.databricks`),
[repos](repos.md#which-files) (`providers.repos`),
[sharing env files](sharing.md#sharing-env-files) (`sharing`), the [sharing env files](sharing.md#sharing-env-files) (`sharing`), the
[link handler](sharing.md#from-the-browser-and-chat) (`linkHandler`), [link handler](sharing.md#from-the-browser-and-chat) (`linkHandler`),
[keybindings](keys.md#rebinding-keys) (`keybinding`) and [keybindings](keys.md#rebinding-keys) (`keybinding`) and
@@ -102,9 +86,8 @@ as boxes, turn them off with `ui.icons: false`.
Several config files can be layered, later ones winning (theme roles and Several config files can be layered, later ones winning (theme roles and
keybindings merge one by one), with a comma-separated `--config` or keybindings merge one by one), with a comma-separated `--config` or
`LAZYSECRETS_CONFIG_FILE`, like lazygit's `LG_CONFIG_FILE`. An editor `LAZYSECRETS_CONFIG_FILE`, like lazygit's `LG_CONFIG_FILE`; e.g. your
plugin can so add a generated theme file to your own config, the way config plus an editor-generated theme:
snacks.nvim themes lazygit from Neovim's highlight groups:
```sh ```sh
LAZYSECRETS_CONFIG_FILE=~/.config/lazysecrets/config.yaml,~/.cache/nvim/lazysecrets-theme.yml lazysecrets LAZYSECRETS_CONFIG_FILE=~/.config/lazysecrets/config.yaml,~/.cache/nvim/lazysecrets-theme.yml lazysecrets
@@ -117,11 +100,10 @@ Stars, notes, usage and the layout are kept in
by you: one record per source, vault or secret, keyed by its by you: one record per source, vault or secret, keyed by its
`lazysecrets://` link, never with values. A star belongs to the account you `lazysecrets://` link, never with values. A star belongs to the account you
starred the item under (a vault two of your accounts can see can be starred starred the item under (a vault two of your accounts can see can be starred
for one); a note describes the item, so it shows under every account. It's for one); a note describes the item, so it shows under every account.
JSON so an editor plugin can read it with a built-in parser; several Several lazysecrets windows can save to it without losing each other's
lazysecrets windows can save to it without losing each other's changes. changes. One written by a newer lazysecrets is read but not saved to,
One written by a newer lazysecrets is read but not saved to, with a with a warning, until you update.
warning, until you update.
Revealing, copying, opening in the browser, sharing and jumping to an item Revealing, copying, opening in the browser, sharing and jumping to an item
count as using it: its record keeps when it was last used and how often, count as using it: its record keeps when it was last used and how often,
@@ -157,8 +139,9 @@ until then it reuses the answer from the state file. Releases are
published on GitHub and on git.kebcloud.no, and it asks them in that published on GitHub and on git.kebcloud.no, and it asks them in that
order, the next when one doesn't answer within 5 seconds; order, the next when one doesn't answer within 5 seconds;
`updates.sources` lists others, such as a mirror of your own. Offline, or with `updates.sources` lists others, such as a mirror of your own. Offline, or with
every server down, it says nothing. A newer release shows in the footer, `v0.1.1 ↑ v0.2.0`; `U` (or every server down, it says nothing. A newer release shows in the status
`lazysecrets update --check`) shows what changed since your version, across the last 10 releases. pane, `↑ v0.2.0`; `U` (or `lazysecrets update --check`) shows what
changed since your version, across the last 10 releases.
Updating (`u` there, or `lazysecrets update`) downloads the release for Updating (`u` there, or `lazysecrets update`) downloads the release for
your OS and CPU from the first server that has it, checks it against your OS and CPU from the first server that has it, checks it against
+3 -5
View File
@@ -12,7 +12,7 @@ repo, pick `kv-shared-prod / env-share` (the second account's) as the
key, then paste the link back. The demo repos aren't on disk, so opening key, then paste the link back. The demo repos aren't on disk, so opening
asks where to save, as for a repo you haven't cloned. asks where to save, as for a repo you haven't cloned.
A stripped binary is about 21 MB instead of 30 MB: A stripped binary is a third smaller:
```sh ```sh
go build -trimpath -ldflags "-s -w" -o lazysecrets ./cmd/lazysecrets go build -trimpath -ldflags "-s -w" -o lazysecrets ./cmd/lazysecrets
@@ -35,10 +35,8 @@ subject, checked by `pr-title.yml`:
| `fix` | a bug fix | Fixed | | `fix` | a bug fix | Fixed |
| `refactor`, `docs`, `test`, `ci`, `chore`, `build`, `style`, `revert` | everything users don't need to hear about | no | | `refactor`, `docs`, `test`, `ci`, `chore`, `build`, `style`, `revert` | everything users don't need to hear about | no |
`feat!:` marks a breaking change, `feat(scope):` names a part. Brackets `feat!:` marks a breaking change, `feat(scope):` names a part.
and a missing space work too: `[feat] x`, `[feat]: x` and `feat:x` all `[skip changelog]` in the description leaves a pull request out.
become `feat: x` in the changelog. `[skip changelog]` in the description
leaves a pull request out.
CI (`ci.yml`) runs gofmt, vet, staticcheck, the tests and a build for each CI (`ci.yml`) runs gofmt, vet, staticcheck, the tests and a build for each
platform on pull requests into `main`. `main` is protected: a pull request platform on pull requests into `main`. `main` is protected: a pull request
+6 -6
View File
@@ -34,7 +34,7 @@ tab-separated:
| name | `platform-prod / kv-app-prod / db-password` | | name | `platform-prod / kv-app-prod / db-password` |
| status | why a vault or source couldn't be read, or that it has `expired` or `expires in 12d` (within `ui.expiryWarningDays`), or empty | | status | why a vault or source couldn't be read, or that it has `expired` or `expires in 12d` (within `ui.expiryWarningDays`), or empty |
| note | your note on it (`n` in lazysecrets), after a note icon (✎ without icons), or empty | | note | your note on it (`n` in lazysecrets), after a note icon (✎ without icons), or empty |
| account | what it's listed through: an az account (`Azure:<user>/<tenant>`), `Databricks` or `Repos` | | account | what it's listed through: `Azure` (`Azure:<user>/<tenant>` with several az accounts), `Databricks` or `Repos` |
| link | its `lazysecrets://` link | | link | its `lazysecrets://` link |
What's cached prints at once, then what loads follows as it arrives: read What's cached prints at once, then what loads follows as it arrives: read
@@ -47,7 +47,7 @@ so `^prod` finds names starting with prod.
| Flag | | | Flag | |
|---|---| |---|---|
| `--format json` | an object per line instead, for editor plugins (below) | | `--format json` | an object per line instead, for editor plugins (below) |
| `--kind secret,vault` | only these kinds: `source`, `vault`, `secret`, `key`, `certificate`; lists only other kinds need aren't loaded | | `--kind secret,vault` | only these kinds: `source`, `vault` (also scopes and repo files), `secret`, `key`, `certificate`; lists they don't need aren't loaded |
| `--cached` | only what's cached, at once: nothing is loaded from Azure or Databricks | | `--cached` | only what's cached, at once: nothing is loaded from Azure or Databricks |
| `--max-age 2m` | load cached lists older than this again | | `--max-age 2m` | load cached lists older than this again |
| `--errors=false` | leave out the vaults and sources it couldn't read | | `--errors=false` | leave out the vaults and sources it couldn't read |
@@ -77,12 +77,11 @@ may be added:
| `expires` | when a secret, key or certificate expires, if it does (Azure) | | `expires` | when a secret, key or certificate expires, if it does (Azure) |
| `enabled` | `false` for a disabled one; only where the provider says (Azure) | | `enabled` | `false` for a disabled one; only where the provider says (Azure) |
`lazysecrets open --action go --account <account> <link>` opens one in [`lazysecrets open --action go --account <account> <link>`](sharing.md#opening-a-link)
lazysecrets without asking what to do or which account, ending in the opens one in lazysecrets without asking what to do or which account.
secrets list, like Go to.
### An fzf picker
### Example usage with fzf
```sh ```sh
lazysecrets list | fzf --ansi --delimiter='\t' --with-nth='2..-3' --nth='2..' --accept-nth=-1 \ lazysecrets list | fzf --ansi --delimiter='\t' --with-nth='2..-3' --nth='2..' --accept-nth=-1 \
--header 'enter: link · ctrl-o: open · ctrl-y: copy value · ctrl-x: copy and close' \ --header 'enter: link · ctrl-o: open · ctrl-y: copy value · ctrl-x: copy and close' \
@@ -90,5 +89,6 @@ lazysecrets list | fzf --ansi --delimiter='\t' --with-nth='2..-3' --nth='2..' --
--bind 'ctrl-y:execute-silent(lazysecrets get --account {-2} {-1} | pbcopy)+change-header(Copied the value of {3})' \ --bind 'ctrl-y:execute-silent(lazysecrets get --account {-2} {-1} | pbcopy)+change-header(Copied the value of {3})' \
--bind 'ctrl-x:become(lazysecrets get --account {-2} {-1} | pbcopy)' --bind 'ctrl-x:become(lazysecrets get --account {-2} {-1} | pbcopy)'
``` ```
![fzf-example](assets/fzf-example.png) ![fzf-example](assets/fzf-example.png)
+16 -19
View File
@@ -49,7 +49,7 @@ match case exactly.
| Key | Action | | Key | Action |
|---|---| |---|---|
| `space` | reveal / hide the selected value (public material for keys and certificates) | | `space` | reveal / hide the selected value (public material for keys and certificates) |
| `y` | copy the value without revealing it; cleared from the clipboard after 30s. On an Azure account's row: its object ID; in its pane, the selected field | | `y` | copy the value without revealing it. On an Azure account's row: its object ID; in its pane, the selected field |
| `Y` | copy the reference (`@Microsoft.KeyVault(...)`, key/certificate URL, `{{secrets/scope/key}}`) | | `Y` | copy the reference (`@Microsoft.KeyVault(...)`, key/certificate URL, `{{secrets/scope/key}}`) |
| `v` | versions of the selected item (Azure); `space`/`y`/`Y` then act on that version; `esc`, `⌫` or `v` to go back | | `v` | versions of the selected item (Azure); `space`/`y`/`Y` then act on that version; `esc`, `⌫` or `v` to go back |
| `o` | open the selected item in the browser (over SSH: copy the link) | | `o` | open the selected item in the browser (over SSH: copy the link) |
@@ -63,10 +63,8 @@ match case exactly.
A revealed value is shown as it reads best: JSON indented, and PEM A revealed value is shown as it reads best: JSON indented, and PEM
certificates as their subject, issuer, dates, names and thumbprint, with certificates as their subject, issuer, dates, names and thumbprint, with
any other PEM block (a private key) named but not shown. `y` still copies any other PEM block (a private key) named but not shown. `y` still copies
it exactly as stored. it exactly as stored. It hides again after a while, and when the terminal
loses focus ([config](config.md)).
A revealed value hides again after `values.revealTimeout` (60s) and
when the terminal loses focus (`values.hideOnBlur`); see [config](config.md).
## Stars and notes ## Stars and notes
@@ -75,8 +73,7 @@ when the terminal loses focus (`values.hideOnBlur`); see [config](config.md).
| `f` | star the selected source, vault or secret: it gets a ★ and sorts first in its list | | `f` | star the selected source, vault or secret: it gets a ★ and sorts first in its list |
| `n` | write a short note on it, shown dimmed on its row and in the detail pane | | `n` | write a short note on it, shown dimmed on its row and in the detail pane |
A star belongs to the account you starred the item under; a note shows Both are kept in the [state file](config.md#state).
under every account. Both are kept in the [state file](config.md#state).
## Go to ## Go to
@@ -124,15 +121,16 @@ it's hidden.
On top, the status pane (`0`) shows the version, a newer release and how On top, the status pane (`0`) shows the version, a newer release and how
your accounts are doing; `e` there opens your [config](config.md) in your your accounts are doing; `e` there opens your [config](config.md) in your
editor, as in lazygit. Focused, the right pane lists more under the editor, as in lazygit. Focused, the right pane lists more under the
logo (when there's room): the version and updates (`U`), each account logo; `y` copies any row:
(`L` on one that needs it signs in), what's expiring (`enter` goes to
it), this session's errors, newest first (what a pane showed, or the - the version and updates (`U`)
footer flashed before you moved on; not kept after quitting), the config, - each account (`L` on one that needs it signs in)
state and cache files (`e` opens one in your editor), and links to the - what's expiring (`enter` goes to it)
docs, source, issues, releases and changelog (`o` opens one; on the - this session's errors, newest first (not kept after quitting)
version or update, its release), then the changelog of every release up - the config, state and cache files (`e` opens one in your editor)
to yours, built in so it needs no network: `/` filters it (`/fixed`, - links to the docs, source, issues, releases and changelog (`o` opens one)
`/search`). `y` copies any row. - the changelog of every release up to yours, built in: `/` filters it
(`/fixed`, `/search`)
| Key | Action | | Key | Action |
|---|---| |---|---|
@@ -141,9 +139,8 @@ to yours, built in so it needs no network: `/` filters it (`/fixed`,
| `+` / `_` | zoom the focused pane: normal → half → full screen, and back | | `+` / `_` | zoom the focused pane: normal → half → full screen, and back |
| `=` | reset the layout (divider, heights, zoom) | | `=` | reset the layout (divider, heights, zoom) |
The divider and pane heights are remembered (zoom isn't). macOS uses The divider and pane heights are remembered (zoom isn't). `<>{}` are for
ctrl+arrows for Mission Control and switching desktops by default, hence macOS, where ctrl+arrows switch desktops.
the `<` `>` `{` `}` alternatives.
## Rebinding keys ## Rebinding keys
+2 -10
View File
@@ -100,13 +100,5 @@ sops 3.8 or later.
## Links ## Links
`S` → `f` shares env files as an [encrypted link](sharing.md#sharing-env-files). Repo links name the repo by its git remote, so they open in a colleague's
clone wherever it is ([sharing](sharing.md)).
Links name the repo by its git remote, so they open in a colleague's clone
wherever it is:
```
lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password
```
See [sharing](sharing.md).
+9 -5
View File
@@ -7,10 +7,13 @@ permissions: the link names the item, never its value. Env files can
also be [shared encrypted](#sharing-env-files), in the link itself. also be [shared encrypted](#sharing-env-files), in the link itself.
``` ```
lazysecrets://azure/subscriptions/<subscription-id> subscription
lazysecrets://azure/kv-app-prod.vault.azure.net vault lazysecrets://azure/kv-app-prod.vault.azure.net vault
lazysecrets://azure/kv-app-prod.vault.azure.net/keys vault, Keys tab lazysecrets://azure/kv-app-prod.vault.azure.net/keys vault, Keys tab
lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password secret lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password secret
lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password/91d3e0 one version lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password/91d3e0 one version
lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net workspace
lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app scope
lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage-key lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage-key
lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password
``` ```
@@ -102,8 +105,8 @@ it now and then: new links use the newest version.
## Opening a link ## Opening a link
- Paste it into lazysecrets, or press `ctrl+o`, which reads the clipboard - Paste it into lazysecrets, or press `ctrl+o`, which reads the clipboard
and fills in any reference it finds there. Key Vault URLs, and fills in any reference it finds there. Key Vault URLs, Azure portal
`@Microsoft.KeyVault(...)` and `{{secrets/scope/key}}` work too. links, `@Microsoft.KeyVault(...)` and `{{secrets/scope/key}}` work too.
- `lazysecrets <link>` (or `lazysecrets open <link>`) in a terminal. - `lazysecrets <link>` (or `lazysecrets open <link>`) in a terminal.
- Click it anywhere, once the [link handler](#from-the-browser-and-chat) is - Click it anywhere, once the [link handler](#from-the-browser-and-chat) is
set up. set up.
@@ -143,9 +146,10 @@ turns the question off too.
link then opens a new window of the terminal you ran it in, with link then opens a new window of the terminal you ran it in, with
lazysecrets on that link. It recognises kitty, Ghostty, WezTerm, iTerm2, lazysecrets on that link. It recognises kitty, Ghostty, WezTerm, iTerm2,
Terminal.app, Alacritty, GNOME Terminal, Konsole and foot, also inside Terminal.app, Alacritty, GNOME Terminal, Konsole and foot, also inside
tmux; `--terminal NAME` picks another. It records your `PATH` and config tmux; `--terminal NAME` picks another. It records your `PATH`, config
locations, since apps start without your shell's environment (and so locations and XDG directories, since apps start without your shell's
without `az`): run it again after moving lazysecrets or changing those. environment (and so without `az`): run it again after moving lazysecrets
or changing those.
- macOS: a small background app, `~/Applications/Lazysecrets Links.app` - macOS: a small background app, `~/Applications/Lazysecrets Links.app`
- Linux: a desktop entry claiming `x-scheme-handler/lazysecrets`, set as - Linux: a desktop entry claiming `x-scheme-handler/lazysecrets`, set as
+2 -1
View File
@@ -48,7 +48,8 @@ type UI struct {
// RecentIcon marks items used lately (Recent, R) with ◷, in the lists // RecentIcon marks items used lately (Recent, R) with ◷, in the lists
// and in lazysecrets list. Defaults to false. // and in lazysecrets list. Defaults to false.
RecentIcon *bool `yaml:"recentIcon"` RecentIcon *bool `yaml:"recentIcon"`
// SourcesView is "grouped" (default) or "flat"; ` + "`" + ` toggles it. // SourcesView is "grouped" (default) or "flat"; the backtick key
// toggles it.
SourcesView string `yaml:"sourcesView"` SourcesView string `yaml:"sourcesView"`
// CollapseGroups starts the Sources pane with its groups folded; // CollapseGroups starts the Sources pane with its groups folded;
// defaults to true. // defaults to true.
+8 -5
View File
@@ -35,15 +35,18 @@ func Dir(stateDir string) string {
type Status struct { type Status struct {
PID int `json:"pid"` PID int `json:"pid"`
LastActive time.Time `json:"lastActive"` // last key press or focus LastActive time.Time `json:"lastActive"` // last key press or focus
// Reach is how readily it can be shown: 2 in view (outside tmux, or Reach int `json:"reach"` // how readily it can be shown: unreachable, switchable or inView
// in a tmux session a client shows), 1 in a tmux session that a tmux
// client can be switched to, 0 not at all.
Reach int `json:"reach"`
// App is the terminal app it would show in (macOS), preferring the // App is the terminal app it would show in (macOS), preferring the
// app the link was clicked in. // app the link was clicked in.
App string `json:"app,omitempty"` App string `json:"app,omitempty"`
} }
const (
unreachable = 0
switchable = 1 // in a tmux session that a tmux client can be switched to
inView = 2 // outside tmux, or in a tmux session a client shows
)
type request struct { type request struct {
Op string `json:"op"` // "status" or "open" Op string `json:"op"` // "status" or "open"
Link string `json:"link,omitempty"` Link string `json:"link,omitempty"`
@@ -187,7 +190,7 @@ func Best(found []Found, front string, terminal bool) (Found, bool) {
best = f best = f
} }
} }
return best, best.Reach > 0 return best, best.Reach > unreachable
} }
// Open hands link, clicked in front, to f. // Open hands link, clicked in front, to f.
+22 -23
View File
@@ -1,6 +1,7 @@
package instance package instance
import ( import (
"cmp"
"os" "os"
"os/exec" "os/exec"
"slices" "slices"
@@ -25,7 +26,8 @@ func tmux(args ...string) (string, error) {
func inTmux() bool { return os.Getenv("TMUX") != "" && os.Getenv("TMUX_PANE") != "" } func inTmux() bool { return os.Getenv("TMUX") != "" && os.Getenv("TMUX_PANE") != "" }
type client struct { type client struct {
name, session, activity string // activity: Unix seconds name, session string
activity int // Unix seconds
pid int pid int
app string app string
} }
@@ -42,30 +44,31 @@ func clients() []client {
if len(f) < 4 { if len(f) < 4 {
continue continue
} }
pid, err := strconv.Atoi(f[1]) activity, err1 := strconv.Atoi(f[0])
if err != nil { pid, err2 := strconv.Atoi(f[1])
if err1 != nil || err2 != nil {
continue continue
} }
cs = append(cs, client{name: f[2], session: f[3], activity: f[0], pid: pid, app: linkhandler.HostApp(pid)}) cs = append(cs, client{name: f[2], session: f[3], activity: activity, pid: pid, app: linkhandler.HostApp(pid)})
} }
// Activity has the same width for centuries: strings compare like slices.SortStableFunc(cs, func(a, b client) int { return cmp.Compare(b.activity, a.activity) })
// numbers.
slices.SortStableFunc(cs, func(a, b client) int { return strings.Compare(b.activity, a.activity) })
return cs return cs
} }
// place is where this instance would show: the tmux client (zero outside // place is where this instance would show: the tmux client (zero outside
// tmux), the app, and reach: 2 already on screen there (outside tmux, or // tmux), the app, and its reach.
// a client on its session), 1 after switching a client, 0 nowhere.
func place(front string) (client, string, int) { func place(front string) (client, string, int) {
if !inTmux() { if !inTmux() {
return client{}, linkhandler.HostApp(os.Getpid()), 2 return client{}, linkhandler.HostApp(os.Getpid()), inView
} }
session, err := tmux("display-message", "-p", "-t", os.Getenv("TMUX_PANE"), "#{session_name}") session, err := tmux("display-message", "-p", "-t", os.Getenv("TMUX_PANE"), "#{session_name}")
if err != nil { if err != nil {
return client{}, "", 0 return client{}, "", unreachable
} }
cs := clients() cs := clients()
if len(cs) == 0 {
return client{}, "", unreachable
}
// Best first: in the front app on its session, in the front app, // Best first: in the front app on its session, in the front app,
// on its session, any. // on its session, any.
rank := func(c client) int { rank := func(c client) int {
@@ -78,20 +81,16 @@ func place(front string) (client, string, int) {
} }
return r return r
} }
var best client best := cs[0]
found := false for _, c := range cs[1:] {
for _, c := range cs { if rank(c) > rank(best) {
if !found || rank(c) > rank(best) { best = c
best, found = c, true
} }
} }
if !found {
return client{}, "", 0
}
if best.session == session { if best.session == session {
return best, best.app, 2 return best, best.app, inView
} }
return best, best.app, 1 return best, best.app, switchable
} }
// Raise brings this instance into view for a link clicked in front: in // Raise brings this instance into view for a link clicked in front: in
@@ -99,14 +98,14 @@ func place(front string) (client, string, int) {
// then its app to the front, showing a hidden dropdown terminal. // then its app to the front, showing a hidden dropdown terminal.
func Raise(front string) { func Raise(front string) {
c, app, reach := place(front) c, app, reach := place(front)
if reach == 0 { if reach == unreachable {
return return
} }
if inTmux() { if inTmux() {
pane := os.Getenv("TMUX_PANE") pane := os.Getenv("TMUX_PANE")
_, _ = tmux("select-window", "-t", pane) _, _ = tmux("select-window", "-t", pane)
_, _ = tmux("select-pane", "-t", pane) _, _ = tmux("select-pane", "-t", pane)
if reach == 1 { if reach == switchable {
_, _ = tmux("switch-client", "-c", c.name, "-t", pane) _, _ = tmux("switch-client", "-c", c.name, "-t", pane)
} }
} }
+1 -1
View File
@@ -114,7 +114,7 @@ func Check() string {
} }
out, err := exec.Command("plutil", "-extract", exeKey, "raw", filepath.Join(app, "Contents", "Info.plist")).Output() out, err := exec.Command("plutil", "-extract", exeKey, "raw", filepath.Join(app, "Contents", "Info.plist")).Output()
if err != nil { if err != nil {
return "" // installed before the key was recorded: assume it works return "" // can't tell: assume it works
} }
exe := strings.TrimSpace(string(out)) exe := strings.TrimSpace(string(out))
if _, err := os.Stat(exe); err != nil { if _, err := os.Stat(exe); err != nil {
+5 -3
View File
@@ -140,7 +140,7 @@ func appleString(s string) string {
return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"` return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"`
} }
// Launch opens a new terminal window running inner, without waiting for // Launch opens a new terminal window running argv, without waiting for
// it. // it.
func Launch(argv []string) error { func Launch(argv []string) error {
cmd := exec.Command(argv[0], argv[1:]...) cmd := exec.Command(argv[0], argv[1:]...)
@@ -165,9 +165,11 @@ func Executable() (string, error) {
// passEnv lists the variables carried from the install's environment to // passEnv lists the variables carried from the install's environment to
// lazysecrets in the new window: apps start with a minimal environment, // lazysecrets in the new window: apps start with a minimal environment,
// without Homebrew's PATH (so no az) or the user's config locations. // without Homebrew's PATH (so no az) or the user's XDG directories, so
// the window would use another cache and miss running instances' sockets.
var passEnv = []string{ var passEnv = []string{
"PATH", "LAZYSECRETS_CONFIG_FILE", "LAZYSECRETS_ICONS", "XDG_CONFIG_HOME", "XDG_STATE_HOME", "PATH", "LAZYSECRETS_CONFIG_FILE", "LAZYSECRETS_ICONS",
"XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME", "XDG_RUNTIME_DIR", "XDG_DATA_HOME",
"AZURE_CONFIG_DIR", "DATABRICKS_CONFIG_FILE", "AZURE_CONFIG_DIR", "DATABRICKS_CONFIG_FILE",
} }
+87 -109
View File
@@ -16,6 +16,7 @@ import (
"github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph"
@@ -99,13 +100,7 @@ func (p *Provider) Label() string { return cmp.Or(p.label, string(provider.KindA
// Sources lists the subscriptions the signed-in identity can see, skipping // Sources lists the subscriptions the signed-in identity can see, skipping
// disabled and deleted ones. // disabled and deleted ones.
func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) { func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
var out []provider.Source out, err := all(ctx, p.subs.NewListPager(nil), func(page armsubscriptions.ClientListResponse) (srcs []provider.Source) {
pager := p.subs.NewListPager(nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, s := range page.Value { for _, s := range page.Value {
switch deref(s.State) { switch deref(s.State) {
case armsubscriptions.SubscriptionStateDisabled, armsubscriptions.SubscriptionStateDeleted: case armsubscriptions.SubscriptionStateDisabled, armsubscriptions.SubscriptionStateDeleted:
@@ -118,7 +113,7 @@ func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
p.mu.Lock() p.mu.Lock()
p.tenants[id] = tenant p.tenants[id] = tenant
p.mu.Unlock() p.mu.Unlock()
out = append(out, provider.Source{ srcs = append(srcs, provider.Source{
Kind: provider.KindAzure, Kind: provider.KindAzure,
ID: id, ID: id,
Name: deref(s.DisplayName), Name: deref(s.DisplayName),
@@ -126,6 +121,10 @@ func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
URL: PortalResourceURL(tenant, "/subscriptions/"+id, "overview"), URL: PortalResourceURL(tenant, "/subscriptions/"+id, "overview"),
}) })
} }
return srcs
})
if err != nil {
return nil, err
} }
slices.SortFunc(out, func(a, b provider.Source) int { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) }) slices.SortFunc(out, func(a, b provider.Source) int { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) })
if len(p.opts.Vaults) > 0 { if len(p.opts.Vaults) > 0 {
@@ -230,22 +229,29 @@ func (p *Provider) armVaults(ctx context.Context, src provider.Source) ([]provid
if err != nil { if err != nil {
return nil, err return nil, err
} }
var out []provider.Container return all(ctx, client.NewListBySubscriptionPager(nil), func(page armkeyvault.VaultsClientListBySubscriptionResponse) (vaults []provider.Container) {
pager := client.NewListBySubscriptionPager(nil) for _, v := range page.Value {
if v.Properties == nil || v.Properties.VaultURI == nil {
continue
}
vaults = append(vaults, provider.Container{
Source: src, ID: deref(v.ID), Name: deref(v.Name),
Detail: *v.Properties.VaultURI, Types: objectTypes, Attrs: armInfo(v).attrs(),
})
}
return vaults
})
}
// all lists every page, items picking out what to keep of each.
func all[P, T any](ctx context.Context, pager *runtime.Pager[P], items func(P) []T) ([]T, error) {
var out []T
for pager.More() { for pager.More() {
page, err := pager.NextPage(ctx) page, err := pager.NextPage(ctx)
if err != nil { if err != nil {
return nil, cleanError(err) return nil, cleanError(err)
} }
for _, v := range page.Value { out = append(out, items(page)...)
if v.Properties == nil || v.Properties.VaultURI == nil {
continue
}
out = append(out, provider.Container{
Source: src, ID: deref(v.ID), Name: deref(v.Name),
Detail: *v.Properties.VaultURI, Types: objectTypes, Attrs: armInfo(v).attrs(),
})
}
} }
return out, nil return out, nil
} }
@@ -288,51 +294,40 @@ func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider
var out []provider.Object var out []provider.Object
switch t { switch t {
case provider.TypeSecret: case provider.TypeSecret:
pager := vc.secrets.NewListSecretPropertiesPager(nil) tenant := p.tenant(c.Source.ID)
for pager.More() { out, err = all(ctx, vc.secrets.NewListSecretPropertiesPager(nil), func(page azsecrets.ListSecretPropertiesResponse) (objs []provider.Object) {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, s := range page.Value { for _, s := range page.Value {
// Certificate-backing secrets appear under Certificates. // Certificate-backing secrets appear under Certificates.
if s.ID == nil || deref(s.Managed) { if s.ID != nil && !deref(s.Managed) {
continue objs = append(objs, secretObject(c, s, tenant))
}
out = append(out, secretObject(c, s, p.tenant(c.Source.ID)))
} }
} }
return objs
})
case provider.TypeKey: case provider.TypeKey:
pager := vc.keys.NewListKeyPropertiesPager(nil) out, err = all(ctx, vc.keys.NewListKeyPropertiesPager(nil), func(page azkeys.ListKeyPropertiesResponse) (objs []provider.Object) {
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, k := range page.Value { for _, k := range page.Value {
if k.KID == nil || deref(k.Managed) { if k.KID != nil && !deref(k.Managed) {
continue objs = append(objs, keyObject(c, k))
}
out = append(out, keyObject(c, k))
} }
} }
return objs
})
case provider.TypeCertificate: case provider.TypeCertificate:
pager := vc.certs.NewListCertificatePropertiesPager(nil) out, err = all(ctx, vc.certs.NewListCertificatePropertiesPager(nil), func(page azcertificates.ListCertificatePropertiesResponse) (objs []provider.Object) {
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, cp := range page.Value { for _, cp := range page.Value {
if cp.ID == nil { if cp.ID != nil {
continue objs = append(objs, certObject(c, cp))
}
out = append(out, certObject(c, cp))
} }
} }
return objs
})
default: default:
return nil, provider.ErrUnsupported return nil, provider.ErrUnsupported
} }
if err != nil {
return nil, err
}
slices.SortFunc(out, func(a, b provider.Object) int { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) }) slices.SortFunc(out, func(a, b provider.Object) int { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) })
return out, nil return out, nil
} }
@@ -353,7 +348,7 @@ func (p *Provider) Describe(ctx context.Context, o provider.Object) (provider.Ob
if resp.Key != nil { if resp.Key != nil {
if resp.Key.KID != nil { if resp.Key.KID != nil {
o.Version = resp.Key.KID.Version() o.Version = resp.Key.KID.Version()
o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), "Key", string(*resp.Key.KID)) o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), o.Type, string(*resp.Key.KID))
} }
o.Attrs = keyAttrs(resp.Key) o.Attrs = keyAttrs(resp.Key)
} }
@@ -364,7 +359,7 @@ func (p *Provider) Describe(ctx context.Context, o provider.Object) (provider.Ob
} }
if resp.ID != nil { if resp.ID != nil {
o.Version = resp.ID.Version() o.Version = resp.ID.Version()
o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), "Certificate", string(*resp.ID)) o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), o.Type, string(*resp.ID))
} }
if pol := resp.Policy; pol != nil && pol.SecretProperties != nil { if pol := resp.Policy; pol != nil && pol.SecretProperties != nil {
o.ContentType = deref(pol.SecretProperties.ContentType) o.ContentType = deref(pol.SecretProperties.ContentType)
@@ -381,56 +376,46 @@ func (p *Provider) Versions(ctx context.Context, o provider.Object) ([]provider.
if err != nil { if err != nil {
return nil, err return nil, err
} }
tenant := p.tenant(o.Container.Source.ID)
var out []provider.Object var out []provider.Object
switch o.Type { switch o.Type {
case provider.TypeSecret: case provider.TypeSecret:
pager := vc.secrets.NewListSecretPropertiesVersionsPager(o.Name, nil) out, err = all(ctx, vc.secrets.NewListSecretPropertiesVersionsPager(o.Name, nil), func(page azsecrets.ListSecretPropertiesVersionsResponse) (objs []provider.Object) {
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, s := range page.Value { for _, s := range page.Value {
if s.ID != nil { if s.ID != nil {
out = append(out, secretObject(o.Container, s, p.tenant(o.Container.Source.ID))) objs = append(objs, secretObject(o.Container, s, tenant))
}
} }
} }
return objs
})
case provider.TypeKey: case provider.TypeKey:
pager := vc.keys.NewListKeyPropertiesVersionsPager(o.Name, nil) out, err = all(ctx, vc.keys.NewListKeyPropertiesVersionsPager(o.Name, nil), func(page azkeys.ListKeyPropertiesVersionsResponse) (objs []provider.Object) {
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, k := range page.Value { for _, k := range page.Value {
if k.KID != nil { if k.KID != nil {
out = append(out, keyObject(o.Container, k)) objs = append(objs, keyObject(o.Container, k))
}
} }
} }
return objs
})
case provider.TypeCertificate: case provider.TypeCertificate:
pager := vc.certs.NewListCertificatePropertiesVersionsPager(o.Name, nil) out, err = all(ctx, vc.certs.NewListCertificatePropertiesVersionsPager(o.Name, nil), func(page azcertificates.ListCertificatePropertiesVersionsResponse) (objs []provider.Object) {
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, cp := range page.Value { for _, cp := range page.Value {
if cp.ID != nil { if cp.ID != nil {
out = append(out, certObject(o.Container, cp)) objs = append(objs, certObject(o.Container, cp))
}
} }
} }
return objs
})
default: default:
return nil, provider.ErrUnsupported return nil, provider.ErrUnsupported
} }
tenant := p.tenant(o.Container.Source.ID) if err != nil {
return nil, err
}
for i := range out { for i := range out {
out[i].Reference = versionedReference(out[i]) out[i].Reference = versionedReference(out[i])
if o.Type != provider.TypeSecret { // secretObject's is the version's already if o.Type != provider.TypeSecret { // secretObject's is the version's already
kind := map[provider.ObjectType]string{provider.TypeKey: "Key", provider.TypeCertificate: "Certificate"}[o.Type] out[i].URL = portalAssetURL(tenant, o.Type, out[i].ID+"/"+out[i].Version) // the version's page
out[i].URL = portalAssetURL(tenant, kind, out[i].ID+"/"+out[i].Version) // the version's page
} }
} }
slices.SortFunc(out, func(a, b provider.Object) int { return b.Updated.Compare(a.Updated) }) slices.SortFunc(out, func(a, b provider.Object) int { return b.Updated.Compare(a.Updated) })
@@ -452,47 +437,19 @@ func versionedReference(o provider.Object) string {
// Value returns a secret's value, a key's public key or a certificate's // Value returns a secret's value, a key's public key or a certificate's
// public certificate, as of o.Version (latest if empty). // public certificate, as of o.Version (latest if empty).
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) { func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
vc, err := p.clients(o.Container.Detail)
if err != nil {
return provider.SecretValue{}, err
}
switch o.Type {
case provider.TypeSecret:
v, _, err := p.VersionedValue(ctx, o) v, _, err := p.VersionedValue(ctx, o)
return v, err return v, err
case provider.TypeKey:
resp, err := vc.keys.GetKey(ctx, o.Name, o.Version, nil)
if err != nil {
return provider.SecretValue{}, cleanError(err)
}
pemKey, err := publicKeyPEM(resp.Key)
if err != nil {
return provider.SecretValue{}, err
}
return provider.NewSecretValue([]byte(pemKey)), nil
case provider.TypeCertificate:
resp, err := vc.certs.GetCertificate(ctx, o.Name, o.Version, nil)
if err != nil {
return provider.SecretValue{}, cleanError(err)
}
if len(resp.CER) == 0 {
return provider.SecretValue{}, errors.New("certificate has no public certificate yet (pending issuance?)")
}
return provider.NewSecretValue([]byte(certPEM(resp.CER))), nil
}
return provider.SecretValue{}, provider.ErrUnsupported
} }
// VersionedValue is Value, with the version read. // VersionedValue is Value, with the version read.
func (p *Provider) VersionedValue(ctx context.Context, o provider.Object) (provider.SecretValue, string, error) { func (p *Provider) VersionedValue(ctx context.Context, o provider.Object) (provider.SecretValue, string, error) {
if o.Type != provider.TypeSecret {
v, err := p.Value(ctx, o)
return v, o.Version, err
}
vc, err := p.clients(o.Container.Detail) vc, err := p.clients(o.Container.Detail)
if err != nil { if err != nil {
return provider.SecretValue{}, "", err return provider.SecretValue{}, "", err
} }
var text string
switch o.Type {
case provider.TypeSecret:
resp, err := vc.secrets.GetSecret(ctx, o.Name, o.Version, nil) resp, err := vc.secrets.GetSecret(ctx, o.Name, o.Version, nil)
if err != nil { if err != nil {
return provider.SecretValue{}, "", cleanError(err) return provider.SecretValue{}, "", cleanError(err)
@@ -502,6 +459,27 @@ func (p *Provider) VersionedValue(ctx context.Context, o provider.Object) (provi
version = resp.ID.Version() version = resp.ID.Version()
} }
return provider.NewSecretValue([]byte(deref(resp.Value))), version, nil return provider.NewSecretValue([]byte(deref(resp.Value))), version, nil
case provider.TypeKey:
resp, err := vc.keys.GetKey(ctx, o.Name, o.Version, nil)
if err != nil {
return provider.SecretValue{}, "", cleanError(err)
}
if text, err = publicKeyPEM(resp.Key); err != nil {
return provider.SecretValue{}, "", err
}
case provider.TypeCertificate:
resp, err := vc.certs.GetCertificate(ctx, o.Name, o.Version, nil)
if err != nil {
return provider.SecretValue{}, "", cleanError(err)
}
if len(resp.CER) == 0 {
return provider.SecretValue{}, "", errors.New("certificate has no public certificate yet (pending issuance?)")
}
text = certPEM(resp.CER)
default:
return provider.SecretValue{}, "", provider.ErrUnsupported
}
return provider.NewSecretValue([]byte(text)), o.Version, nil
} }
var ( var (
+9 -15
View File
@@ -55,8 +55,8 @@ func PortalResourceURL(tenant, resourceID, blade string) string {
// portalAssetURL links to a Key Vault object's page: a version's with // portalAssetURL links to a Key Vault object's page: a version's with
// one in objectID; a secret's own (its versions) without. // one in objectID; a secret's own (its versions) without.
func portalAssetURL(tenant, kind, objectID string) string { func portalAssetURL(tenant string, t provider.ObjectType, objectID string) string {
return portal + tenantPrefix(tenant) + "asset/Microsoft_Azure_KeyVault/" + kind + "/" + objectID return portal + tenantPrefix(tenant) + "asset/Microsoft_Azure_KeyVault/" + string(t) + "/" + objectID
} }
func tenantPrefix(tenant string) string { func tenantPrefix(tenant string) string {
@@ -73,19 +73,13 @@ func ObjectListURL(c provider.Container, t provider.ObjectType) string {
if !ok { if !ok {
return "" return ""
} }
switch t { return base + "/" + t.Collection()
case provider.TypeKey:
return base + "/keys"
case provider.TypeCertificate:
return base + "/certificates"
}
return base + "/secrets"
} }
// objectURL is the data-plane URL of an object, e.g. // objectURL is the data-plane URL of an object, e.g.
// https://kv.vault.azure.net/secrets/name. // https://kv.vault.azure.net/secrets/name.
func objectURL(c provider.Container, collection, name string) string { func objectURL(c provider.Container, t provider.ObjectType, name string) string {
return strings.TrimSuffix(c.Detail, "/") + "/" + collection + "/" + name return strings.TrimSuffix(c.Detail, "/") + "/" + t.Collection() + "/" + name
} }
// secretObject is a secret, or a version of one, from a listing. Its URL // secretObject is a secret, or a version of one, from a listing. Its URL
@@ -93,7 +87,7 @@ func objectURL(c provider.Container, collection, name string) string {
// a version, that version's. // a version, that version's.
func secretObject(c provider.Container, p *azsecrets.SecretProperties, tenant string) provider.Object { func secretObject(c provider.Container, p *azsecrets.SecretProperties, tenant string) provider.Object {
name := p.ID.Name() name := p.ID.Name()
url := objectURL(c, "secrets", name) url := objectURL(c, provider.TypeSecret, name)
o := provider.Object{ o := provider.Object{
Container: c, Container: c,
Type: provider.TypeSecret, Type: provider.TypeSecret,
@@ -103,7 +97,7 @@ func secretObject(c provider.Container, p *azsecrets.SecretProperties, tenant st
ContentType: deref(p.ContentType), ContentType: deref(p.ContentType),
Tags: tags(p.Tags), Tags: tags(p.Tags),
Reference: "@Microsoft.KeyVault(SecretUri=" + url + ")", Reference: "@Microsoft.KeyVault(SecretUri=" + url + ")",
URL: portalAssetURL(tenant, "Secret", strings.TrimSuffix(url+"/"+p.ID.Version(), "/")), URL: portalAssetURL(tenant, provider.TypeSecret, strings.TrimSuffix(url+"/"+p.ID.Version(), "/")),
} }
if a := p.Attributes; a != nil { if a := p.Attributes; a != nil {
o.Enabled, o.Updated, o.Expires = a.Enabled, deref(a.Updated), deref(a.Expires) o.Enabled, o.Updated, o.Expires = a.Enabled, deref(a.Updated), deref(a.Expires)
@@ -113,7 +107,7 @@ func secretObject(c provider.Container, p *azsecrets.SecretProperties, tenant st
func keyObject(c provider.Container, p *azkeys.KeyProperties) provider.Object { func keyObject(c provider.Container, p *azkeys.KeyProperties) provider.Object {
name := p.KID.Name() name := p.KID.Name()
url := objectURL(c, "keys", name) url := objectURL(c, provider.TypeKey, name)
o := provider.Object{ o := provider.Object{
Container: c, Container: c,
Type: provider.TypeKey, Type: provider.TypeKey,
@@ -132,7 +126,7 @@ func keyObject(c provider.Container, p *azkeys.KeyProperties) provider.Object {
func certObject(c provider.Container, p *azcertificates.CertificateProperties) provider.Object { func certObject(c provider.Container, p *azcertificates.CertificateProperties) provider.Object {
name := p.ID.Name() name := p.ID.Name()
url := objectURL(c, "certificates", name) url := objectURL(c, provider.TypeCertificate, name)
o := provider.Object{ o := provider.Object{
Container: c, Container: c,
Type: provider.TypeCertificate, Type: provider.TypeCertificate,
+11 -7
View File
@@ -71,13 +71,10 @@ func graphInfo(m map[string]any) vaultInfo {
// armInfo reads a vault's settings from the management API. // armInfo reads a vault's settings from the management API.
func armInfo(v *armkeyvault.Vault) vaultInfo { func armInfo(v *armkeyvault.Vault) vaultInfo {
info := vaultInfo{location: deref(v.Location), tags: map[string]string{}} info := vaultInfo{location: deref(v.Location), tags: tags(v.Tags)}
if id, err := arm.ParseResourceID(deref(v.ID)); err == nil { if id, err := arm.ParseResourceID(deref(v.ID)); err == nil {
info.group = id.ResourceGroupName info.group = id.ResourceGroupName
} }
for k, t := range v.Tags {
info.tags[k] = deref(t)
}
pr := v.Properties pr := v.Properties
if pr == nil { if pr == nil {
return info return info
@@ -123,7 +120,11 @@ func (v vaultInfo) attrs() []provider.Attr {
} else { } else {
add("soft delete", "off") add("soft delete", "off")
} }
add("purge prot.", map[bool]string{true: "on", false: "off"}[on(v.purge, false)]) if on(v.purge, false) {
add("purge prot.", "on")
} else {
add("purge prot.", "off")
}
switch { switch {
case strings.EqualFold(v.publicAccess, "disabled"): case strings.EqualFold(v.publicAccess, "disabled"):
add("network", "private endpoints only") add("network", "private endpoints only")
@@ -132,8 +133,11 @@ func (v vaultInfo) attrs() []provider.Attr {
default: default:
add("network", "all networks") add("network", "all networks")
} }
if v.endpoints > 0 { switch {
add("private", fmt.Sprintf("%d endpoint%s", v.endpoints, map[bool]string{true: "s"}[v.endpoints > 1])) case v.endpoints == 1:
add("private", "1 endpoint")
case v.endpoints > 1:
add("private", fmt.Sprintf("%d endpoints", v.endpoints))
} }
for i, k := range slices.Sorted(maps.Keys(v.tags)) { for i, k := range slices.Sorted(maps.Keys(v.tags)) {
label := "" label := ""
+14 -11
View File
@@ -203,17 +203,20 @@ func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provi
return out, nil return out, nil
} }
// vaultURL is the backing vault of an AKV-backed scope, or "". It falls // fallbackVault is the backing vault of an AKV-backed scope, to read it
// back to the scope's Detail, so a scope remembered from an earlier session // from when Databricks fails, or "" when there's none to fall back to. It
// (a favourite) works before its workspace's scopes are listed. // falls back to the scope's Detail, so a scope remembered from an earlier
func (p *Provider) vaultURL(c provider.Container) string { // session (a favourite) works before its workspace's scopes are listed.
func (p *Provider) fallbackVault(ctx context.Context, c provider.Container) string {
if p.keyVault == nil || ctx.Err() != nil {
return ""
}
p.mu.Lock() p.mu.Lock()
url := p.vaults[c.ID] url := p.vaults[c.ID]
p.mu.Unlock() p.mu.Unlock()
if url == "" { if url == "" {
url, _ = strings.CutPrefix(c.Detail, akvDetail) if detail, ok := strings.CutPrefix(c.Detail, akvDetail); ok {
if url == c.Detail { url = detail
url = ""
} }
} }
return url return url
@@ -264,8 +267,8 @@ func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider
// vaultObjects lists an AKV-backed scope's secrets from the vault itself. // vaultObjects lists an AKV-backed scope's secrets from the vault itself.
func (p *Provider) vaultObjects(ctx context.Context, c provider.Container) ([]provider.Object, error) { func (p *Provider) vaultObjects(ctx context.Context, c provider.Container) ([]provider.Object, error) {
url := p.vaultURL(c) url := p.fallbackVault(ctx, c)
if url == "" || p.keyVault == nil || ctx.Err() != nil { if url == "" {
return nil, errNoFallback return nil, errNoFallback
} }
secrets, err := p.keyVault.Objects(ctx, provider.Container{Detail: url}, provider.TypeSecret) secrets, err := p.keyVault.Objects(ctx, provider.Container{Detail: url}, provider.TypeSecret)
@@ -302,8 +305,8 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
} }
dbxErr := classify(pr, err) dbxErr := classify(pr, err)
url := p.vaultURL(o.Container) url := p.fallbackVault(ctx, o.Container)
if url == "" || p.keyVault == nil || ctx.Err() != nil { if url == "" {
return provider.SecretValue{}, dbxErr return provider.SecretValue{}, dbxErr
} }
v, kvErr := p.keyVault.Value(ctx, provider.Object{ v, kvErr := p.keyVault.Value(ctx, provider.Object{
+5 -9
View File
@@ -225,9 +225,7 @@ func (p *Provider) add(c provider.Container, t provider.ObjectType, o provider.O
o.Type = t o.Type = t
o.ID = c.ID + "/" + strings.ToLower(string(t)) + "/" + o.Name o.ID = c.ID + "/" + strings.ToLower(string(t)) + "/" + o.Name
if base, ok := strings.CutSuffix(c.URL, "/overview"); ok { if base, ok := strings.CutSuffix(c.URL, "/overview"); ok {
o.URL = base + map[provider.ObjectType]string{ o.URL = base + "/" + t.Collection()
provider.TypeSecret: "/secrets", provider.TypeKey: "/keys", provider.TypeCertificate: "/certificates",
}[t]
} }
k := listKey{c.ID, t} k := listKey{c.ID, t}
p.objects[k] = append(p.objects[k], o) p.objects[k] = append(p.objects[k], o)
@@ -256,12 +254,13 @@ const (
var allTypes = []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} var allTypes = []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate}
func secretRef(vault, name string) string {
return fmt.Sprintf("@Microsoft.KeyVault(SecretUri=https://%s.vault.azure.net/secrets/%s)", vault, name)
}
// Azure returns a fake Azure provider with two subscriptions. // Azure returns a fake Azure provider with two subscriptions.
func Azure(delay time.Duration) *Provider { func Azure(delay time.Duration) *Provider {
p := newProvider(provider.KindAzure, delay) p := newProvider(provider.KindAzure, delay)
secretRef := func(vault, name string) string {
return fmt.Sprintf("@Microsoft.KeyVault(SecretUri=https://%s.vault.azure.net/secrets/%s)", vault, name)
}
objectURL := func(vault, kind, name string) string { objectURL := func(vault, kind, name string) string {
return fmt.Sprintf("https://%s.vault.azure.net/%s/%s", vault, kind, name) return fmt.Sprintf("https://%s.vault.azure.net/%s/%s", vault, kind, name)
} }
@@ -373,9 +372,6 @@ func Azure(delay time.Duration) *Provider {
// its own, like an admin account next to a personal one. // its own, like an admin account next to a personal one.
func AzureOps(delay time.Duration) *Provider { func AzureOps(delay time.Duration) *Provider {
p := newProvider(provider.KindAzure, delay) p := newProvider(provider.KindAzure, delay)
secretRef := func(vault, name string) string {
return fmt.Sprintf("@Microsoft.KeyVault(SecretUri=https://%s.vault.azure.net/secrets/%s)", vault, name)
}
prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com") prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com")
breakGlass := p.addContainer(prod, "kv-breakglass", "https://kv-breakglass.vault.azure.net", allTypes...) breakGlass := p.addContainer(prod, "kv-breakglass", "https://kv-breakglass.vault.azure.net", allTypes...)
+14
View File
@@ -84,6 +84,20 @@ const (
TypeCertificate ObjectType = "Certificate" TypeCertificate ObjectType = "Certificate"
) )
// Collection is the type's path segment in Key Vault URLs and links, e.g.
// "secrets".
func (t ObjectType) Collection() string {
switch t {
case TypeSecret:
return "secrets"
case TypeKey:
return "keys"
case TypeCertificate:
return "certificates"
}
return ""
}
// Container holds objects: a Key Vault or a Databricks secret scope. // Container holds objects: a Key Vault or a Databricks secret scope.
type Container struct { type Container struct {
Source Source Source Source
+46 -39
View File
@@ -1,6 +1,6 @@
// Package repo browses the encrypted secret files in git repositories: // Package repo browses the encrypted secret files in git repositories:
// a repo is a source, its secret files the containers (vault level), and // a repo is a source, its secret files the containers (vault level), and
// their keys the secrets. SOPS files are the one type so far. // their keys the secrets. Secret files are SOPS files and plain env files.
// //
// Listing never decrypts: SOPS encrypts values only, so a file's keys and // Listing never decrypts: SOPS encrypts values only, so a file's keys and
// metadata are read directly. A value is decrypted when it's revealed or // metadata are read directly. A value is decrypted when it's revealed or
@@ -106,8 +106,6 @@ func (p *Provider) load() {
roots = append(roots, root) roots = append(roots, root)
} }
} }
// The repo lazysecrets started in, if any: marked, so the UI can
// put it on top, with its files under Dir first.
here, inRepo := p.current() here, inRepo := p.current()
if inRepo { if inRepo {
seen[here.root] = true seen[here.root] = true
@@ -137,7 +135,6 @@ func (p *Provider) load() {
add(root) add(root)
} }
} }
// Repos you starred something in, wherever they are.
for _, dir := range p.s.Starred { for _, dir := range p.s.Starred {
if root, err := gitRoot(dir); err == nil { if root, err := gitRoot(dir); err == nil {
add(root) add(root)
@@ -359,30 +356,23 @@ func (p *Provider) Objects(_ context.Context, c provider.Container, t provider.O
if err != nil { if err != nil {
return nil, err return nil, err
} }
meta, isSops := readMeta(c.ID) meta, isSops := parseMeta(c.ID, b)
keys, err := parseKeys(c.ID, b) keys, err := parseKeys(c.ID, b)
if err != nil { if err != nil {
return nil, fmt.Errorf("%s: %w", c.Name, err) return nil, fmt.Errorf("%s: %w", c.Name, err)
} }
if !isSops { // a plain env file attrs := []provider.Attr{{Label: "format", Value: "env"}, {Label: "encrypted", Value: "no: plain text in the file"}}
out := make([]provider.Object, 0, len(keys)) if isSops {
for _, k := range keys { attrs = []provider.Attr{{Label: "format", Value: "sops " + formatOf(c.ID)}, {Label: "encryption", Value: strings.Join(meta.methods, ", ")}}
out = append(out, provider.Object{
Container: c, Type: provider.TypeSecret, ID: c.ID + "\x00" + k.extract, Name: k.name,
Attrs: []provider.Attr{{Label: "format", Value: "env"}, {Label: "encrypted", Value: "no: plain text in the file"}},
})
}
return out, nil
} }
out := make([]provider.Object, 0, len(keys)) out := make([]provider.Object, 0, len(keys))
for _, k := range keys { for _, k := range keys {
o := provider.Object{ o := provider.Object{
Container: c, Type: provider.TypeSecret, Container: c, Type: provider.TypeSecret,
ID: c.ID + "\x00" + k.extract, Name: k.name, Updated: meta.modified, ID: c.ID + "\x00" + k.extract, Name: k.name, Updated: meta.modified, Attrs: attrs,
Attrs: []provider.Attr{{Label: "format", Value: "sops " + formatOf(c.ID)}, {Label: "encryption", Value: strings.Join(meta.methods, ", ")}},
} }
if !k.encrypted { if isSops && !k.encrypted {
o.Attrs = append(o.Attrs, provider.Attr{Label: "encrypted", Value: "no (left in plain text by the file's sops rules)"}) o.Attrs = append(slices.Clip(attrs), provider.Attr{Label: "encrypted", Value: "no (left in plain text by the file's sops rules)"})
} }
out = append(out, o) out = append(out, o)
} }
@@ -401,10 +391,7 @@ func (p *Provider) Versions(context.Context, provider.Object) ([]provider.Object
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) { func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
file, extract, ok := strings.Cut(o.ID, "\x00") file, extract, ok := strings.Cut(o.ID, "\x00")
if !ok { // made from a link: the repo's root, the file's path, the key if !ok { // made from a link: the repo's root, the file's path, the key
file = o.Container.ID file = filePath(o.Container)
if file == "" {
file = filepath.Join(o.Container.Source.ID, filepath.FromSlash(o.Container.Name))
}
extract = extractFor(file, o.Name) extract = extractFor(file, o.Name)
} }
if _, isSops := readMeta(file); !isSops { if _, isSops := readMeta(file); !isSops {
@@ -432,12 +419,18 @@ func (p *Provider) Contents(_ context.Context, c provider.Container) ([]byte, er
return os.ReadFile(f.Path) return os.ReadFile(f.Path)
} }
// filePath is the file c is. A container made from a link has no ID: the
// file is its path in the repo.
func filePath(c provider.Container) string {
if c.ID != "" {
return c.ID
}
return filepath.Join(c.Source.ID, filepath.FromSlash(c.Name))
}
// File is the file c is, and the line o's key is on. // File is the file c is, and the line o's key is on.
func (p *Provider) File(c provider.Container, o *provider.Object) (provider.File, error) { func (p *Provider) File(c provider.Container, o *provider.Object) (provider.File, error) {
path := c.ID path := filePath(c)
if path == "" { // made from a link
path = filepath.Join(c.Source.ID, filepath.FromSlash(c.Name))
}
f := provider.File{Path: path} f := provider.File{Path: path}
if _, isSops := readMeta(path); isSops { if _, isSops := readMeta(path); isSops {
f.Sops = p.s.Sops f.Sops = p.s.Sops
@@ -607,34 +600,39 @@ type lazysecretsFile struct {
// ShareKey is the sharing.key of the repo's .lazysecrets.yaml, if any. // ShareKey is the sharing.key of the repo's .lazysecrets.yaml, if any.
func ShareKey(root string) (string, error) { func ShareKey(root string) (string, error) {
f, err := readLazysecretsFile(root)
return f.Sharing.Key, err
}
// readLazysecretsFile reads the repo's .lazysecrets.yaml; without one, it's
// empty.
func readLazysecretsFile(root string) (lazysecretsFile, error) {
var f lazysecretsFile
b, err := os.ReadFile(filepath.Join(root, ".lazysecrets.yaml")) b, err := os.ReadFile(filepath.Join(root, ".lazysecrets.yaml"))
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
return "", nil return f, nil
} }
if err != nil { if err != nil {
return "", err return f, err
} }
var f lazysecretsFile
if err := yaml.Unmarshal(b, &f); err != nil { if err := yaml.Unmarshal(b, &f); err != nil {
return "", fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err) return f, fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err)
} }
return f.Sharing.Key, nil return f, nil
} }
// selector says which files to look at: user (the include/exclude of the // selector says which files to look at: user (the include/exclude of the
// repo's .lazysecrets.yaml or the config) for every file, and sopsWhere // repo's .lazysecrets.yaml or the config) for every file, and sopsWhere
// (.sops.yaml's creation_rules, when there's no include) for SOPS files. // (.sops.yaml's creation_rules, when there's no include) for SOPS files.
func (p *Provider) selector(root string, files []string) (user, sopsWhere func(string) bool, err error) { func (p *Provider) selector(root string, files []string) (user, sopsWhere func(string) bool, err error) {
include, exclude := p.s.Include, p.s.Exclude f, err := readLazysecretsFile(root)
if b, err := os.ReadFile(filepath.Join(root, ".lazysecrets.yaml")); err == nil { if err != nil {
var f lazysecretsFile return nil, nil, err
if err := yaml.Unmarshal(b, &f); err != nil {
return nil, nil, fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err)
} }
include, exclude := p.s.Include, p.s.Exclude
if len(f.Include) > 0 || len(f.Exclude) > 0 { if len(f.Include) > 0 || len(f.Exclude) > 0 {
include, exclude = f.Include, f.Exclude include, exclude = f.Include, f.Exclude
} }
}
inc, err := globs(include) inc, err := globs(include)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
@@ -905,12 +903,21 @@ func readMeta(file string) (sopsMeta, bool) {
return sopsMeta{}, false // not a format sops writes: skip reading return sopsMeta{}, false // not a format sops writes: skip reading
} }
b, err := os.ReadFile(file) b, err := os.ReadFile(file)
if err != nil || !bytes.Contains(b, []byte("sops")) { if err != nil {
return sopsMeta{}, false
}
return parseMeta(file, b)
}
// parseMeta is readMeta, of the file's contents b.
func parseMeta(file string, b []byte) (sopsMeta, bool) {
format := formatOf(file)
if len(b) > maxFileSize || format == "" || !bytes.Contains(b, []byte("sops")) {
return sopsMeta{}, false // cheap: most files aren't sops, no parsing return sopsMeta{}, false // cheap: most files aren't sops, no parsing
} }
var m sopsMeta var m sopsMeta
known := []string{"age", "azure_kv", "kms", "gcp_kms", "hc_vault", "pgp"} known := []string{"age", "azure_kv", "kms", "gcp_kms", "hc_vault", "pgp"}
switch formatOf(file) { switch format {
case "yaml", "json": case "yaml", "json":
var doc struct { var doc struct {
Sops map[string]any `yaml:"sops"` Sops map[string]any `yaml:"sops"`
+7 -13
View File
@@ -92,15 +92,9 @@ func (r Ref) ContainerName() string {
// Container.Detail. // Container.Detail.
func (r Ref) VaultURL() string { return "https://" + r.Host + "/" } func (r Ref) VaultURL() string { return "https://" + r.Host + "/" }
var collections = map[provider.ObjectType]string{
provider.TypeSecret: "secrets",
provider.TypeKey: "keys",
provider.TypeCertificate: "certificates",
}
func typeOf(collection string) (provider.ObjectType, bool) { func typeOf(collection string) (provider.ObjectType, bool) {
for t, c := range collections { for _, t := range []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} {
if strings.EqualFold(c, collection) { if strings.EqualFold(t.Collection(), collection) {
return t, true return t, true
} }
} }
@@ -118,7 +112,7 @@ func (r Ref) IdentifierURL() string {
if r.Type == "" { if r.Type == "" {
return u + "/" return u + "/"
} }
for _, seg := range []string{collections[r.Type], r.Name, r.Version} { for _, seg := range []string{r.Type.Collection(), r.Name, r.Version} {
if seg != "" { if seg != "" {
u += "/" + url.PathEscape(seg) u += "/" + url.PathEscape(seg)
} }
@@ -140,7 +134,7 @@ func (r Ref) String() string {
segs = append(segs, r.Scope, r.Name) segs = append(segs, r.Scope, r.Name)
default: default:
if r.Type != "" { if r.Type != "" {
segs = append(segs, collections[r.Type]) segs = append(segs, r.Type.Collection())
} }
segs = append(segs, r.Name, r.Version) segs = append(segs, r.Name, r.Version)
} }
@@ -273,7 +267,7 @@ func parseLink(s string) (Ref, error) {
r.Host = strings.ToLower(segs[0]) r.Host = strings.ToLower(segs[0])
rest := segs[1:] rest := segs[1:]
switch strings.ToLower(u.Host) { switch host := strings.ToLower(u.Host); host {
case "azure": case "azure":
r.Kind = provider.KindAzure r.Kind = provider.KindAzure
if r.Host == "subscriptions" { if r.Host == "subscriptions" {
@@ -300,11 +294,11 @@ func parseLink(s string) (Ref, error) {
} }
case "databricks", "repo": case "databricks", "repo":
r.Kind = provider.KindDatabricks r.Kind = provider.KindDatabricks
if strings.EqualFold(u.Host, "repo") { if host == "repo" {
r.Kind = provider.KindRepo r.Kind = provider.KindRepo
} }
if len(rest) > 2 { if len(rest) > 2 {
return Ref{}, fmt.Errorf("%s link has too many parts", strings.ToLower(u.Host)) return Ref{}, fmt.Errorf("%s link has too many parts", host)
} }
if len(rest) > 0 { if len(rest) > 0 {
r.Scope = rest[0] r.Scope = rest[0]
+5 -1
View File
@@ -48,7 +48,11 @@ func (r Rules) Check(key []byte) error {
needs = append(needs, fmt.Sprintf("%d (minLength)", r.MinLength)) needs = append(needs, fmt.Sprintf("%d (minLength)", r.MinLength))
} }
if kinds < r.MinClasses { if kinds < r.MinClasses {
has = append(has, fmt.Sprintf("%d %s of character", kinds, map[bool]string{true: "kind", false: "kinds"}[kinds == 1])) kind := "kinds"
if kinds == 1 {
kind = "kind"
}
has = append(has, fmt.Sprintf("%d %s of character", kinds, kind))
needs = append(needs, fmt.Sprintf("%d of lowercase, uppercase, digits and symbols (minClasses)", r.MinClasses)) needs = append(needs, fmt.Sprintf("%d of lowercase, uppercase, digits and symbols (minClasses)", r.MinClasses))
} }
if len(has) == 0 { if len(has) == 0 {
+10 -14
View File
@@ -8,7 +8,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"image/color" "image/color"
"math"
"os" "os"
"os/exec" "os/exec"
"strings" "strings"
@@ -725,7 +724,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.versionsOf != nil { if m.versionsOf != nil {
m.setVersions(msg.items) m.setVersions(msg.items)
} else { } else {
m.secrets.SetItems(m.starredFirst(msg.items)) m.secrets.SetItems(m.starredObjectsFirst(msg.items))
cmds = append(cmds, m.refreshShown(before)) cmds = append(cmds, m.refreshShown(before))
} }
} }
@@ -1257,7 +1256,7 @@ func (m *Model) showObjects(c provider.Container, key string) tea.Cmd {
m.secrets.SetLoading() m.secrets.SetLoading()
return m.loadObjects(c, m.objType, key) return m.loadObjects(c, m.objType, key)
} }
m.secrets.SetItems(m.starredFirst(e.value)) m.secrets.SetItems(m.starredObjectsFirst(e.value))
m.secrets.Select(m.cache.cursor[key]) m.secrets.Select(m.cache.cursor[key])
if e.fresh(m.now()) { if e.fresh(m.now()) {
return nil return nil
@@ -1317,8 +1316,7 @@ func (m *Model) countVersions(o provider.Object) tea.Cmd {
}) })
} }
// closeVersions returns the Secrets pane to the object list, with the // closeVersions returns the Secrets pane to the object list.
// cursor on the object whose versions were shown.
func (m *Model) closeVersions() { func (m *Model) closeVersions() {
m.versionsOf = nil m.versionsOf = nil
} }
@@ -1677,12 +1675,8 @@ func (m *Model) copyText(label, text string, sensitive bool) tea.Cmd {
// clipTick fires when the countdown's displayed second next changes. // clipTick fires when the countdown's displayed second next changes.
func (m *Model) clipTick() tea.Cmd { func (m *Model) clipTick() tea.Cmd {
wait := m.clipDeadline.Sub(m.now()) % time.Second
if wait <= 0 {
wait = time.Second
}
token := m.clipToken token := m.clipToken
return tea.Tick(wait, func(time.Time) tea.Msg { return clipTickMsg{token: token} }) return tea.Tick(untilNextSecond(m.clipDeadline, m.now()), func(time.Time) tea.Msg { return clipTickMsg{token: token} })
} }
func (m *Model) cancelClipClear() { func (m *Model) cancelClipClear() {
@@ -1789,13 +1783,15 @@ func (m Model) footerView() string {
} else if l != nil && l.Filtering() { } else if l != nil && l.Filtering() {
help = m.helpBar(keys.FilterHelp{Map: m.keys}.ShortHelp(), 2) help = m.helpBar(keys.FilterHelp{Map: m.keys}.ShortHelp(), 2)
} else { } else {
revealed := m.detail.State() == panes.ValueShown reveal := "show"
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) if m.detail.State() == panes.ValueShown {
reveal = "hide"
}
help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, of: m.detailOf(), commands: len(m.commandsHere()) > 0, account: m.fields.Shown(), reveal: reveal}.ShortHelp(), 2)
} }
var lines []string var lines []string
if !m.clipDeadline.IsZero() { if !m.clipDeadline.IsZero() {
left := max(0, int(math.Ceil(m.clipDeadline.Sub(m.now()).Seconds()))) lines = append(lines, m.flashLine(done(fmt.Sprintf("copied %s · clipboard clears in %ds", m.clipLabel, secondsLeft(m.clipDeadline, m.now())))))
lines = append(lines, m.flashLine(done(fmt.Sprintf("copied %s · clipboard clears in %ds", m.clipLabel, left))))
} }
if m.flash.text != "" { if m.flash.text != "" {
lines = append(lines, m.flashLine(m.flash)) lines = append(lines, m.flashLine(m.flash))
+3
View File
@@ -306,3 +306,6 @@ func newTextInput(prompt string, width int) textinput.Model {
in.SetWidth(width) in.SetWidth(width)
return in return in
} }
// pathInputWidth fits a link or a folder path in a prompt, in the window.
func (m Model) pathInputWidth() int { return max(20, min(m.w-12, 64)) }
+28 -16
View File
@@ -116,7 +116,7 @@ func (m Model) containerRender(withSource bool) func(styles.Styles, provider.Con
line = marks(sty, n, c.Source.Provider, m.recentIcon, m.now()) + line line = marks(sty, n, c.Source.Provider, m.recentIcon, m.now()) + line
if withSource { if withSource {
src := c.Source.Name src := c.Source.Name
if account, ok := strings.CutPrefix(c.Source.ProviderLabel, string(c.Source.Kind)+" · "); ok { if account := accountOf(c.Source); account != "" {
src = account + " / " + src src = account + " / " + src
} }
line += sty.Muted.Render(" · " + src) line += sty.Muted.Render(" · " + src)
@@ -144,24 +144,36 @@ func (m Model) objectRender() func(styles.Styles, provider.Object, int) string {
} }
} }
// starredContainersFirst moves starred vaults and scopes to the front, // starredFirst moves starred items to the front, keeping the order
// keeping the order otherwise. It doesn't modify items, which may be cached. // otherwise. It doesn't modify items, which may be cached.
func (m Model) starredContainersFirst(items []provider.Container) []provider.Container { func starredFirst[T any](items []T, starred func(T) bool) []T {
rank := func(x T) int {
if starred(x) {
return 0
}
return 1
}
out := slices.Clone(items) out := slices.Clone(items)
slices.SortStableFunc(out, func(a, b provider.Container) int { slices.SortStableFunc(out, func(a, b T) int { return rank(a) - rank(b) })
return boolRank(m.store.Node(storeKey(a)).StarredFor(a.Source.Provider)) - boolRank(m.store.Node(storeKey(b)).StarredFor(b.Source.Provider))
})
return out return out
} }
// starredFirst moves starred objects to the front, keeping the order func (m Model) starredSourcesFirst(sources []provider.Source) []provider.Source {
// otherwise. It doesn't modify items, which may be cached. return starredFirst(sources, func(s provider.Source) bool {
func (m Model) starredFirst(items []provider.Object) []provider.Object { return m.store.Node(sourceKey(s)).StarredFor(s.Provider)
out := slices.Clone(items) })
slices.SortStableFunc(out, func(a, b provider.Object) int { }
return boolRank(m.store.Node(objectKey(a)).StarredFor(a.Container.Source.Provider)) - boolRank(m.store.Node(objectKey(b)).StarredFor(b.Container.Source.Provider))
func (m Model) starredContainersFirst(items []provider.Container) []provider.Container {
return starredFirst(items, func(c provider.Container) bool {
return m.store.Node(storeKey(c)).StarredFor(c.Source.Provider)
})
}
func (m Model) starredObjectsFirst(items []provider.Object) []provider.Object {
return starredFirst(items, func(o provider.Object) bool {
return m.store.Node(objectKey(o)).StarredFor(o.Container.Source.Provider)
}) })
return out
} }
// showLinkedContainers lists the vaults opened from links. // showLinkedContainers lists the vaults opened from links.
@@ -199,7 +211,7 @@ func (m *Model) resortStarred() {
m.containers.SetItems(m.starredContainersFirst(m.containers.Items())) m.containers.SetItems(m.starredContainersFirst(m.containers.Items()))
} }
if m.versionsOf == nil { if m.versionsOf == nil {
m.secrets.SetItems(m.starredFirst(m.secrets.Items())) m.secrets.SetItems(m.starredObjectsFirst(m.secrets.Items()))
} }
} }
@@ -245,7 +257,7 @@ func (m *Model) toggleFavourite() tea.Cmd {
c := o.Container c := o.Container
toggle := func(n *store.Node) { on = n.ToggleStar(c.Source.Provider) } toggle := func(n *store.Node) { on = n.ToggleStar(c.Source.Provider) }
if err = m.remember(objectKey(o), o.Name, []string{c.Source.Name, c.Name}, c.Source, toggle); err == nil { if err = m.remember(objectKey(o), o.Name, []string{c.Source.Name, c.Name}, c.Source, toggle); err == nil {
m.secrets.SetItems(m.starredFirst(m.secrets.Items())) m.secrets.SetItems(m.starredObjectsFirst(m.secrets.Items()))
} }
} }
switch { switch {
+6 -11
View File
@@ -39,10 +39,9 @@ type contextHelp struct {
commands bool // custom commands apply here commands bool // custom commands apply here
account bool // the fourth pane shows Fields (fields.go) account bool // the fourth pane shows Fields (fields.go)
of pane // what the fourth pane describes of pane // what the fourth pane describes
// revealed, when set, says whether the selected value is shown, so // reveal, when set, is what the reveal key will do, "show" or "hide":
// the reveal key says just what it'll do: show or hide, the same // the same width so the bar doesn't shift.
// width so the bar doesn't shift. reveal string
revealed *bool
} }
func (c contextHelp) ShortHelp() []key.Binding { func (c contextHelp) ShortHelp() []key.Binding {
@@ -70,8 +69,8 @@ func (c contextHelp) ShortHelp() []key.Binding {
b = append(b, k.Enter, k.Share, k.ShareMenu, k.Favourite, k.Open, k.Filter) b = append(b, k.Enter, k.Share, k.ShareMenu, k.Favourite, k.Open, k.Filter)
default: default:
reveal := k.Reveal reveal := k.Reveal
if c.revealed != nil { if c.reveal != "" {
reveal = key.NewBinding(key.WithKeys(k.Reveal.Keys()...), key.WithHelp(k.Reveal.Help().Key, map[bool]string{false: "show", true: "hide"}[*c.revealed])) reveal = key.NewBinding(key.WithKeys(k.Reveal.Keys()...), key.WithHelp(k.Reveal.Help().Key, c.reveal))
} }
b = append(b, reveal, k.Copy, k.Share, k.ShareMenu, k.Versions, k.Favourite, k.Filter) b = append(b, reveal, k.Copy, k.Share, k.ShareMenu, k.Versions, k.Favourite, k.Filter)
} }
@@ -259,11 +258,7 @@ func hint(b key.Binding, desc string) keyHint { return keyHint{keys.Name(b), des
// move is "↑/↓ move" for an up and a down binding. // move is "↑/↓ move" for an up and a down binding.
func move(up, down key.Binding) keyHint { func move(up, down key.Binding) keyHint {
u, d := keys.Name(up), keys.Name(down) return keyHint{joinKeys(up, down), "move"}
if u == "" || d == "" {
return keyHint{u + d, "move"}
}
return keyHint{u + "/" + d, "move"}
} }
// withKey fills a hint's format with the binding's key ("· %s loads // withKey fills a hint's format with the binding's key ("· %s loads
+1 -12
View File
@@ -199,18 +199,7 @@ func (m *Model) jumpTo(r ref.Ref, n store.Node) tea.Cmd {
m.startNav(r, actGo, sourceRowKey(n.Account, n.Source)) m.startNav(r, actGo, sourceRowKey(n.Account, n.Source))
return nil return nil
} }
var choices []choice choices := m.openWith(r.Kind, func(src provider.Source) bool { return src.ID == n.Source && n.Source != "" })
for _, p := range m.providers {
if p.Kind() != r.Kind {
continue
}
for _, src := range m.sourceResults[p.ID()].sources {
if src.ID == n.Source && n.Source != "" {
row := sourceRowKey(p.ID(), src.ID)
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
}
}
}
switch len(choices) { switch len(choices) {
case 0: case 0:
return m.openRef(r, actGo) return m.openRef(r, actGo)
+20 -16
View File
@@ -8,7 +8,6 @@ import (
"strings" "strings"
"time" "time"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile" "git.kebcloud.no/keb/lazysecrets/internal/cachefile"
@@ -210,18 +209,7 @@ const defaultResolveTimeout = 30 * time.Second
// openSource goes to a subscription or workspace, asking which account // openSource goes to a subscription or workspace, asking which account
// when several list it. Sources are loaded already, so it needs no lookup. // when several list it. Sources are loaded already, so it needs no lookup.
func (m *Model) openSource(r ref.Ref) { func (m *Model) openSource(r ref.Ref) {
var choices []choice choices := m.openWith(r.Kind, func(src provider.Source) bool { return sourceMatches(r, src) })
for _, p := range m.providers {
if p.Kind() != r.Kind {
continue
}
for _, src := range m.sourceResults[p.ID()].sources {
if sourceMatches(r, src) {
row := sourceRowKey(p.ID(), src.ID)
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
}
}
}
if row, ok := preferredRow(r, choices); ok { if row, ok := preferredRow(r, choices); ok {
choices = []choice{{sourceRow: row}} choices = []choice{{sourceRow: row}}
} }
@@ -243,6 +231,24 @@ func (m *Model) openSource(r ref.Ref) {
} }
} }
// openWith offers each loaded source of kind that matches, one per
// account that lists it.
func (m Model) openWith(kind provider.Kind, matches func(provider.Source) bool) []choice {
var choices []choice
for _, p := range m.providers {
if p.Kind() != kind {
continue
}
for _, src := range m.sourceResults[p.ID()].sources {
if matches(src) {
row := sourceRowKey(p.ID(), src.ID)
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
}
}
}
return choices
}
// refLabel draws what a link points at like the favourites list: the // refLabel draws what a link points at like the favourites list: the
// provider's icon and the path in its colour, "󰠅 platform-prod / // provider's icon and the path in its colour, "󰠅 platform-prod /
// kv-app-prod / api-key", with the type and version after it. The // kv-app-prod / api-key", with the type and version after it. The
@@ -831,10 +837,8 @@ func (m *Model) shareObject(o provider.Object, k string) tea.Cmd {
} }
func (m *Model) startOpenPrompt() tea.Cmd { func (m *Model) startOpenPrompt() tea.Cmd {
in := textinput.New() in := newTextInput("open: ", m.pathInputWidth())
in.Prompt = "open: "
in.Placeholder = "lazysecrets:// link, Key Vault URL or reference" in.Placeholder = "lazysecrets:// link, Key Vault URL or reference"
in.SetWidth(max(20, min(m.w-12, 64)))
m.prompt = &modal{ m.prompt = &modal{
title: "Open a link", input: &in, title: "Open a link", input: &in,
lines: []string{m.st.Muted.Render("lazysecrets:// link, Key Vault URL or reference")}, lines: []string{m.st.Muted.Render("lazysecrets:// link, Key Vault URL or reference")},
+1 -3
View File
@@ -75,10 +75,8 @@ type modal struct {
// showFilterMenu opens a menu with a filter input above the options. // showFilterMenu opens a menu with a filter input above the options.
func (m *Model) showFilterMenu(title string, options []option) tea.Cmd { func (m *Model) showFilterMenu(title string, options []option) tea.Cmd {
in := textinput.New() in := newTextInput("/", 40)
in.Prompt = "/"
in.Placeholder = "type to filter" in.Placeholder = "type to filter"
in.SetWidth(40)
m.prompt = &modal{title: title, options: options, all: options, filter: &in} m.prompt = &modal{title: title, options: options, all: options, filter: &in}
m.prompt.move(0) m.prompt.move(0)
m.flash = flash{} m.flash = flash{}
+1 -4
View File
@@ -3,7 +3,6 @@ package app
import ( import (
"strings" "strings"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/ansi"
@@ -58,11 +57,9 @@ func (m *Model) editNote() tea.Cmd {
c := o.Container c := o.Container
key, name, path, src = objectKey(o), o.Name, []string{c.Source.Name, c.Name}, c.Source key, name, path, src = objectKey(o), o.Name, []string{c.Source.Name, c.Name}, c.Source
} }
in := textinput.New() in := newTextInput("note: ", 48)
in.Prompt = "note: "
in.Placeholder = "e.g. prod SQL admin" in.Placeholder = "e.g. prod SQL admin"
in.CharLimit = 80 in.CharLimit = 80
in.SetWidth(48)
in.SetValue(m.store.Node(key).Note) in.SetValue(m.store.Node(key).Note)
in.CursorEnd() in.CursorEnd()
m.prompt = &modal{ m.prompt = &modal{
+18 -15
View File
@@ -29,41 +29,43 @@ type persistTickMsg struct{}
type persistedMsg struct{ err error } type persistedMsg struct{ err error }
// keepCached reports whether a cache key's account still exists and // keepers report whether a provider ID, or a cache key's, is an account
// isn't a repo: every key starts with the provider ID. // that still exists and isn't a repo.
func (m *Model) keepCached(key string) bool { return m.keepProvider(cachefile.ProviderOf(key)) } func (m *Model) keepers() (keep, keepKey func(string) bool) {
keep = cachefile.Keep(m.providers)
func (m *Model) keepProvider(id string) bool { return cachefile.Keep(m.providers)(id) } return keep, func(key string) bool { return keep(cachefile.ProviderOf(key)) }
}
// loadCacheFile fills the cache from the file, leaving out what's too // loadCacheFile fills the cache from the file, leaving out what's too
// old or belongs to accounts that aren't signed in any more. // old or belongs to accounts that aren't signed in any more.
func (m *Model) loadCacheFile() { func (m *Model) loadCacheFile() {
f := cachefile.Read(m.cachePath) f := cachefile.Read(m.cachePath)
keep, keepKey := m.keepers()
now := m.now() now := m.now()
recent := func(at time.Time) bool { return now.Sub(at) < cachefile.MaxAge } recent := func(at time.Time) bool { return now.Sub(at) < cachefile.MaxAge }
for id, e := range f.Sources { for id, e := range f.Sources {
if m.keepProvider(id) && recent(e.At) { if keep(id) && recent(e.At) {
m.sourceResults[id] = sourcesMsg{gen: m.sourcesGen, provider: id, sources: e.Value} m.sourceResults[id] = sourcesMsg{gen: m.sourcesGen, provider: id, sources: e.Value}
m.sourcesAt[id] = e.At m.sourcesAt[id] = e.At
} }
} }
for k, e := range f.Containers { for k, e := range f.Containers {
if m.keepCached(k) && recent(e.At) { if keepKey(k) && recent(e.At) {
m.cache.containers[k] = entry[[]provider.Container]{e.Value, e.At} m.cache.containers[k] = entry[[]provider.Container]{e.Value, e.At}
} }
} }
for k, e := range f.Objects { for k, e := range f.Objects {
if m.keepCached(k) && recent(e.At) { if keepKey(k) && recent(e.At) {
m.cache.objects[k] = entry[[]provider.Object]{e.Value, e.At} m.cache.objects[k] = entry[[]provider.Object]{e.Value, e.At}
} }
} }
for k, e := range f.Details { for k, e := range f.Details {
if m.keepCached(k) && recent(e.At) { if keepKey(k) && recent(e.At) {
m.cache.details[k] = entry[provider.Object]{e.Value, e.At} m.cache.details[k] = entry[provider.Object]{e.Value, e.At}
} }
} }
for k, v := range f.Cursor { for k, v := range f.Cursor {
if m.keepCached(k) { if keepKey(k) {
m.cache.cursor[k] = v m.cache.cursor[k] = v
} }
} }
@@ -72,29 +74,30 @@ func (m *Model) loadCacheFile() {
// cacheSnapshot is the cache as the file holds it. // cacheSnapshot is the cache as the file holds it.
func (m *Model) cacheSnapshot() cachefile.File { func (m *Model) cacheSnapshot() cachefile.File {
f := cachefile.New() f := cachefile.New()
keep, keepKey := m.keepers()
f.Dropped, m.cacheDropped = m.cacheDropped, nil f.Dropped, m.cacheDropped = m.cacheDropped, nil
for id, r := range m.sourceResults { for id, r := range m.sourceResults {
if r.err == nil && m.keepProvider(id) { if r.err == nil && keep(id) {
f.Sources[id] = cachefile.Stamped[[]provider.Source]{At: m.sourcesAt[id], Value: r.sources} f.Sources[id] = cachefile.Stamped[[]provider.Source]{At: m.sourcesAt[id], Value: r.sources}
} }
} }
for k, e := range m.cache.containers { for k, e := range m.cache.containers {
if m.keepCached(k) { if keepKey(k) {
f.Containers[k] = cachefile.Stamped[[]provider.Container]{At: e.at, Value: e.value} f.Containers[k] = cachefile.Stamped[[]provider.Container]{At: e.at, Value: e.value}
} }
} }
for k, e := range m.cache.objects { for k, e := range m.cache.objects {
if m.keepCached(k) { if keepKey(k) {
f.Objects[k] = cachefile.Stamped[[]provider.Object]{At: e.at, Value: e.value} f.Objects[k] = cachefile.Stamped[[]provider.Object]{At: e.at, Value: e.value}
} }
} }
for k, e := range m.cache.details { for k, e := range m.cache.details {
if m.keepCached(k) { if keepKey(k) {
f.Details[k] = cachefile.Stamped[provider.Object]{At: e.at, Value: e.value} f.Details[k] = cachefile.Stamped[provider.Object]{At: e.at, Value: e.value}
} }
} }
for k, v := range m.cache.cursor { for k, v := range m.cache.cursor {
if m.keepCached(k) { if keepKey(k) {
f.Cursor[k] = v f.Cursor[k] = v
} }
} }
+16 -7
View File
@@ -28,12 +28,22 @@ func (m *Model) startRevealTimer() tea.Cmd {
// revealTickCmd fires when the displayed second next changes. // revealTickCmd fires when the displayed second next changes.
func (m *Model) revealTickCmd() tea.Cmd { func (m *Model) revealTickCmd() tea.Cmd {
wait := m.revealDeadline.Sub(m.now()) % time.Second
if wait <= 0 {
wait = time.Second
}
token := m.revealToken token := m.revealToken
return tea.Tick(wait, func(time.Time) tea.Msg { return revealTickMsg{token} }) return tea.Tick(untilNextSecond(m.revealDeadline, m.now()), func(time.Time) tea.Msg { return revealTickMsg{token} })
}
// untilNextSecond is how long until a countdown to deadline shows its
// next whole second.
func untilNextSecond(deadline, now time.Time) time.Duration {
if wait := deadline.Sub(now) % time.Second; wait > 0 {
return wait
}
return time.Second
}
// secondsLeft is a countdown to deadline, in whole seconds rounded up.
func secondsLeft(deadline, now time.Time) int {
return max(0, int(math.Ceil(deadline.Sub(now).Seconds())))
} }
func (m *Model) revealTick(msg revealTickMsg) tea.Cmd { func (m *Model) revealTick(msg revealTickMsg) tea.Cmd {
@@ -49,8 +59,7 @@ func (m *Model) revealTick(msg revealTickMsg) tea.Cmd {
} }
func (m *Model) showHidesIn() { func (m *Model) showHidesIn() {
left := max(0, int(math.Ceil(m.revealDeadline.Sub(m.now()).Seconds()))) m.detail.SetHidesIn(fmt.Sprintf("%ds", secondsLeft(m.revealDeadline, m.now())))
m.detail.SetHidesIn(fmt.Sprintf("%ds", left))
} }
// hideValue hides the revealed value and stops its countdown. // hideValue hides the revealed value and stops its countdown.
+14 -8
View File
@@ -341,6 +341,19 @@ func (m *Model) handleCrawl(msg crawlMsg) tea.Cmd {
return cmd return cmd
} }
// vaultsLoaded counts the vaults whose secrets are cached or can't be
// read, and says whether any are loading.
func (m *Model) vaultsLoaded(vaults []provider.Container) (loaded int, loading bool) {
for _, c := range vaults {
key := cachefile.ObjectsKey(c, provider.TypeSecret)
loading = loading || m.crawl.pending[key]
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
loaded++
}
}
return loaded, loading
}
// sourceMark shows how much of a subscription or workspace is loaded: // sourceMark shows how much of a subscription or workspace is loaded:
// ○ nothing, ◐ some vaults, ● every vault, ◌ loading, ✗ unreadable. // ○ nothing, ◐ some vaults, ● every vault, ◌ loading, ✗ unreadable.
func (m *Model) sourceMark(row string) string { func (m *Model) sourceMark(row string) string {
@@ -354,14 +367,7 @@ func (m *Model) sourceMark(row string) string {
if !ok { if !ok {
return m.st.Muted.Render("○") return m.st.Muted.Render("○")
} }
loaded, loading := 0, false loaded, loading := m.vaultsLoaded(e.value)
for _, c := range e.value {
key := cachefile.ObjectsKey(c, provider.TypeSecret)
loading = loading || m.crawl.pending[key]
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
loaded++
}
}
switch { switch {
case loading: case loading:
return m.st.Muted.Render("◌") return m.st.Muted.Render("◌")
+4 -28
View File
@@ -95,9 +95,7 @@ func (m *Model) showFavourites() tea.Cmd { return m.openGoTo(tabFavs, false) }
func (m *Model) showRecent() tea.Cmd { return m.openGoTo(tabRecent, false) } func (m *Model) showRecent() tea.Cmd { return m.openGoTo(tabRecent, false) }
func (m *Model) openGoTo(tab int, typing bool) tea.Cmd { func (m *Model) openGoTo(tab int, typing bool) tea.Cmd {
in := textinput.New() in := newTextInput("/", searchWidth(m.w)-6) // as wide as the popup
in.Prompt = "/"
in.SetWidth(searchWidth(m.w) - 6) // as wide as the popup
v := &searchView{input: in, tab: tab, results: true} v := &searchView{input: in, tab: tab, results: true}
v.favs, v.recent = m.storeEntries() v.favs, v.recent = m.storeEntries()
m.search = v m.search = v
@@ -616,13 +614,7 @@ func (m Model) loadHint() string {
} }
return m.st.Warning.Render("⚠ not loaded" + withKey(m.loadKey(), " · %s loads "+name)) return m.st.Warning.Render("⚠ not loaded" + withKey(m.loadKey(), " · %s loads "+name))
} }
loaded := 0 loaded, _ := m.vaultsLoaded(e.value)
for _, c := range e.value {
key := cachefile.ObjectsKey(c, provider.TypeSecret)
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
loaded++
}
}
if loaded == len(e.value) { if loaded == len(e.value) {
return "" return ""
} }
@@ -770,7 +762,7 @@ func (m Model) searchViewBox() string {
body = append(body, " "+m.st.Error.Render(l)) body = append(body, " "+m.st.Error.Render(l))
} }
} }
body = append(body, "", " "+m.goToTabs(ts)) body = append(body, "", " "+panes.Tabs(m.st, ts, tabNames[:], m.search.tab))
} }
body = append(body, " "+v.input.View(), "") body = append(body, " "+v.input.View(), "")
for _, l := range strings.Split(lists, "\n") { for _, l := range strings.Split(lists, "\n") {
@@ -803,7 +795,7 @@ func (m Model) searchViewBox() string {
// Tabs in the title, the current one underlined, like the panes; a // Tabs in the title, the current one underlined, like the panes; a
// picker has its own title, and the tabs over the input. // picker has its own title, and the tabs over the input.
if v.picker == nil { if v.picker == nil {
head := ts.Render(goToTitle+" ") + m.goToTabs(ts) head := ts.Render(goToTitle+" ") + panes.Tabs(m.st, ts, tabNames[:], m.search.tab)
return panes.Box(m.st, head, foot, body, w, len(body)+2, focused) return panes.Box(m.st, head, foot, body, w, len(body)+2, focused)
} }
// A picker: in its own border, under what it's for. // A picker: in its own border, under what it's for.
@@ -856,19 +848,3 @@ func pickable(o option) (ref.Ref, bool) {
} }
return r, true return r, true
} }
// goToTabs draws Go to's tabs, the current one underlined.
func (m Model) goToTabs(ts lipgloss.Style) string {
var out string
for i, name := range tabNames {
if i > 0 {
out += ts.Render(" - ")
}
if i == m.search.tab {
out += ts.Underline(true).Render(name)
} else {
out += m.st.Muted.Render(name)
}
}
return out
}
+2 -5
View File
@@ -10,7 +10,6 @@ import (
"slices" "slices"
"strings" "strings"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/ansi"
@@ -304,7 +303,7 @@ func (m *Model) handleShareOpened(msg shareOpenedMsg) tea.Cmd {
} }
// openFailed says why a share link couldn't be decrypted, in a popup // openFailed says why a share link couldn't be decrypted, in a popup
// where its offer was: its first line (wrapped), or with whole all of it // where its offer was: its first line (wrapped), or, with whole, all of it
// (Key Vault's refusals say what to check on later lines). From there, // (Key Vault's refusals say what to check on later lines). From there,
// the key's secret can be gone to: its version, which may be disabled. // the key's secret can be gone to: its version, which may be disabled.
func (m *Model) openFailed(s share.Sealed, err error, whole bool) { func (m *Model) openFailed(s share.Sealed, err error, whole bool) {
@@ -338,9 +337,7 @@ func (m *Model) openFailed(s share.Sealed, err error, whole bool) {
// askShareDir asks where to save files from a repo that isn't here. It // askShareDir asks where to save files from a repo that isn't here. It
// stays until they're saved, saying why when they can't be. // stays until they're saved, saying why when they can't be.
func (m *Model) askShareDir(msg shareOpenedMsg) tea.Cmd { func (m *Model) askShareDir(msg shareOpenedMsg) tea.Cmd {
in := textinput.New() in := newTextInput("folder: ", m.pathInputWidth())
in.Prompt = "folder: "
in.SetWidth(max(20, min(m.w-12, 64)))
dir := m.placeDir dir := m.placeDir
if dir == "" { if dir == "" {
dir, _ = os.Getwd() dir, _ = os.Getwd()
+1 -22
View File
@@ -134,10 +134,7 @@ func (m *Model) rebuildSources() {
} }
for _, p := range m.providers { for _, p := range m.providers {
kind, id := p.Kind(), p.ID() kind, id := p.Kind(), p.ID()
account, _ := strings.CutPrefix(p.Label(), string(kind)+" · ") account := accountOf(provider.Source{Kind: kind, ProviderLabel: p.Label()})
if account == p.Label() {
account = ""
}
r, done := m.sourceResults[id] r, done := m.sourceResults[id]
sources := r.sources sources := r.sources
if !flat { if !flat {
@@ -215,17 +212,6 @@ func (m Model) currentSource() (provider.Source, bool) {
return provider.Source{}, false return provider.Source{}, false
} }
// starredSourcesFirst moves starred sources to the front, keeping the
// order otherwise. It doesn't modify sources.
func (m Model) starredSourcesFirst(sources []provider.Source) []provider.Source {
out := slices.Clone(sources)
rank := func(s provider.Source) int {
return boolRank(m.store.Node(sourceKey(s)).StarredFor(s.Provider))
}
slices.SortStableFunc(out, func(a, b provider.Source) int { return rank(a) - rank(b) })
return out
}
// starRank orders the Favourites row, then starred sources, then the rest. // starRank orders the Favourites row, then starred sources, then the rest.
func starRank(mem *store.Store, r sourceRow) int { func starRank(mem *store.Store, r sourceRow) int {
switch { switch {
@@ -239,13 +225,6 @@ func starRank(mem *store.Store, r sourceRow) int {
return 3 return 3
} }
func boolRank(starred bool) int {
if starred {
return 0
}
return 1
}
// selectedSource is the source under the cursor; false on a header. // selectedSource is the source under the cursor; false on a header.
func (m Model) selectedSource() (provider.Source, bool) { func (m Model) selectedSource() (provider.Source, bool) {
r, ok := m.sources.Selected() r, ok := m.sources.Selected()
+1 -11
View File
@@ -2,13 +2,11 @@ package app
import ( import (
"context" "context"
"errors"
"strings" "strings"
"time" "time"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/browser"
"git.kebcloud.no/keb/lazysecrets/internal/store" "git.kebcloud.no/keb/lazysecrets/internal/store"
"git.kebcloud.no/keb/lazysecrets/internal/update" "git.kebcloud.no/keb/lazysecrets/internal/update"
) )
@@ -130,15 +128,7 @@ func (m *Model) showUpdate() {
run: func(m *Model) tea.Cmd { return m.install(r) }}} run: func(m *Model) tea.Cmd { return m.install(r) }}}
if r.URL != "" { if r.URL != "" {
opts = append(opts, option{key: "o", label: "Open the release page", run: func(m *Model) tea.Cmd { opts = append(opts, option{key: "o", label: "Open the release page", run: func(m *Model) tea.Cmd {
switch err := m.openURL(r.URL); { return m.openPage("the release page", r.URL, func() {})
case errors.Is(err, browser.ErrRemote):
cmd := m.copyText("link to the release", r.URL, false)
m.flash.text += " (can't open a browser over SSH)"
return cmd
case err != nil:
m.flash = flash{text: "open: " + err.Error(), isErr: true}
}
return nil
}}) }})
} }
m.showMenu("lazysecrets "+r.Tag, lines, opts) m.showMenu("lazysecrets "+r.Tag, lines, opts)
+17
View File
@@ -69,3 +69,20 @@ func TitleStyle(st styles.Styles, focused bool) lipgloss.Style {
} }
return st.TitleBlurred return st.TitleBlurred
} }
// Tabs draws tab names for a title in style ts, the current one
// underlined and the rest muted.
func Tabs(st styles.Styles, ts lipgloss.Style, names []string, current int) string {
var out string
for i, name := range names {
if i > 0 {
out += ts.Render(" - ")
}
if i == current {
out += ts.Underline(true).Render(name)
} else {
out += st.Muted.Render(name)
}
}
return out
}
+27 -21
View File
@@ -188,20 +188,31 @@ func carryStyle(lines []string) []string {
return lines return lines
} }
var valueIndent = strings.Repeat(" ", 12)
// fieldLabel starts a detail line: the label, padded so values line up
// at valueIndent.
func fieldLabel(st styles.Styles, label string) string {
return " " + st.Label.Render(fmt.Sprintf("%-10s", label)) + " "
}
// lines renders the body, wrapped to the pane width. // lines renders the body, wrapped to the pane width.
func (d Detail) lines(st styles.Styles) []string { func (d Detail) lines(st styles.Styles) []string {
inner := max(1, d.w-3) inner := max(1, d.w-3)
var out []string var out []string
field := func(label, value string) { field := func(label, value string) {
prefix := " " + st.Label.Render(fmt.Sprintf("%-10s", label)) + " "
indent := strings.Repeat(" ", 12)
wrapped := carryStyle(strings.Split(ansi.Hardwrap(value, max(1, inner-11), true), "\n")) wrapped := carryStyle(strings.Split(ansi.Hardwrap(value, max(1, inner-11), true), "\n"))
out = append(out, prefix+wrapped[0]) out = append(out, fieldLabel(st, label)+wrapped[0])
for _, w := range wrapped[1:] { for _, w := range wrapped[1:] {
out = append(out, indent+w) out = append(out, valueIndent+w)
} }
} }
muted := st.Muted.Render muted := st.Muted.Render
note := func() {
if d.note != "" {
field("note", st.Favourite.Render(d.note))
}
}
if d.container == nil { if d.container == nil {
return []string{muted(" Select a vault or scope to browse its secrets.")} return []string{muted(" Select a vault or scope to browse its secrets.")}
@@ -211,30 +222,27 @@ func (d Detail) lines(st styles.Styles) []string {
if containerLabel == "" { if containerLabel == "" {
containerLabel = "vault" containerLabel = "vault"
} }
source := d.Icons.Prefix(c.Source.Kind) + cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind)) + " / " + c.Source.Name
if d.secret == nil { if d.secret == nil {
field(containerLabel, c.Name) field(containerLabel, c.Name)
if d.note != "" { note()
field("note", st.Favourite.Render(d.note))
}
if c.Detail != "" { if c.Detail != "" {
field("", muted(c.Detail)) field("", muted(c.Detail))
} }
field("source", d.Icons.Prefix(c.Source.Kind)+cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name) field("source", source)
out = append(out, "", muted(" Select a secret to see its details.")) out = append(out, "", muted(" Select a secret to see its details."))
return out return out
} }
s := *d.secret s := *d.secret
field("name", s.Name) field("name", s.Name)
if d.note != "" { note()
field("note", st.Favourite.Render(d.note))
}
field(containerLabel, c.Name) field(containerLabel, c.Name)
if c.Source.Kind != provider.KindAzure && c.Detail != "" { if c.Source.Kind != provider.KindAzure && c.Detail != "" {
field("", muted(c.Detail)) field("", muted(c.Detail))
} }
field("source", d.Icons.Prefix(c.Source.Kind)+cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name) field("source", source)
if s.Version != "" { if s.Version != "" {
field("version", s.Version) field("version", s.Version)
} }
@@ -298,14 +306,12 @@ func (d Detail) lines(st styles.Styles) []string {
case provider.TypeCertificate: case provider.TypeCertificate:
label, hidden, reveal = "cert", "", "show the certificate" label, hidden, reveal = "cert", "", "show the certificate"
} }
indent := strings.Repeat(" ", 12)
out = append(out, "") out = append(out, "")
switch d.state { switch d.state {
case ValueHidden: case ValueHidden:
if hidden != "" { if hidden != "" {
field(label, hidden) field(label, hidden)
out = append(out, indent+hints(reveal)) out = append(out, valueIndent+hints(reveal))
} else { } else {
field(label, hints(reveal)) field(label, hints(reveal))
} }
@@ -322,9 +328,9 @@ func (d Detail) lines(st styles.Styles) []string {
for j, w := range strings.Split(ansi.Wrap(text, max(1, inner-11-len(lead)), ""), "\n") { for j, w := range strings.Split(ansi.Wrap(text, max(1, inner-11-len(lead)), ""), "\n") {
w = lead + w w = lead + w
if i == 0 && j == 0 { if i == 0 && j == 0 {
out = append(out, " "+st.Label.Render(fmt.Sprintf("%-10s", label))+" "+st.Error.Render(w)) out = append(out, fieldLabel(st, label)+st.Error.Render(w))
} else { } else {
out = append(out, strings.Repeat(" ", 12)+st.Error.Render(w)) out = append(out, valueIndent+st.Error.Render(w))
} }
} }
} }
@@ -335,10 +341,10 @@ func (d Detail) lines(st styles.Styles) []string {
for _, line := range lines { for _, line := range lines {
for _, w := range strings.Split(ansi.Hardwrap(line, max(1, inner-11), true), "\n") { for _, w := range strings.Split(ansi.Hardwrap(line, max(1, inner-11), true), "\n") {
if first { if first {
out = append(out, " "+st.Label.Render(fmt.Sprintf("%-10s", label))+" "+st.Value.Render(w)) out = append(out, fieldLabel(st, label)+st.Value.Render(w))
first = false first = false
} else { } else {
out = append(out, indent+st.Value.Render(w)) out = append(out, valueIndent+st.Value.Render(w))
} }
} }
} }
@@ -352,7 +358,7 @@ func (d Detail) lines(st styles.Styles) []string {
if d.hidesIn != "" { if d.hidesIn != "" {
more = append(more, "hides in "+d.hidesIn) more = append(more, "hides in "+d.hidesIn)
} }
out = append(out, indent+hints("hide", more...)) out = append(out, valueIndent+hints("hide", more...))
} }
return append(out, d.versionLines(st)...) return append(out, d.versionLines(st)...)
} }
@@ -373,7 +379,7 @@ func (d Detail) versionLines(st styles.Styles) []string {
head += muted(" · " + d.VersionsKey + " to browse") head += muted(" · " + d.VersionsKey + " to browse")
} }
} }
out := []string{"", " " + st.Label.Render(fmt.Sprintf("%-10s", "versions")) + " " + head} out := []string{"", fieldLabel(st, "versions") + head}
current := d.versions[0].Version current := d.versions[0].Version
for i, v := range d.versions { for i, v := range d.versions {
if i == maxVersionLines { if i == maxVersionLines {
-1
View File
@@ -9,7 +9,6 @@ import (
"github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/ansi"
) )
// sgr matches SGR escape sequences.
var sgr = regexp.MustCompile("\x1b\\[([0-9;:]*)m") var sgr = regexp.MustCompile("\x1b\\[([0-9;:]*)m")
// Highlight draws a cursor row: line, padded to width, on style's // Highlight draws a cursor row: line, padded to width, on style's
+3 -26
View File
@@ -329,17 +329,7 @@ func (l List[T]) View(st styles.Styles, focused bool, spin string) string {
ts := TitleStyle(st, focused) ts := TitleStyle(st, focused)
title := ts.Render(l.Title) title := ts.Render(l.Title)
if len(l.Tabs) > 1 { if len(l.Tabs) > 1 {
for i, tab := range l.Tabs { title += ts.Render(" ") + Tabs(st, ts, l.Tabs, l.Tab)
sep := " - "
if i == 0 {
sep = " "
}
if i == l.Tab {
title += ts.Render(sep) + ts.Underline(true).Render(tab)
} else {
title += ts.Render(sep) + st.Muted.Render(tab)
}
}
} }
if q := l.input.Value(); q != "" && !l.filtering { if q := l.input.Value(); q != "" && !l.filtering {
title += ts.Render(" /" + q) title += ts.Render(" /" + q)
@@ -370,26 +360,13 @@ func (l List[T]) View(st styles.Styles, focused bool, spin string) string {
default: default:
for i := l.offset; i < len(l.visible) && i < l.offset+rows; i++ { for i := l.offset; i < len(l.visible) && i < l.offset+rows; i++ {
line := l.item.Render(st, l.items[l.visible[i]], inner-1) line := l.item.Render(st, l.items[l.visible[i]], inner-1)
plain := ansi.Strip(line) line = " " + MarkMatches(line, l.highlight(i, ansi.Strip(line)), st.Match)
hits := l.highlight(i, plain)
if i == l.cursor { if i == l.cursor {
base := st.SelectedBlurred base := st.SelectedBlurred
if focused { if focused {
base = st.Selected base = st.Selected
} }
text := " " + line line = Highlight(ansi.Truncate(line, inner, "…"), base, inner)
if len(hits) > 0 {
for j := range hits {
hits[j]++ // leading space
}
text = lipgloss.StyleRanges(text, matchRanges(" "+plain, hits, st.Match)...)
}
line = Highlight(ansi.Truncate(text, inner, "…"), base, inner)
} else if len(hits) > 0 {
// Style just the matches, keeping the row's own colours.
line = " " + lipgloss.StyleRanges(line, matchRanges(plain, hits, st.Match)...)
} else {
line = " " + line
} }
body = append(body, line) body = append(body, line)
} }
-1
View File
@@ -126,7 +126,6 @@ type value struct {
bold, italic, underline, reverse, faint bool bold, italic, underline, reverse, faint bool
} }
// fg styles s with the value as foreground, bg as background.
func (v value) fg(s lipgloss.Style) lipgloss.Style { func (v value) fg(s lipgloss.Style) lipgloss.Style {
if v.color != nil { if v.color != nil {
s = s.Foreground(v.color) s = s.Foreground(v.color)
+9 -11
View File
@@ -45,9 +45,9 @@ const (
// DefaultServers are where releases are looked for, in order. // DefaultServers are where releases are looked for, in order.
var DefaultServers = []string{GitHubRepo, DefaultRepo} var DefaultServers = []string{GitHubRepo, DefaultRepo}
// latestTimeout bounds asking one server for the latest release, so a // serverTimeout bounds asking one server for the latest release or its
// server that's down leaves time for the next. // checksums, so a server that's down leaves time for the next.
const latestTimeout = 5 * time.Second const serverTimeout = 5 * time.Second
// maxDownload bounds what's read from the server. // maxDownload bounds what's read from the server.
const maxDownload = 200 << 20 const maxDownload = 200 << 20
@@ -89,14 +89,9 @@ func (r Release) Notes(current string) string {
return PlainLinks(strings.TrimSpace(notes)) return PlainLinks(strings.TrimSpace(notes))
} }
// recentReleases is how many releases Latest reads the changelogs of.
const recentReleases = 10
// PlainLinks shows Markdown links as their text, for a terminal. // PlainLinks shows Markdown links as their text, for a terminal.
func PlainLinks(s string) string { return mdLink.ReplaceAllString(s, "$1") } func PlainLinks(s string) string { return mdLink.ReplaceAllString(s, "$1") }
// mdLink is a Markdown link, [text](url): the notes link PRs and commits,
// and a terminal shows them as their text.
var mdLink = regexp.MustCompile(`\[([^\]]+)\]\((https?://[^)\s]+)\)`) var mdLink = regexp.MustCompile(`\[([^\]]+)\]\((https?://[^)\s]+)\)`)
// Client talks to the release servers. // Client talks to the release servers.
@@ -148,12 +143,15 @@ func (c *Client) Latest(ctx context.Context) (Release, error) {
return Release{}, errors.Join(errs...) return Release{}, errors.Join(errs...)
} }
// recentReleases is how many releases Latest reads the changelogs of.
const recentReleases = 10
func (c *Client) latest(ctx context.Context, repo string) (Release, error) { func (c *Client) latest(ctx context.Context, repo string) (Release, error) {
api, err := API(repo) api, err := API(repo)
if err != nil { if err != nil {
return Release{}, err return Release{}, err
} }
ctx, cancel := context.WithTimeout(ctx, latestTimeout) ctx, cancel := context.WithTimeout(ctx, serverTimeout)
defer cancel() defer cancel()
// The recent releases in one request (per_page on GitHub, limit on // The recent releases in one request (per_page on GitHub, limit on
// Gitea), for their changelogs. // Gitea), for their changelogs.
@@ -222,7 +220,7 @@ func (c *Client) Install(ctx context.Context, r Release, exe string) error {
if m := Managed(exe); m != "" { if m := Managed(exe); m != "" {
return fmt.Errorf("%s is managed by %s: update it there", exe, m) return fmt.Errorf("%s is managed by %s: update it there", exe, m)
} }
if runtime.GOOS != "darwin" && runtime.GOOS != "linux" || runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64" { if (runtime.GOOS != "darwin" && runtime.GOOS != "linux") || (runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64") {
return fmt.Errorf("no release build for %s/%s", runtime.GOOS, runtime.GOARCH) return fmt.Errorf("no release build for %s/%s", runtime.GOOS, runtime.GOARCH)
} }
version := strings.TrimPrefix(r.Tag, "v") version := strings.TrimPrefix(r.Tag, "v")
@@ -272,7 +270,7 @@ func (c *Client) download(ctx context.Context, tag, file string) ([]byte, error)
var errs []error var errs []error
for _, repo := range c.servers() { for _, repo := range c.servers() {
base := strings.TrimSuffix(repo, "/") + "/releases/download/" + tag + "/" base := strings.TrimSuffix(repo, "/") + "/releases/download/" + tag + "/"
sctx, cancel := context.WithTimeout(ctx, latestTimeout) sctx, cancel := context.WithTimeout(ctx, serverTimeout)
sums, err := c.get(sctx, base+"checksums.txt", 1<<20) sums, err := c.get(sctx, base+"checksums.txt", 1<<20)
cancel() cancel()
if err != nil { if err != nil {