Compare commits
9
Commits
19299631b8
..
v0.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
637bf6a253
|
||
|
|
29b0c76f40
|
||
|
|
df429002c9 | ||
|
|
0ff5204d4d | ||
|
|
6842c72662 | ||
|
|
fae8e1cbb4 | ||
|
|
1753630c73 | ||
|
|
b773dfe296 | ||
|
|
bb41767cba |
No files matched your search
+8
-28
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)'
|
|
||||||
```
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -16,7 +16,7 @@ A service principal is used instead when `AZURE_CLIENT_ID`,
|
|||||||
On an account's group row, the fourth pane shows who it signs in as: name,
|
On an account's group row, the fourth pane shows who it signs in as: name,
|
||||||
user, tenant, object ID, whether the session used MFA, directory roles
|
user, tenant, object ID, whether the session used MFA, directory roles
|
||||||
(built-in ones by name) and its subscriptions, read from az's account list
|
(built-in ones by name) and its subscriptions, read from az's account list
|
||||||
and the token lazysecrets already has: no requests of their own. `y` on
|
and the token lazysecrets already has. `y` on
|
||||||
the row copies the object ID, the one access policies and role
|
the row copies the object ID, the one access policies and role
|
||||||
assignments ask for; `l` goes into the pane, where `j`/`k` pick any
|
assignments ask for; `l` goes into the pane, where `j`/`k` pick any
|
||||||
field, `/` filters them (`/tenant`) and `y` copies the selected one.
|
field, `/` filters them (`/tenant`) and `y` copies the selected one.
|
||||||
@@ -67,9 +67,8 @@ fetched. `v` lists an item's versions. Items expiring within
|
|||||||
|
|
||||||
The status pane (`0`) lists them across every vault loaded, opened or
|
The status pane (`0`) lists them across every vault loaded, opened or
|
||||||
cached, soonest first, and its line counts them (`⚠ 3 expiring`); `enter`
|
cached, soonest first, and its line counts them (`⚠ 3 expiring`); `enter`
|
||||||
on one goes to it. That makes no requests of its own: expiry dates come
|
on one goes to it. Expiry dates come with the lists. It says how many
|
||||||
with the lists. It says how many vaults it covers; `ctrl+a` in search
|
vaults it covers; `ctrl+a` in search loads the rest.
|
||||||
loads the rest.
|
|
||||||
|
|
||||||
On a vault, the right pane shows its settings: resource group, location,
|
On a vault, the right pane shows its settings: resource group, location,
|
||||||
SKU, whether access is by Azure roles or access policies, soft delete and
|
SKU, whether access is by Azure roles or access policies, soft delete and
|
||||||
|
|||||||
+21
-38
@@ -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
@@ -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
@@ -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)'
|
||||||
```
|
```
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
+16
-19
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+24
-25
@@ -1,6 +1,7 @@
|
|||||||
package instance
|
package instance
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -25,9 +26,10 @@ 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
|
||||||
pid int
|
activity int // Unix seconds
|
||||||
app string
|
pid int
|
||||||
|
app string
|
||||||
}
|
}
|
||||||
|
|
||||||
// clients lists the tmux server's clients, the one last typed in first.
|
// clients lists the tmux server's clients, the one last typed in first.
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,56 +437,49 @@ 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)
|
v, _, err := p.VersionedValue(ctx, o)
|
||||||
if err != nil {
|
return v, err
|
||||||
return provider.SecretValue{}, err
|
|
||||||
}
|
|
||||||
switch o.Type {
|
|
||||||
case provider.TypeSecret:
|
|
||||||
v, _, err := p.VersionedValue(ctx, o)
|
|
||||||
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
|
||||||
}
|
}
|
||||||
resp, err := vc.secrets.GetSecret(ctx, o.Name, o.Version, nil)
|
var text string
|
||||||
if err != nil {
|
switch o.Type {
|
||||||
return provider.SecretValue{}, "", cleanError(err)
|
case provider.TypeSecret:
|
||||||
|
resp, err := vc.secrets.GetSecret(ctx, o.Name, o.Version, nil)
|
||||||
|
if err != nil {
|
||||||
|
return provider.SecretValue{}, "", cleanError(err)
|
||||||
|
}
|
||||||
|
version := o.Version
|
||||||
|
if resp.ID != nil {
|
||||||
|
version = resp.ID.Version()
|
||||||
|
}
|
||||||
|
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
|
||||||
}
|
}
|
||||||
version := o.Version
|
return provider.NewSecretValue([]byte(text)), o.Version, nil
|
||||||
if resp.ID != nil {
|
|
||||||
version = resp.ID.Version()
|
|
||||||
}
|
|
||||||
return provider.NewSecretValue([]byte(deref(resp.Value))), version, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 := ""
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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...)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,33 +600,38 @@ 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) {
|
||||||
|
f, err := readLazysecretsFile(root)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
include, exclude := p.s.Include, p.s.Exclude
|
include, exclude := p.s.Include, p.s.Exclude
|
||||||
if b, err := os.ReadFile(filepath.Join(root, ".lazysecrets.yaml")); err == nil {
|
if len(f.Include) > 0 || len(f.Exclude) > 0 {
|
||||||
var f lazysecretsFile
|
include, exclude = f.Include, f.Exclude
|
||||||
if err := yaml.Unmarshal(b, &f); err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err)
|
|
||||||
}
|
|
||||||
if len(f.Include) > 0 || len(f.Exclude) > 0 {
|
|
||||||
include, exclude = f.Include, f.Exclude
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
inc, err := globs(include)
|
inc, err := globs(include)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -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
@@ -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]
|
||||||
|
|||||||
@@ -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
@@ -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))
|
||||||
|
|||||||
@@ -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)) }
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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")},
|
||||||
|
|||||||
@@ -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{}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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("◌")
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in new issue
Block a user