Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9a7ed73be
|
||
|
|
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
|
||||||
@@ -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
|
||||||
|
|||||||
+4
-10
@@ -18,7 +18,7 @@ import (
|
|||||||
// It ends with a newline only when printing to a terminal, so command
|
// It ends with a newline only when printing to a terminal, so command
|
||||||
// substitution and pipes get the value exactly.
|
// substitution and pipes get the value exactly.
|
||||||
func get(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) error {
|
func get(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) error {
|
||||||
v, err := value(ctx, providers, st, r)
|
v, _, err := readKey(ctx, providers, st, r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -30,15 +30,9 @@ func get(ctx context.Context, providers []provider.Provider, st *store.Store, r
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// value reads what r points at (ref.Read), with the account that last
|
// readKey reads what r points at (ref.Read), and the version read. The
|
||||||
// read it first.
|
// account asked for (--account) goes first, else the one that last read
|
||||||
func value(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, error) {
|
// it.
|
||||||
v, _, err := readKey(ctx, providers, st, r)
|
|
||||||
return v, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// readKey is value, also returning the version read. The account asked
|
|
||||||
// for (--account) goes first, else the one that last read it.
|
|
||||||
func readKey(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, string, error) {
|
func readKey(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, string, error) {
|
||||||
key := r
|
key := r
|
||||||
key.Version = ""
|
key.Version = ""
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ func TestValue(t *testing.T) {
|
|||||||
"lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage-key": "storage-demo-key",
|
"lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage-key": "storage-demo-key",
|
||||||
} {
|
} {
|
||||||
r, _ := ref.Parse(link)
|
r, _ := ref.Parse(link)
|
||||||
v, err := value(context.Background(), providers, st, r)
|
v, _, err := readKey(context.Background(), providers, st, r)
|
||||||
if err != nil || v.Reveal() != want {
|
if err != nil || v.Reveal() != want {
|
||||||
t.Errorf("%s: %q, %v", link, v.Reveal(), err)
|
t.Errorf("%s: %q, %v", link, v.Reveal(), err)
|
||||||
}
|
}
|
||||||
@@ -31,7 +31,7 @@ func TestValue(t *testing.T) {
|
|||||||
"{{secrets/app/storage-key}}": "names no workspace",
|
"{{secrets/app/storage-key}}": "names no workspace",
|
||||||
} {
|
} {
|
||||||
r, _ := ref.Parse(link)
|
r, _ := ref.Parse(link)
|
||||||
if _, err := value(context.Background(), providers, st, r); err == nil || !strings.Contains(err.Error(), wantErr) {
|
if _, _, err := readKey(context.Background(), providers, st, r); err == nil || !strings.Contains(err.Error(), wantErr) {
|
||||||
t.Errorf("%s: %v, want %q", link, err, wantErr)
|
t.Errorf("%s: %v, want %q", link, err, wantErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,27 +68,20 @@ func checkCommandKeys(km *keys.Map, cmds []commands.Command) error {
|
|||||||
if c.Key == "" {
|
if c.Key == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
name := fmt.Sprintf("custom command %q", cmpOr(c.Description, c.Command))
|
name := fmt.Sprintf("custom command %q", cmp.Or(c.Description, c.Command))
|
||||||
if err := km.CheckExtra(c.Key, name); err != nil {
|
if err := km.CheckExtra(c.Key, name); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, d := range cmds[:i] {
|
for _, d := range cmds[:i] {
|
||||||
overlap := c.Context == d.Context || c.Context == commands.Global || d.Context == commands.Global
|
overlap := c.Context == d.Context || c.Context == commands.Global || d.Context == commands.Global
|
||||||
if d.Key == c.Key && overlap {
|
if d.Key == c.Key && overlap {
|
||||||
return fmt.Errorf("%s: %q is also the key of custom command %q", name, c.Key, cmpOr(d.Description, d.Command))
|
return fmt.Errorf("%s: %q is also the key of custom command %q", name, c.Key, cmp.Or(d.Description, d.Command))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func cmpOr(a, b string) string {
|
|
||||||
if a != "" {
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
// refArg parses get's and open's arguments: a reference, and optionally
|
// refArg parses get's and open's arguments: a reference, and optionally
|
||||||
// the account to use when several could (lazysecrets list's column) and,
|
// the account to use when several could (lazysecrets list's column) and,
|
||||||
// for open, what to do with it rather than asking (--action).
|
// for open, what to do with it rather than asking (--action).
|
||||||
@@ -150,6 +143,7 @@ func run() error {
|
|||||||
for scripts: VAR=$(lazysecrets get <ref>)
|
for scripts: VAR=$(lazysecrets get <ref>)
|
||||||
lazysecrets [flags] list [--kind secret,…] [--cached] [--max-age 2m] [--format text|json]
|
lazysecrets [flags] list [--kind secret,…] [--cached] [--max-age 2m] [--format text|json]
|
||||||
[--color always|never] [--icons=false] [--errors=false]
|
[--color always|never] [--icons=false] [--errors=false]
|
||||||
|
[--stars=false] [--notes=false] [--recent] [--expiry=false]
|
||||||
print every source, vault and secret it can
|
print every source, vault and secret it can
|
||||||
find, one per line, for fzf and pickers:
|
find, one per line, for fzf and pickers:
|
||||||
what's cached at once, then what loads
|
what's cached at once, then what loads
|
||||||
|
|||||||
@@ -10,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
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
@@ -42,9 +42,6 @@ func New() Clipboard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Native reports whether the system clipboard is used rather than OSC52.
|
|
||||||
func (c Clipboard) Native() bool { return c.native }
|
|
||||||
|
|
||||||
// Write puts s on the clipboard.
|
// Write puts s on the clipboard.
|
||||||
func (c Clipboard) Write(s string) tea.Cmd {
|
func (c Clipboard) Write(s string) tea.Cmd {
|
||||||
if !c.native {
|
if !c.native {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
package commands
|
package commands
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -60,10 +61,7 @@ var envName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
|||||||
|
|
||||||
// Validate checks a command from the config.
|
// Validate checks a command from the config.
|
||||||
func (c Command) Validate() error {
|
func (c Command) Validate() error {
|
||||||
name := c.Description
|
name := cmp.Or(c.Description, c.Command)
|
||||||
if name == "" {
|
|
||||||
name = c.Command
|
|
||||||
}
|
|
||||||
switch {
|
switch {
|
||||||
case c.Command == "":
|
case c.Command == "":
|
||||||
return fmt.Errorf("custom command %q has no command", c.Description)
|
return fmt.Errorf("custom command %q has no command", c.Description)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"maps"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -47,7 +48,8 @@ type UI struct {
|
|||||||
// RecentIcon marks items used lately (Recent, R) with ◷, in the lists
|
// RecentIcon marks items used lately (Recent, R) with ◷, in the lists
|
||||||
// and in lazysecrets list. Defaults to false.
|
// and in lazysecrets list. Defaults to false.
|
||||||
RecentIcon *bool `yaml:"recentIcon"`
|
RecentIcon *bool `yaml:"recentIcon"`
|
||||||
// SourcesView is "grouped" (default) or "flat"; ` + "`" + ` toggles it.
|
// SourcesView is "grouped" (default) or "flat"; the backtick key
|
||||||
|
// toggles it.
|
||||||
SourcesView string `yaml:"sourcesView"`
|
SourcesView string `yaml:"sourcesView"`
|
||||||
// CollapseGroups starts the Sources pane with its groups folded;
|
// CollapseGroups starts the Sources pane with its groups folded;
|
||||||
// defaults to true.
|
// defaults to true.
|
||||||
@@ -203,17 +205,12 @@ func xdgDir(env, fallback string) (string, error) {
|
|||||||
return filepath.Join(base, "lazysecrets"), nil
|
return filepath.Join(base, "lazysecrets"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads path. A missing file is an empty config. Unknown keys are an
|
|
||||||
// error, so typos don't silently do nothing.
|
|
||||||
func Load(path string) (Config, error) {
|
|
||||||
return LoadAll([]string{path})
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadAll reads several config files in order, each on top of the ones
|
// LoadAll reads several config files in order, each on top of the ones
|
||||||
// before, like lazygit's LG_CONFIG_FILE: a later file overrides the
|
// before, like lazygit's LG_CONFIG_FILE: a later file overrides the
|
||||||
// settings it sets, and theme roles merge one by one. That lets an editor
|
// settings it sets, and theme roles merge one by one. That lets an editor
|
||||||
// plugin add a generated theme file to the user's own config. Missing
|
// plugin add a generated theme file to the user's own config. Missing
|
||||||
// files are skipped.
|
// files are skipped. Unknown keys are an error, so typos don't silently do
|
||||||
|
// nothing.
|
||||||
func LoadAll(paths []string) (Config, error) {
|
func LoadAll(paths []string) (Config, error) {
|
||||||
var c Config
|
var c Config
|
||||||
keybinding := map[string]map[string][]string{}
|
keybinding := map[string]map[string][]string{}
|
||||||
@@ -228,9 +225,7 @@ func LoadAll(paths []string) (Config, error) {
|
|||||||
if keybinding[ctx] == nil {
|
if keybinding[ctx] == nil {
|
||||||
keybinding[ctx] = map[string][]string{}
|
keybinding[ctx] = map[string][]string{}
|
||||||
}
|
}
|
||||||
for name, keys := range actions {
|
maps.Copy(keybinding[ctx], actions)
|
||||||
keybinding[ctx][name] = keys
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
c.Keybinding = keybinding
|
c.Keybinding = keybinding
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ func write(t *testing.T, content string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestLoad(t *testing.T) {
|
func TestLoad(t *testing.T) {
|
||||||
c, err := Load(write(t, `
|
c, err := LoadAll([]string{write(t, `
|
||||||
ui:
|
ui:
|
||||||
icons: false
|
icons: false
|
||||||
sourcesView: flat
|
sourcesView: flat
|
||||||
@@ -36,7 +36,7 @@ providers:
|
|||||||
databricks:
|
databricks:
|
||||||
enabled: false
|
enabled: false
|
||||||
hideProfiles: [test]
|
hideProfiles: [test]
|
||||||
`))
|
`)})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -61,7 +61,7 @@ func TestLoadMissingAndEmpty(t *testing.T) {
|
|||||||
"missing": filepath.Join(t.TempDir(), "nope.yaml"),
|
"missing": filepath.Join(t.TempDir(), "nope.yaml"),
|
||||||
"empty": write(t, ""),
|
"empty": write(t, ""),
|
||||||
} {
|
} {
|
||||||
c, err := Load(path)
|
c, err := LoadAll([]string{path})
|
||||||
if err != nil || c.UI.Icons != nil || c.Values.ClipboardClear != nil {
|
if err != nil || c.UI.Icons != nil || c.Values.ClipboardClear != nil {
|
||||||
t.Errorf("%s: %+v, %v", name, c, err)
|
t.Errorf("%s: %+v, %v", name, c, err)
|
||||||
}
|
}
|
||||||
@@ -85,7 +85,7 @@ func TestLoadRejectsMistakes(t *testing.T) {
|
|||||||
"short keys": "sharing:\n keyRules:\n minLength: 8\n",
|
"short keys": "sharing:\n keyRules:\n minLength: 8\n",
|
||||||
"classes": "sharing:\n keyRules:\n minClasses: 5\n",
|
"classes": "sharing:\n keyRules:\n minClasses: 5\n",
|
||||||
} {
|
} {
|
||||||
if _, err := Load(write(t, content)); err == nil {
|
if _, err := LoadAll([]string{write(t, content)}); err == nil {
|
||||||
t.Errorf("%s: no error", name)
|
t.Errorf("%s: no error", name)
|
||||||
} else if !strings.Contains(err.Error(), "config.yaml") {
|
} else if !strings.Contains(err.Error(), "config.yaml") {
|
||||||
t.Errorf("%s: error doesn't name the file: %v", name, err)
|
t.Errorf("%s: error doesn't name the file: %v", name, err)
|
||||||
@@ -132,18 +132,18 @@ theme:
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdates(t *testing.T) {
|
func TestUpdates(t *testing.T) {
|
||||||
c, err := Load(write(t, "updates:\n autoCheck: false\n autoCheckInterval: 168h\n sources: [https://git.example.com/team/lazysecrets]\n"))
|
c, err := LoadAll([]string{write(t, "updates:\n autoCheck: false\n autoCheckInterval: 168h\n sources: [https://git.example.com/team/lazysecrets]\n")})
|
||||||
if err != nil || On(c.Updates.AutoCheck) || *c.Updates.AutoCheckInterval != 7*24*time.Hour || len(c.Updates.Sources) != 1 {
|
if err != nil || On(c.Updates.AutoCheck) || *c.Updates.AutoCheckInterval != 7*24*time.Hour || len(c.Updates.Sources) != 1 {
|
||||||
t.Errorf("updates = %+v, %v", c.Updates, err)
|
t.Errorf("updates = %+v, %v", c.Updates, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadDelay(t *testing.T) {
|
func TestLoadDelay(t *testing.T) {
|
||||||
c, err := Load(write(t, "ui:\n loadDelay: 300ms\n"))
|
c, err := LoadAll([]string{write(t, "ui:\n loadDelay: 300ms\n")})
|
||||||
if err != nil || c.UI.LoadDelay == nil || *c.UI.LoadDelay != 300*time.Millisecond {
|
if err != nil || c.UI.LoadDelay == nil || *c.UI.LoadDelay != 300*time.Millisecond {
|
||||||
t.Errorf("loadDelay = %v, %v", c.UI.LoadDelay, err)
|
t.Errorf("loadDelay = %v, %v", c.UI.LoadDelay, err)
|
||||||
}
|
}
|
||||||
if _, err := Load(write(t, "ui:\n loadDelay: 1m\n")); err == nil {
|
if _, err := LoadAll([]string{write(t, "ui:\n loadDelay: 1m\n")}); err == nil {
|
||||||
t.Error("a minute's delay accepted")
|
t.Error("a minute's delay accepted")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
package instance
|
package instance
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -117,7 +116,7 @@ func (s *Server) handle(c net.Conn) {
|
|||||||
defer c.Close()
|
defer c.Close()
|
||||||
_ = c.SetDeadline(time.Now().Add(5 * time.Second))
|
_ = c.SetDeadline(time.Now().Add(5 * time.Second))
|
||||||
var req request
|
var req request
|
||||||
if err := json.NewDecoder(bufio.NewReader(c)).Decode(&req); err != nil {
|
if err := json.NewDecoder(c).Decode(&req); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var resp response
|
var resp response
|
||||||
|
|||||||
@@ -114,19 +114,15 @@ func Check() string {
|
|||||||
}
|
}
|
||||||
out, err := exec.Command("plutil", "-extract", exeKey, "raw", filepath.Join(app, "Contents", "Info.plist")).Output()
|
out, err := exec.Command("plutil", "-extract", exeKey, "raw", filepath.Join(app, "Contents", "Info.plist")).Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "" // installed before the key was recorded: assume it works
|
return "" // can't tell: assume it works
|
||||||
}
|
}
|
||||||
if exe := strings.TrimSpace(string(out)); !exists(exe) {
|
exe := strings.TrimSpace(string(out))
|
||||||
|
if _, err := os.Stat(exe); err != nil {
|
||||||
return "lazysecrets:// links run " + exe + ", which no longer exists"
|
return "lazysecrets:// links run " + exe + ", which no longer exists"
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func exists(path string) bool {
|
|
||||||
_, err := os.Stat(path)
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Uninstall removes the applet and its registration.
|
// Uninstall removes the applet and its registration.
|
||||||
func Uninstall() (string, error) {
|
func Uninstall() (string, error) {
|
||||||
app, err := AppPath()
|
app, err := AppPath()
|
||||||
|
|||||||
@@ -140,11 +140,10 @@ func appleString(s string) string {
|
|||||||
return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"`
|
return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Launch opens a new terminal window running inner, without waiting for
|
// Launch opens a new terminal window running argv, without waiting for
|
||||||
// it.
|
// it.
|
||||||
func Launch(argv []string) error {
|
func Launch(argv []string) error {
|
||||||
cmd := exec.Command(argv[0], argv[1:]...)
|
cmd := exec.Command(argv[0], argv[1:]...)
|
||||||
cmd.Stdin, cmd.Stdout, cmd.Stderr = nil, nil, nil
|
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -166,9 +165,11 @@ func Executable() (string, error) {
|
|||||||
|
|
||||||
// passEnv lists the variables carried from the install's environment to
|
// passEnv lists the variables carried from the install's environment to
|
||||||
// lazysecrets in the new window: apps start with a minimal environment,
|
// lazysecrets in the new window: apps start with a minimal environment,
|
||||||
// without Homebrew's PATH (so no az) or the user's config locations.
|
// without Homebrew's PATH (so no az) or the user's XDG directories, so
|
||||||
|
// the window would use another cache and miss running instances' sockets.
|
||||||
var passEnv = []string{
|
var passEnv = []string{
|
||||||
"PATH", "LAZYSECRETS_CONFIG_FILE", "LAZYSECRETS_ICONS", "XDG_CONFIG_HOME", "XDG_STATE_HOME",
|
"PATH", "LAZYSECRETS_CONFIG_FILE", "LAZYSECRETS_ICONS",
|
||||||
|
"XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME", "XDG_RUNTIME_DIR", "XDG_DATA_HOME",
|
||||||
"AZURE_CONFIG_DIR", "DATABRICKS_CONFIG_FILE",
|
"AZURE_CONFIG_DIR", "DATABRICKS_CONFIG_FILE",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -107,9 +107,6 @@ func parseTerm(tok string) (term, bool) {
|
|||||||
return t, true
|
return t, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Empty reports whether the query has no terms: everything matches.
|
|
||||||
func (q Query) Empty() bool { return len(q.groups) == 0 }
|
|
||||||
|
|
||||||
// Item is a row to match: Text is everything searched, and runes
|
// Item is a row to match: Text is everything searched, and runes
|
||||||
// NameStart up to NameEnd of it are the row's name (the rest is context,
|
// NameStart up to NameEnd of it are the row's name (the rest is context,
|
||||||
// like a path or a note). Without a name, nothing gets the name bonus.
|
// like a path or a note). Without a name, nothing gets the name bonus.
|
||||||
|
|||||||
@@ -99,9 +99,3 @@ func TestAnchorsOnTheName(t *testing.T) {
|
|||||||
t.Errorf("password$: %v", got)
|
t.Errorf("password$: %v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEmpty(t *testing.T) {
|
|
||||||
if !Parse(" | ").Empty() || Parse("x").Empty() {
|
|
||||||
t.Error("Empty")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,7 @@ package azure
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -134,12 +135,7 @@ func (v vaultInfo) attrs() []provider.Attr {
|
|||||||
if v.endpoints > 0 {
|
if v.endpoints > 0 {
|
||||||
add("private", fmt.Sprintf("%d endpoint%s", v.endpoints, map[bool]string{true: "s"}[v.endpoints > 1]))
|
add("private", fmt.Sprintf("%d endpoint%s", v.endpoints, map[bool]string{true: "s"}[v.endpoints > 1]))
|
||||||
}
|
}
|
||||||
keys := make([]string, 0, len(v.tags))
|
for i, k := range slices.Sorted(maps.Keys(v.tags)) {
|
||||||
for k := range v.tags {
|
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
slices.Sort(keys)
|
|
||||||
for i, k := range keys {
|
|
||||||
label := ""
|
label := ""
|
||||||
if i == 0 {
|
if i == 0 {
|
||||||
label = "tags"
|
label = "tags"
|
||||||
|
|||||||
@@ -48,19 +48,8 @@ func newProvider(kind provider.Kind, delay time.Duration) *Provider {
|
|||||||
|
|
||||||
func (p *Provider) Kind() provider.Kind { return p.kind }
|
func (p *Provider) Kind() provider.Kind { return p.kind }
|
||||||
|
|
||||||
func (p *Provider) ID() string {
|
func (p *Provider) ID() string { return cmp.Or(p.id, string(p.kind)) }
|
||||||
if p.id != "" {
|
func (p *Provider) Label() string { return cmp.Or(p.label, string(p.kind)) }
|
||||||
return p.id
|
|
||||||
}
|
|
||||||
return string(p.kind)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Provider) Label() string {
|
|
||||||
if p.label != "" {
|
|
||||||
return p.label
|
|
||||||
}
|
|
||||||
return string(p.kind)
|
|
||||||
}
|
|
||||||
|
|
||||||
// As renames the provider, to simulate several accounts of one kind.
|
// As renames the provider, to simulate several accounts of one kind.
|
||||||
func (p *Provider) As(id, label string) *Provider {
|
func (p *Provider) As(id, label string) *Provider {
|
||||||
@@ -252,11 +241,7 @@ func (p *Provider) fail(c provider.Container, err error, types ...provider.Objec
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var base = time.Date(2026, 9, 1, 9, 30, 0, 0, time.UTC)
|
||||||
base = time.Date(2026, 9, 1, 9, 30, 0, 0, time.UTC)
|
|
||||||
yes = true
|
|
||||||
no = false
|
|
||||||
)
|
|
||||||
|
|
||||||
// ErrForbidden mimics a data-plane permission error from a backend.
|
// ErrForbidden mimics a data-plane permission error from a backend.
|
||||||
var ErrForbidden = errors.New("403 Forbidden: caller lacks permission to list secrets")
|
var ErrForbidden = errors.New("403 Forbidden: caller lacks permission to list secrets")
|
||||||
@@ -284,32 +269,32 @@ func Azure(delay time.Duration) *Provider {
|
|||||||
prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com")
|
prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com")
|
||||||
kv := p.addContainer(prod, "kv-app-prod", "https://kv-app-prod.vault.azure.net", allTypes...)
|
kv := p.addContainer(prod, "kv-app-prod", "https://kv-app-prod.vault.azure.net", allTypes...)
|
||||||
dbPassword := p.add(kv, provider.TypeSecret, provider.Object{
|
dbPassword := p.add(kv, provider.TypeSecret, provider.Object{
|
||||||
Name: "db-password", Version: "3f9c2a", Enabled: &yes, Updated: base,
|
Name: "db-password", Version: "3f9c2a", Enabled: new(true), Updated: base,
|
||||||
Expires: base.AddDate(1, 0, 0), ContentType: "text/plain",
|
Expires: base.AddDate(1, 0, 0), ContentType: "text/plain",
|
||||||
Tags: map[string]string{"owner": "data", "env": "prod"}, Reference: secretRef("kv-app-prod", "db-password"),
|
Tags: map[string]string{"owner": "data", "env": "prod"}, Reference: secretRef("kv-app-prod", "db-password"),
|
||||||
}, "correct-horse-battery-staple")
|
}, "correct-horse-battery-staple")
|
||||||
p.versions[dbPassword.ID] = []provider.Object{
|
p.versions[dbPassword.ID] = []provider.Object{
|
||||||
{Version: "3f9c2a", Enabled: &yes, Updated: base, Expires: base.AddDate(1, 0, 0), ContentType: "text/plain"},
|
{Version: "3f9c2a", Enabled: new(true), Updated: base, Expires: base.AddDate(1, 0, 0), ContentType: "text/plain"},
|
||||||
{Version: "91d3e0", Enabled: &yes, Updated: base.AddDate(0, -6, 0), Expires: base.AddDate(0, -1, 0)},
|
{Version: "91d3e0", Enabled: new(true), Updated: base.AddDate(0, -6, 0), Expires: base.AddDate(0, -1, 0)},
|
||||||
{Version: "0a77c4", Enabled: &no, Updated: base.AddDate(-1, 0, 0)},
|
{Version: "0a77c4", Enabled: new(false), Updated: base.AddDate(-1, 0, 0)},
|
||||||
}
|
}
|
||||||
p.values[dbPassword.ID+"@91d3e0"] = "previous-password"
|
p.values[dbPassword.ID+"@91d3e0"] = "previous-password"
|
||||||
p.values[dbPassword.ID+"@0a77c4"] = "original-password"
|
p.values[dbPassword.ID+"@0a77c4"] = "original-password"
|
||||||
p.add(kv, provider.TypeSecret, provider.Object{
|
p.add(kv, provider.TypeSecret, provider.Object{
|
||||||
Name: "api-key", Version: "a17b04", Enabled: &yes, Updated: base.AddDate(0, -2, 0),
|
Name: "api-key", Version: "a17b04", Enabled: new(true), Updated: base.AddDate(0, -2, 0),
|
||||||
Reference: secretRef("kv-app-prod", "api-key"),
|
Reference: secretRef("kv-app-prod", "api-key"),
|
||||||
}, "sk_live_51HxDEMO0000000000000000")
|
}, "sk_live_51HxDEMO0000000000000000")
|
||||||
p.add(kv, provider.TypeSecret, provider.Object{
|
p.add(kv, provider.TypeSecret, provider.Object{
|
||||||
Name: "legacy-token", Version: "0c11de", Enabled: &yes, Updated: base.AddDate(-1, 0, 0),
|
Name: "legacy-token", Version: "0c11de", Enabled: new(true), Updated: base.AddDate(-1, 0, 0),
|
||||||
Expires: base.AddDate(0, -3, 0), Reference: secretRef("kv-app-prod", "legacy-token"),
|
Expires: base.AddDate(0, -3, 0), Reference: secretRef("kv-app-prod", "legacy-token"),
|
||||||
}, "deprecated")
|
}, "deprecated")
|
||||||
p.add(kv, provider.TypeSecret, provider.Object{
|
p.add(kv, provider.TypeSecret, provider.Object{
|
||||||
Name: "smtp-password", Version: "7d2e90", Enabled: &no, Updated: base.AddDate(0, -2, 0),
|
Name: "smtp-password", Version: "7d2e90", Enabled: new(false), Updated: base.AddDate(0, -2, 0),
|
||||||
Reference: secretRef("kv-app-prod", "smtp-password"),
|
Reference: secretRef("kv-app-prod", "smtp-password"),
|
||||||
}, "smtp-demo-password")
|
}, "smtp-demo-password")
|
||||||
|
|
||||||
p.add(kv, provider.TypeKey, provider.Object{
|
p.add(kv, provider.TypeKey, provider.Object{
|
||||||
Name: "signing-key", Version: "5e21b7", Enabled: &yes, Updated: base.AddDate(0, -3, 0),
|
Name: "signing-key", Version: "5e21b7", Enabled: new(true), Updated: base.AddDate(0, -3, 0),
|
||||||
Attrs: []provider.Attr{
|
Attrs: []provider.Attr{
|
||||||
{Label: "key type", Value: "RSA 2048"},
|
{Label: "key type", Value: "RSA 2048"},
|
||||||
{Label: "ops", Value: "sign, verify"},
|
{Label: "ops", Value: "sign, verify"},
|
||||||
@@ -317,7 +302,7 @@ func Azure(delay time.Duration) *Provider {
|
|||||||
Reference: objectURL("kv-app-prod", "keys", "signing-key"),
|
Reference: objectURL("kv-app-prod", "keys", "signing-key"),
|
||||||
}, demoPublicKey)
|
}, demoPublicKey)
|
||||||
p.add(kv, provider.TypeKey, provider.Object{
|
p.add(kv, provider.TypeKey, provider.Object{
|
||||||
Name: "data-encryption", Version: "c40f19", Enabled: &yes, Updated: base.AddDate(0, -5, 0),
|
Name: "data-encryption", Version: "c40f19", Enabled: new(true), Updated: base.AddDate(0, -5, 0),
|
||||||
Attrs: []provider.Attr{
|
Attrs: []provider.Attr{
|
||||||
{Label: "key type", Value: "EC P-256"},
|
{Label: "key type", Value: "EC P-256"},
|
||||||
{Label: "ops", Value: "wrapKey, unwrapKey"},
|
{Label: "ops", Value: "wrapKey, unwrapKey"},
|
||||||
@@ -326,7 +311,7 @@ func Azure(delay time.Duration) *Provider {
|
|||||||
}, demoPublicKey)
|
}, demoPublicKey)
|
||||||
|
|
||||||
p.add(kv, provider.TypeCertificate, provider.Object{
|
p.add(kv, provider.TypeCertificate, provider.Object{
|
||||||
Name: "app-tls", Version: "77e0aa", Enabled: &yes, Updated: base.AddDate(0, -1, 0),
|
Name: "app-tls", Version: "77e0aa", Enabled: new(true), Updated: base.AddDate(0, -1, 0),
|
||||||
Expires: base.AddDate(0, 11, 0), ContentType: "application/x-pem-file",
|
Expires: base.AddDate(0, 11, 0), ContentType: "application/x-pem-file",
|
||||||
Attrs: []provider.Attr{
|
Attrs: []provider.Attr{
|
||||||
{Label: "subject", Value: "CN=app.example.com"},
|
{Label: "subject", Value: "CN=app.example.com"},
|
||||||
@@ -338,7 +323,7 @@ func Azure(delay time.Duration) *Provider {
|
|||||||
Reference: objectURL("kv-app-prod", "certificates", "app-tls"),
|
Reference: objectURL("kv-app-prod", "certificates", "app-tls"),
|
||||||
}, demoCert)
|
}, demoCert)
|
||||||
p.add(kv, provider.TypeCertificate, provider.Object{
|
p.add(kv, provider.TypeCertificate, provider.Object{
|
||||||
Name: "old-tls", Version: "19ab3c", Enabled: &yes, Updated: base.AddDate(-1, 0, 0),
|
Name: "old-tls", Version: "19ab3c", Enabled: new(true), Updated: base.AddDate(-1, 0, 0),
|
||||||
Expires: base.AddDate(0, -6, 0), ContentType: "application/x-pkcs12",
|
Expires: base.AddDate(0, -6, 0), ContentType: "application/x-pkcs12",
|
||||||
Attrs: []provider.Attr{
|
Attrs: []provider.Attr{
|
||||||
{Label: "subject", Value: "CN=old.example.com"},
|
{Label: "subject", Value: "CN=old.example.com"},
|
||||||
@@ -351,10 +336,10 @@ func Azure(delay time.Duration) *Provider {
|
|||||||
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
|
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
|
||||||
// A key to share env files with (S → f): random, as sharing.keyRules wants.
|
// A key to share env files with (S → f): random, as sharing.keyRules wants.
|
||||||
p.add(shared, provider.TypeSecret, provider.Object{
|
p.add(shared, provider.TypeSecret, provider.Object{
|
||||||
Name: "env-share", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "env-share"),
|
Name: "env-share", Enabled: new(true), Updated: base, Reference: secretRef("kv-shared-prod", "env-share"),
|
||||||
}, "q8Zt3VwL1mXc7Rk2Pj9Ys4Hn6Bf0Ga5Ud+Te/OiWlE=")
|
}, "q8Zt3VwL1mXc7Rk2Pj9Ys4Hn6Bf0Ga5Ud+Te/OiWlE=")
|
||||||
p.add(shared, provider.TypeSecret, provider.Object{
|
p.add(shared, provider.TypeSecret, provider.Object{
|
||||||
Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
|
Name: "smtp-password", Enabled: new(true), Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
|
||||||
}, "smtp-demo-password")
|
}, "smtp-demo-password")
|
||||||
p.fail(shared, ErrForbiddenKeys, provider.TypeKey)
|
p.fail(shared, ErrForbiddenKeys, provider.TypeKey)
|
||||||
|
|
||||||
@@ -364,19 +349,19 @@ func Azure(delay time.Duration) *Provider {
|
|||||||
dev := p.addSource("sub-dev", "platform-dev", "tenant: example.onmicrosoft.com")
|
dev := p.addSource("sub-dev", "platform-dev", "tenant: example.onmicrosoft.com")
|
||||||
kvDev := p.addContainer(dev, "kv-app-dev", "https://kv-app-dev.vault.azure.net", allTypes...)
|
kvDev := p.addContainer(dev, "kv-app-dev", "https://kv-app-dev.vault.azure.net", allTypes...)
|
||||||
p.add(kvDev, provider.TypeSecret, provider.Object{
|
p.add(kvDev, provider.TypeSecret, provider.Object{
|
||||||
Name: "db-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-app-dev", "db-password"),
|
Name: "db-password", Enabled: new(true), Updated: base, Reference: secretRef("kv-app-dev", "db-password"),
|
||||||
}, "dev-password")
|
}, "dev-password")
|
||||||
// Left behind, as dev vaults are: expired, disabled, or both.
|
// Left behind, as dev vaults are: expired, disabled, or both.
|
||||||
p.add(kvDev, provider.TypeSecret, provider.Object{
|
p.add(kvDev, provider.TypeSecret, provider.Object{
|
||||||
Name: "stripe-webhook-secret", Enabled: &yes, Updated: base.AddDate(-1, 0, 0),
|
Name: "stripe-webhook-secret", Enabled: new(true), Updated: base.AddDate(-1, 0, 0),
|
||||||
Expires: base.AddDate(0, -2, 0), Reference: secretRef("kv-app-dev", "stripe-webhook-secret"),
|
Expires: base.AddDate(0, -2, 0), Reference: secretRef("kv-app-dev", "stripe-webhook-secret"),
|
||||||
}, "whsec_demo_expired")
|
}, "whsec_demo_expired")
|
||||||
p.add(kvDev, provider.TypeSecret, provider.Object{
|
p.add(kvDev, provider.TypeSecret, provider.Object{
|
||||||
Name: "test-user-password", Enabled: &no, Updated: base.AddDate(0, -5, 0),
|
Name: "test-user-password", Enabled: new(false), Updated: base.AddDate(0, -5, 0),
|
||||||
Reference: secretRef("kv-app-dev", "test-user-password"),
|
Reference: secretRef("kv-app-dev", "test-user-password"),
|
||||||
}, "disabled-demo-password")
|
}, "disabled-demo-password")
|
||||||
p.add(kvDev, provider.TypeSecret, provider.Object{
|
p.add(kvDev, provider.TypeSecret, provider.Object{
|
||||||
Name: "vendor-token", Enabled: &no, Updated: base.AddDate(-2, 0, 0),
|
Name: "vendor-token", Enabled: new(false), Updated: base.AddDate(-2, 0, 0),
|
||||||
Expires: base.AddDate(-1, 0, 0), Reference: secretRef("kv-app-dev", "vendor-token"),
|
Expires: base.AddDate(-1, 0, 0), Reference: secretRef("kv-app-dev", "vendor-token"),
|
||||||
}, "expired-and-disabled")
|
}, "expired-and-disabled")
|
||||||
|
|
||||||
@@ -395,17 +380,17 @@ func AzureOps(delay time.Duration) *Provider {
|
|||||||
prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com")
|
prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com")
|
||||||
breakGlass := p.addContainer(prod, "kv-breakglass", "https://kv-breakglass.vault.azure.net", allTypes...)
|
breakGlass := p.addContainer(prod, "kv-breakglass", "https://kv-breakglass.vault.azure.net", allTypes...)
|
||||||
p.add(breakGlass, provider.TypeSecret, provider.Object{
|
p.add(breakGlass, provider.TypeSecret, provider.Object{
|
||||||
Name: "emergency-admin", Enabled: &yes, Updated: base.AddDate(0, -4, 0),
|
Name: "emergency-admin", Enabled: new(true), Updated: base.AddDate(0, -4, 0),
|
||||||
Expires: time.Now().AddDate(0, 0, 5).Truncate(24 * time.Hour), // soon, whenever the demo runs
|
Expires: time.Now().AddDate(0, 0, 5).Truncate(24 * time.Hour), // soon, whenever the demo runs
|
||||||
Tags: map[string]string{"owner": "ops"}, Reference: secretRef("kv-breakglass", "emergency-admin"),
|
Tags: map[string]string{"owner": "ops"}, Reference: secretRef("kv-breakglass", "emergency-admin"),
|
||||||
}, "break-glass-demo-password")
|
}, "break-glass-demo-password")
|
||||||
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
|
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
|
||||||
p.add(shared, provider.TypeSecret, provider.Object{
|
p.add(shared, provider.TypeSecret, provider.Object{
|
||||||
Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
|
Name: "smtp-password", Enabled: new(true), Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
|
||||||
}, "smtp-demo-password")
|
}, "smtp-demo-password")
|
||||||
// Unlike the first account, ops may read keys here.
|
// Unlike the first account, ops may read keys here.
|
||||||
p.add(shared, provider.TypeKey, provider.Object{
|
p.add(shared, provider.TypeKey, provider.Object{
|
||||||
Name: "backup-encryption", Version: "e9a102", Enabled: &yes, Updated: base.AddDate(0, -2, 0),
|
Name: "backup-encryption", Version: "e9a102", Enabled: new(true), Updated: base.AddDate(0, -2, 0),
|
||||||
Attrs: []provider.Attr{{Label: "key type", Value: "RSA-HSM 3072"}, {Label: "ops", Value: "wrapKey, unwrapKey"}},
|
Attrs: []provider.Attr{{Label: "key type", Value: "RSA-HSM 3072"}, {Label: "ops", Value: "wrapKey, unwrapKey"}},
|
||||||
Reference: "https://kv-shared-prod.vault.azure.net/keys/backup-encryption",
|
Reference: "https://kv-shared-prod.vault.azure.net/keys/backup-encryption",
|
||||||
}, demoPublicKey)
|
}, demoPublicKey)
|
||||||
@@ -413,12 +398,12 @@ func AzureOps(delay time.Duration) *Provider {
|
|||||||
sandbox := p.addSource("sub-ops", "ops-sandbox", "tenant: example.onmicrosoft.com")
|
sandbox := p.addSource("sub-ops", "ops-sandbox", "tenant: example.onmicrosoft.com")
|
||||||
kv := p.addContainer(sandbox, "kv-ops-tools", "https://kv-ops-tools.vault.azure.net", allTypes...)
|
kv := p.addContainer(sandbox, "kv-ops-tools", "https://kv-ops-tools.vault.azure.net", allTypes...)
|
||||||
p.add(kv, provider.TypeSecret, provider.Object{
|
p.add(kv, provider.TypeSecret, provider.Object{
|
||||||
Name: "pagerduty-token", Enabled: &yes, Updated: base.AddDate(0, -1, 0), Reference: secretRef("kv-ops-tools", "pagerduty-token"),
|
Name: "pagerduty-token", Enabled: new(true), Updated: base.AddDate(0, -1, 0), Reference: secretRef("kv-ops-tools", "pagerduty-token"),
|
||||||
}, "pd-demo-token")
|
}, "pd-demo-token")
|
||||||
// Expiring soon whenever the demo runs: the other dates are fixed,
|
// Expiring soon whenever the demo runs: the other dates are fixed,
|
||||||
// and would have passed. Tests use the first account only.
|
// and would have passed. Tests use the first account only.
|
||||||
p.add(kv, provider.TypeSecret, provider.Object{
|
p.add(kv, provider.TypeSecret, provider.Object{
|
||||||
Name: "grafana-api-key", Enabled: &yes, Updated: base.AddDate(0, -11, 0),
|
Name: "grafana-api-key", Enabled: new(true), Updated: base.AddDate(0, -11, 0),
|
||||||
Expires: time.Now().AddDate(0, 0, 12).Truncate(24 * time.Hour), Reference: secretRef("kv-ops-tools", "grafana-api-key"),
|
Expires: time.Now().AddDate(0, 0, 12).Truncate(24 * time.Hour), Reference: secretRef("kv-ops-tools", "grafana-api-key"),
|
||||||
}, "glsa_demo_expiring")
|
}, "glsa_demo_expiring")
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Package repo browses the encrypted secret files in git repositories:
|
// Package repo browses the encrypted secret files in git repositories:
|
||||||
// a repo is a source, its secret files the containers (vault level), and
|
// a repo is a source, its secret files the containers (vault level), and
|
||||||
// their keys the secrets. SOPS files are the one type so far.
|
// their keys the secrets. Secret files are SOPS files and plain env files.
|
||||||
//
|
//
|
||||||
// Listing never decrypts: SOPS encrypts values only, so a file's keys and
|
// Listing never decrypts: SOPS encrypts values only, so a file's keys and
|
||||||
// metadata are read directly. A value is decrypted when it's revealed or
|
// metadata are read directly. A value is decrypted when it's revealed or
|
||||||
@@ -106,8 +106,6 @@ func (p *Provider) load() {
|
|||||||
roots = append(roots, root)
|
roots = append(roots, root)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The repo lazysecrets started in, if any: marked, so the UI can
|
|
||||||
// put it on top, with its files under Dir first.
|
|
||||||
here, inRepo := p.current()
|
here, inRepo := p.current()
|
||||||
if inRepo {
|
if inRepo {
|
||||||
seen[here.root] = true
|
seen[here.root] = true
|
||||||
@@ -137,7 +135,6 @@ func (p *Provider) load() {
|
|||||||
add(root)
|
add(root)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Repos you starred something in, wherever they are.
|
|
||||||
for _, dir := range p.s.Starred {
|
for _, dir := range p.s.Starred {
|
||||||
if root, err := gitRoot(dir); err == nil {
|
if root, err := gitRoot(dir); err == nil {
|
||||||
add(root)
|
add(root)
|
||||||
@@ -206,13 +203,11 @@ func parallel(n int, f func(i int)) {
|
|||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
sem := make(chan struct{}, max(8, runtime.NumCPU()))
|
sem := make(chan struct{}, max(8, runtime.NumCPU()))
|
||||||
for i := range n {
|
for i := range n {
|
||||||
wg.Add(1)
|
wg.Go(func() {
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
sem <- struct{}{}
|
sem <- struct{}{}
|
||||||
defer func() { <-sem }()
|
defer func() { <-sem }()
|
||||||
f(i)
|
f(i)
|
||||||
}()
|
})
|
||||||
}
|
}
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ func NewSecretValue(b []byte) SecretValue {
|
|||||||
// Reveal returns the plaintext. Call it only to display or copy the value.
|
// Reveal returns the plaintext. Call it only to display or copy the value.
|
||||||
func (v SecretValue) Reveal() string { return string(v.b) }
|
func (v SecretValue) Reveal() string { return string(v.b) }
|
||||||
|
|
||||||
func (v SecretValue) Len() int { return len(v.b) }
|
|
||||||
|
|
||||||
func (v SecretValue) String() string { return masked }
|
func (v SecretValue) String() string { return masked }
|
||||||
func (v SecretValue) GoString() string { return masked }
|
func (v SecretValue) GoString() string { return masked }
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Rules are what a key's value must be to share with it: random enough
|
// Rules are what a key's value must be to share with it: random enough
|
||||||
@@ -40,7 +41,7 @@ func (r Rules) Check(key []byte) error {
|
|||||||
kinds++
|
kinds++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
n := len([]rune(s))
|
n := utf8.RuneCountInString(s)
|
||||||
var has, needs []string
|
var has, needs []string
|
||||||
if n < r.MinLength {
|
if n < r.MinLength {
|
||||||
has = append(has, fmt.Sprintf("%d characters", n))
|
has = append(has, fmt.Sprintf("%d characters", n))
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ func Read(r ref.Ref) (Sealed, error) {
|
|||||||
return Sealed{}, errors.New("share link is from a newer lazysecrets: update it (lazysecrets update)")
|
return Sealed{}, errors.New("share link is from a newer lazysecrets: update it (lazysecrets update)")
|
||||||
}
|
}
|
||||||
n, size := binary.Uvarint(b[1:])
|
n, size := binary.Uvarint(b[1:])
|
||||||
if size <= 0 || n > uint64(len(b)) {
|
if size <= 0 {
|
||||||
return Sealed{}, bad
|
return Sealed{}, bad
|
||||||
}
|
}
|
||||||
rest := b[1+size:]
|
rest := b[1+size:]
|
||||||
|
|||||||
+11
-17
@@ -3,6 +3,7 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -654,7 +655,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case detailsMsg:
|
case detailsMsg:
|
||||||
if msg.req == m.reqID[slotDetails] {
|
if msg.req == m.reqID[slotDetails] {
|
||||||
m.done(slotDetails)
|
m.cancelReq(slotDetails)
|
||||||
}
|
}
|
||||||
if errors.Is(msg.err, context.Canceled) || errors.Is(msg.err, context.DeadlineExceeded) {
|
if errors.Is(msg.err, context.Canceled) || errors.Is(msg.err, context.DeadlineExceeded) {
|
||||||
delete(m.details, msg.key) // tried again when shown again
|
delete(m.details, msg.key) // tried again when shown again
|
||||||
@@ -685,7 +686,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if msg.req != m.reqID[slotContainers] {
|
if msg.req != m.reqID[slotContainers] {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
m.done(slotContainers)
|
m.cancelReq(slotContainers)
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.noteErr(msg.err)
|
m.noteErr(msg.err)
|
||||||
if src, ok := m.selectedSource(); ok {
|
if src, ok := m.selectedSource(); ok {
|
||||||
@@ -703,7 +704,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if msg.req != m.reqID[slotObjects] {
|
if msg.req != m.reqID[slotObjects] {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
m.done(slotObjects)
|
m.cancelReq(slotObjects)
|
||||||
if errors.Is(msg.err, provider.ErrUnsupported) && m.versionsOf != nil {
|
if errors.Is(msg.err, provider.ErrUnsupported) && m.versionsOf != nil {
|
||||||
m.flash = warning(fmt.Sprintf("versions aren't supported for %s", m.versionsOf.Container.Source.Kind))
|
m.flash = warning(fmt.Sprintf("versions aren't supported for %s", m.versionsOf.Container.Source.Kind))
|
||||||
m.closeVersions()
|
m.closeVersions()
|
||||||
@@ -733,7 +734,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if msg.req != m.reqID[slotValue] {
|
if msg.req != m.reqID[slotValue] {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
m.done(slotValue)
|
m.cancelReq(slotValue)
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.noteErr(msg.err)
|
m.noteErr(msg.err)
|
||||||
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
|
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
|
||||||
@@ -748,7 +749,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if msg.req != m.reqID[slotVersions] {
|
if msg.req != m.reqID[slotVersions] {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
m.done(slotVersions)
|
m.cancelReq(slotVersions)
|
||||||
// Errors and unsupported providers just leave the count out.
|
// Errors and unsupported providers just leave the count out.
|
||||||
if msg.err == nil {
|
if msg.err == nil {
|
||||||
m.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()}
|
m.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()}
|
||||||
@@ -760,7 +761,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if msg.req != m.reqID[slotDescribe] {
|
if msg.req != m.reqID[slotDescribe] {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
m.done(slotDescribe)
|
m.cancelReq(slotDescribe)
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.noteErr(msg.err)
|
m.noteErr(msg.err)
|
||||||
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
|
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
|
||||||
@@ -775,7 +776,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if msg.req != m.reqID[slotCopy] {
|
if msg.req != m.reqID[slotCopy] {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
m.done(slotCopy)
|
m.cancelReq(slotCopy)
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.noteErr(msg.err)
|
m.noteErr(msg.err)
|
||||||
m.flash = flash{text: "copy failed: " + msg.err.Error(), isErr: true}
|
m.flash = flash{text: "copy failed: " + msg.err.Error(), isErr: true}
|
||||||
@@ -887,7 +888,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
|
|||||||
case key.Matches(msg, k.Quit):
|
case key.Matches(msg, k.Quit):
|
||||||
return nil, true
|
return nil, true
|
||||||
case key.Matches(msg, k.Help):
|
case key.Matches(msg, k.Help):
|
||||||
return m.showKeybindings(), false
|
return m.showKeybindingsFor(""), false
|
||||||
case key.Matches(msg, k.Up):
|
case key.Matches(msg, k.Up):
|
||||||
m.move(-1)
|
m.move(-1)
|
||||||
case key.Matches(msg, k.Down):
|
case key.Matches(msg, k.Down):
|
||||||
@@ -1167,14 +1168,11 @@ func (m *Model) syncSelection() tea.Cmd {
|
|||||||
if m.onLinks() {
|
if m.onLinks() {
|
||||||
m.showLinkedContainers()
|
m.showLinkedContainers()
|
||||||
} else if src, ok := m.selectedSource(); ok {
|
} else if src, ok := m.selectedSource(); ok {
|
||||||
m.containers.Title = "[2] Vaults"
|
|
||||||
m.containers.Empty = "no vaults found"
|
m.containers.Empty = "no vaults found"
|
||||||
switch src.Kind {
|
switch src.Kind {
|
||||||
case provider.KindDatabricks:
|
case provider.KindDatabricks:
|
||||||
m.containers.Title = "[2] Scopes"
|
|
||||||
m.containers.Empty = "no secret scopes"
|
m.containers.Empty = "no secret scopes"
|
||||||
case provider.KindRepo:
|
case provider.KindRepo:
|
||||||
m.containers.Title = "[2] Files"
|
|
||||||
m.containers.Empty = "no SOPS or env files here (see docs/repos.md)"
|
m.containers.Empty = "no SOPS or env files here (see docs/repos.md)"
|
||||||
}
|
}
|
||||||
cmds = append(cmds, m.showContainers(src, id))
|
cmds = append(cmds, m.showContainers(src, id))
|
||||||
@@ -1319,8 +1317,7 @@ func (m *Model) countVersions(o provider.Object) tea.Cmd {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// closeVersions returns the Secrets pane to the object list, with the
|
// closeVersions returns the Secrets pane to the object list.
|
||||||
// cursor on the object whose versions were shown.
|
|
||||||
func (m *Model) closeVersions() {
|
func (m *Model) closeVersions() {
|
||||||
m.versionsOf = nil
|
m.versionsOf = nil
|
||||||
}
|
}
|
||||||
@@ -1517,9 +1514,6 @@ func (m *Model) settled(ctx context.Context, load tea.Cmd) tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// done releases the context of a completed request.
|
|
||||||
func (m *Model) done(p slot) { m.cancelReq(p) }
|
|
||||||
|
|
||||||
// loadSources starts a new generation of source loads, superseding any in
|
// loadSources starts a new generation of source loads, superseding any in
|
||||||
// flight.
|
// flight.
|
||||||
func (m *Model) loadSources() tea.Cmd {
|
func (m *Model) loadSources() tea.Cmd {
|
||||||
@@ -1639,7 +1633,7 @@ func (m *Model) describe(o provider.Object) tea.Cmd {
|
|||||||
func (m *Model) copyValue() tea.Cmd {
|
func (m *Model) copyValue() tea.Cmd {
|
||||||
if m.focus == paneDetail && m.fields.Shown() {
|
if m.focus == paneDetail && m.fields.Shown() {
|
||||||
if f, ok := m.fields.Selected(); ok {
|
if f, ok := m.fields.Selected(); ok {
|
||||||
return m.copyText(fieldName(f), cmpOr(f.URL, f.Value), false)
|
return m.copyText(fieldName(f), cmp.Or(f.URL, f.Value), false)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -991,11 +991,11 @@ func TestRebindingShowsEverywhere(t *testing.T) {
|
|||||||
if !strings.Contains(scr, "z to reveal") || strings.Contains(scr, "space to reveal") {
|
if !strings.Contains(scr, "z to reveal") || strings.Contains(scr, "space to reveal") {
|
||||||
t.Errorf("detail hint doesn't follow the binding:\n%s", scr)
|
t.Errorf("detail hint doesn't follow the binding:\n%s", scr)
|
||||||
}
|
}
|
||||||
if !strings.Contains(lastLine(scr), "z show") {
|
if !strings.Contains(lastLines(scr, 1), "z show") {
|
||||||
t.Errorf("help bar: %q", lastLine(scr))
|
t.Errorf("help bar: %q", lastLines(scr, 1))
|
||||||
}
|
}
|
||||||
d.keys("z")
|
d.keys("z")
|
||||||
if bar := lastLine(d.screen()); !strings.Contains(bar, "z hide") {
|
if bar := lastLines(d.screen(), 1); !strings.Contains(bar, "z hide") {
|
||||||
t.Errorf("help bar with the value shown: %q", bar)
|
t.Errorf("help bar with the value shown: %q", bar)
|
||||||
}
|
}
|
||||||
d.keys("z")
|
d.keys("z")
|
||||||
@@ -1125,7 +1125,7 @@ func TestAccountPane(t *testing.T) {
|
|||||||
if !strings.Contains(scr, "Alice Example") {
|
if !strings.Contains(scr, "Alice Example") {
|
||||||
t.Errorf("account not shown:\n%s", scr)
|
t.Errorf("account not shown:\n%s", scr)
|
||||||
}
|
}
|
||||||
bar := lastLine(scr)
|
bar := lastLines(scr, 1)
|
||||||
if !strings.Contains(bar, "copy") || strings.Contains(bar, "reveal") || strings.Contains(bar, "versions") {
|
if !strings.Contains(bar, "copy") || strings.Contains(bar, "reveal") || strings.Contains(bar, "versions") {
|
||||||
t.Errorf("help bar: %q", bar)
|
t.Errorf("help bar: %q", bar)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ func (m *Model) refreshShown(before []provider.Object) tea.Cmd {
|
|||||||
if i < 0 || sameListing(before[i], s) {
|
if i < 0 || sameListing(before[i], s) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
m.detail.Refresh(s)
|
m.detail.SetDescribed(s)
|
||||||
uncache(m, m.cache.details, detailKey(s))
|
uncache(m, m.cache.details, detailKey(s))
|
||||||
return m.describe(s)
|
return m.describe(s)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
@@ -84,10 +85,7 @@ func (m *Model) showCommands() tea.Cmd {
|
|||||||
}
|
}
|
||||||
var opts []option
|
var opts []option
|
||||||
for _, c := range cmds {
|
for _, c := range cmds {
|
||||||
label := c.Description
|
label := cmp.Or(c.Description, c.Command)
|
||||||
if label == "" {
|
|
||||||
label = c.Command
|
|
||||||
}
|
|
||||||
if c.Key != "" {
|
if c.Key != "" {
|
||||||
label = m.st.Label.Render(fmt.Sprintf("%-8s", c.Key)) + label
|
label = m.st.Label.Render(fmt.Sprintf("%-8s", c.Key)) + label
|
||||||
}
|
}
|
||||||
@@ -122,7 +120,7 @@ func (m *Model) askPrompt(c commands.Command, it commands.Item, obj *provider.Ob
|
|||||||
in.SetValue(p.Default)
|
in.SetValue(p.Default)
|
||||||
in.CursorEnd()
|
in.CursorEnd()
|
||||||
m.prompt = &modal{
|
m.prompt = &modal{
|
||||||
title: cmpOr(c.Description, "Command"), input: &in,
|
title: cmp.Or(c.Description, "Command"), input: &in,
|
||||||
submit: func(m *Model, text string) tea.Cmd {
|
submit: func(m *Model, text string) tea.Cmd {
|
||||||
it.Prompt[p.Name] = commands.Q(text)
|
it.Prompt[p.Name] = commands.Q(text)
|
||||||
return m.askPrompt(c, it, obj, i+1)
|
return m.askPrompt(c, it, obj, i+1)
|
||||||
@@ -151,7 +149,7 @@ func (m *Model) confirmCommand(c commands.Command, it commands.Item, obj *provid
|
|||||||
case strings.HasPrefix(c.Value, "env:"):
|
case strings.HasPrefix(c.Value, "env:"):
|
||||||
lines = append(lines, "", "It gets the value of "+string(it.Name)+" in $"+strings.TrimPrefix(c.Value, "env:")+".")
|
lines = append(lines, "", "It gets the value of "+string(it.Name)+" in $"+strings.TrimPrefix(c.Value, "env:")+".")
|
||||||
}
|
}
|
||||||
m.showMenu("Run "+cmpOr(c.Description, "command")+"?", lines, []option{
|
m.showMenu("Run "+cmp.Or(c.Description, "command")+"?", lines, []option{
|
||||||
{key: "y", label: "Run it", run: func(m *Model) tea.Cmd { return m.runCommand(c, rendered, obj) }},
|
{key: "y", label: "Run it", run: func(m *Model) tea.Cmd { return m.runCommand(c, rendered, obj) }},
|
||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
@@ -177,7 +175,7 @@ func (m *Model) runCommand(c commands.Command, rendered string, obj *provider.Ob
|
|||||||
|
|
||||||
// execCommand runs the command as its output mode says.
|
// execCommand runs the command as its output mode says.
|
||||||
func (m *Model) execCommand(c commands.Command, rendered string, value provider.SecretValue) tea.Cmd {
|
func (m *Model) execCommand(c commands.Command, rendered string, value provider.SecretValue) tea.Cmd {
|
||||||
shell := cmpOr(os.Getenv("SHELL"), "/bin/sh")
|
shell := cmp.Or(os.Getenv("SHELL"), "/bin/sh")
|
||||||
setValue := func(cmd *exec.Cmd) {
|
setValue := func(cmd *exec.Cmd) {
|
||||||
switch {
|
switch {
|
||||||
case c.Value == "stdin":
|
case c.Value == "stdin":
|
||||||
@@ -195,7 +193,7 @@ func (m *Model) execCommand(c commands.Command, rendered string, value provider.
|
|||||||
m.flash = flash{}
|
m.flash = flash{}
|
||||||
return tea.ExecProcess(cmd, func(err error) tea.Msg { return commandDoneMsg{cmd: c, err: err} })
|
return tea.ExecProcess(cmd, func(err error) tea.Msg { return commandDoneMsg{cmd: c, err: err} })
|
||||||
}
|
}
|
||||||
m.flash = flash{text: "running " + cmpOr(c.Description, "command") + "…"}
|
m.flash = flash{text: "running " + cmp.Or(c.Description, "command") + "…"}
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -219,7 +217,7 @@ func (m *Model) handleCommandValue(msg commandValueMsg) tea.Cmd {
|
|||||||
const maxOutputLines = 30
|
const maxOutputLines = 30
|
||||||
|
|
||||||
func (m *Model) handleCommandDone(msg commandDoneMsg) {
|
func (m *Model) handleCommandDone(msg commandDoneMsg) {
|
||||||
name := cmpOr(msg.cmd.Description, "command")
|
name := cmp.Or(msg.cmd.Description, "command")
|
||||||
if msg.cmd.Out() != commands.Popup || m.prompt != nil || m.search != nil {
|
if msg.cmd.Out() != commands.Popup || m.prompt != nil || m.search != nil {
|
||||||
switch {
|
switch {
|
||||||
case msg.err != nil:
|
case msg.err != nil:
|
||||||
@@ -298,11 +296,7 @@ func (m *Model) commandItem(ctx string) (commands.Item, *provider.Object, bool)
|
|||||||
if r, err := ref.ForObject(o, version); err == nil {
|
if r, err := ref.ForObject(o, version); err == nil {
|
||||||
it.Link = commands.Q(r.String())
|
it.Link = commands.Q(r.String())
|
||||||
}
|
}
|
||||||
if version != "" {
|
o.Version = version // "": the current value, like copying it
|
||||||
o.Version = version
|
|
||||||
} else {
|
|
||||||
o.Version = "" // the current value, like copying it
|
|
||||||
}
|
|
||||||
return it, &o, true
|
return it, &o, true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,10 +306,3 @@ func newTextInput(prompt string, width int) textinput.Model {
|
|||||||
in.SetWidth(width)
|
in.SetWidth(width)
|
||||||
return in
|
return in
|
||||||
}
|
}
|
||||||
|
|
||||||
func cmpOr(a, b string) string {
|
|
||||||
if a != "" {
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/commands"
|
"git.kebcloud.no/keb/lazysecrets/internal/commands"
|
||||||
|
"github.com/charmbracelet/x/ansi"
|
||||||
)
|
)
|
||||||
|
|
||||||
func commandDriver(t *testing.T, cmds ...commands.Command) *driver {
|
func commandDriver(t *testing.T, cmds ...commands.Command) *driver {
|
||||||
@@ -34,7 +35,7 @@ func TestCommandGetsValueOnInput(t *testing.T) {
|
|||||||
// temporary path's length.
|
// temporary path's length.
|
||||||
shown := ""
|
shown := ""
|
||||||
if d.m.prompt != nil {
|
if d.m.prompt != nil {
|
||||||
shown = ansiStrip(strings.Join(d.m.prompt.lines, ""))
|
shown = ansi.Strip(strings.Join(d.m.prompt.lines, ""))
|
||||||
}
|
}
|
||||||
if !strings.Contains(scr, "Run Save it?") || !strings.Contains(shown, "db-password 'team a'") || !strings.Contains(shown, "on its input") {
|
if !strings.Contains(scr, "Run Save it?") || !strings.Contains(shown, "db-password 'team a'") || !strings.Contains(shown, "on its input") {
|
||||||
t.Fatalf("no confirmation with the command %q:\n%s", shown, scr)
|
t.Fatalf("no confirmation with the command %q:\n%s", shown, scr)
|
||||||
|
|||||||
@@ -242,7 +242,7 @@ func (m *Model) openInEditor(f provider.File, finished func(error) tea.Msg) tea.
|
|||||||
return tea.QuitMsg{}
|
return tea.QuitMsg{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if editor := cmpOr(m.getenv("VISUAL"), m.getenv("EDITOR")); editor != "" {
|
if editor := cmp.Or(m.getenv("VISUAL"), m.getenv("EDITOR")); editor != "" {
|
||||||
// Through the shell, so EDITOR can carry arguments ("code -w").
|
// Through the shell, so EDITOR can carry arguments ("code -w").
|
||||||
args := append([]string{"-c", editor + ` "$@"`, "lazysecrets"}, editorArgs(editor, f)...)
|
args := append([]string{"-c", editor + ` "$@"`, "lazysecrets"}, editorArgs(editor, f)...)
|
||||||
m.flash = flash{}
|
m.flash = flash{}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
||||||
@@ -30,7 +31,7 @@ func (m Model) expiringItems() []expiringItem {
|
|||||||
for _, c := range m.cache.containers[row].value {
|
for _, c := range m.cache.containers[row].value {
|
||||||
for _, t := range c.ObjectTypes() {
|
for _, t := range c.ObjectTypes() {
|
||||||
for _, o := range m.cache.objects[cachefile.ObjectsKey(c, t)].value {
|
for _, o := range m.cache.objects[cachefile.ObjectsKey(c, t)].value {
|
||||||
if state, _ := m.expiry(o); state != notExpiring {
|
if state, _ := m.expiry(o); state != expiry.None {
|
||||||
out = append(out, expiringItem{o, row})
|
out = append(out, expiringItem{o, row})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -51,11 +52,11 @@ func (m Model) expiringFields() []panes.Field {
|
|||||||
var out []panes.Field
|
var out []panes.Field
|
||||||
for _, it := range items {
|
for _, it := range items {
|
||||||
state, left := m.expiry(it.o)
|
state, left := m.expiry(it.o)
|
||||||
mark := "⚠ " + daysLeft(left)
|
mark := "⚠ " + expiry.Left(left)
|
||||||
if state == expired {
|
if state == expiry.Expired {
|
||||||
mark = "✗ expired"
|
mark = "✗ expired"
|
||||||
}
|
}
|
||||||
f := panes.Field{Value: mark + " " + it.o.Name + " · " + it.o.Container.Name, Err: state == expired, Row: it.row}
|
f := panes.Field{Value: mark + " " + it.o.Name + " · " + it.o.Container.Name, Err: state == expiry.Expired, Row: it.row}
|
||||||
if r, err := ref.ForObject(it.o, ""); err == nil {
|
if r, err := ref.ForObject(it.o, ""); err == nil {
|
||||||
f.Link = r.String()
|
f.Link = r.String()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
|
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
|
||||||
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -12,30 +11,19 @@ import (
|
|||||||
// list once they have expired (✗ expired) or will within the configured
|
// list once they have expired (✗ expired) or will within the configured
|
||||||
// warning period (⚠ 12d), and typing "expir" in the filter finds them.
|
// warning period (⚠ 12d), and typing "expir" in the filter finds them.
|
||||||
|
|
||||||
type expiryState = expiry.State
|
|
||||||
|
|
||||||
const (
|
|
||||||
notExpiring = expiry.None
|
|
||||||
expiresSoon = expiry.Soon
|
|
||||||
expired = expiry.Expired
|
|
||||||
)
|
|
||||||
|
|
||||||
// expiry tells whether o has expired or will within the warning period,
|
// expiry tells whether o has expired or will within the warning period,
|
||||||
// and how long it has left.
|
// and how long it has left.
|
||||||
func (m Model) expiry(o provider.Object) (expiryState, time.Duration) {
|
func (m Model) expiry(o provider.Object) (expiry.State, time.Duration) {
|
||||||
return expiry.Of(o.Expires, m.now(), m.expiryWarning)
|
return expiry.Of(o.Expires, m.now(), m.expiryWarning)
|
||||||
}
|
}
|
||||||
|
|
||||||
// expiryWord is matched by the filter: "expired" or "expiring".
|
// expiryWord is matched by the filter: "expired" or "expiring".
|
||||||
func (m Model) expiryWord(o provider.Object) string {
|
func (m Model) expiryWord(o provider.Object) string {
|
||||||
switch state, _ := m.expiry(o); state {
|
switch state, _ := m.expiry(o); state {
|
||||||
case expired:
|
case expiry.Expired:
|
||||||
return "expired"
|
return "expired"
|
||||||
case expiresSoon:
|
case expiry.Soon:
|
||||||
return "expiring"
|
return "expiring"
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// daysLeft is a short "12d", or "5h" on the last day.
|
|
||||||
func daysLeft(d time.Duration) string { return expiry.Left(d) }
|
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||||
@@ -134,10 +135,10 @@ func (m Model) objectRender() func(styles.Styles, provider.Object, int) string {
|
|||||||
}
|
}
|
||||||
line = marks(sty, n, o.Container.Source.Provider, m.recentIcon, m.now()) + line
|
line = marks(sty, n, o.Container.Source.Provider, m.recentIcon, m.now()) + line
|
||||||
switch state, left := m.expiry(o); state {
|
switch state, left := m.expiry(o); state {
|
||||||
case expired:
|
case expiry.Expired:
|
||||||
line += " " + sty.Error.Render("✗ expired")
|
line += " " + sty.Error.Render("✗ expired")
|
||||||
case expiresSoon:
|
case expiry.Soon:
|
||||||
line += " " + sty.Warning.Render("⚠ "+daysLeft(left))
|
line += " " + sty.Warning.Render("⚠ "+expiry.Left(left))
|
||||||
}
|
}
|
||||||
return withNote(sty, line, n.Note, width)
|
return withNote(sty, line, n.Note, width)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||||
|
"github.com/charmbracelet/x/ansi"
|
||||||
)
|
)
|
||||||
|
|
||||||
// jump opens the favourites list, filters it and jumps to the first match.
|
// jump opens the favourites list, filters it and jumps to the first match.
|
||||||
@@ -429,7 +430,7 @@ func TestStarIsPerAccount(t *testing.T) {
|
|||||||
d.keys("F")
|
d.keys("F")
|
||||||
var labels []string
|
var labels []string
|
||||||
for _, o := range d.m.search.shown {
|
for _, o := range d.m.search.shown {
|
||||||
labels = append(labels, ansiStrip(o.label))
|
labels = append(labels, ansi.Strip(o.label))
|
||||||
}
|
}
|
||||||
if len(labels) != 1 || !strings.Contains(labels[0], "platform-prod · alice") {
|
if len(labels) != 1 || !strings.Contains(labels[0], "platform-prod · alice") {
|
||||||
t.Errorf("favourites should list alice's star only: %q", labels)
|
t.Errorf("favourites should list alice's star only: %q", labels)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -61,7 +62,7 @@ func (m *Model) syncFields() tea.Cmd {
|
|||||||
case !ok:
|
case !ok:
|
||||||
m.fields.Show("", nil)
|
m.fields.Show("", nil)
|
||||||
case m.selectedProblem() != "":
|
case m.selectedProblem() != "":
|
||||||
m.fields.Show(cmpOr(r.label, r.source.Name), m.problemFields(m.selectedProblem()))
|
m.fields.Show(cmp.Or(r.label, r.source.Name), m.problemFields(m.selectedProblem()))
|
||||||
case r.kind == rowGroup:
|
case r.kind == rowGroup:
|
||||||
m.showAccount(r)
|
m.showAccount(r)
|
||||||
case r.kind == rowSource:
|
case r.kind == rowSource:
|
||||||
@@ -134,7 +135,7 @@ func (m Model) sourceFields(src provider.Source) []panes.Field {
|
|||||||
add("web", src.URL)
|
add("web", src.URL)
|
||||||
if e, ok := m.cache.containers[sourceRowKey(src.Provider, src.ID)]; ok {
|
if e, ok := m.cache.containers[sourceRowKey(src.Provider, src.ID)]; ok {
|
||||||
label := map[provider.Kind]string{provider.KindDatabricks: "scopes", provider.KindRepo: "files"}[src.Kind]
|
label := map[provider.Kind]string{provider.KindDatabricks: "scopes", provider.KindRepo: "files"}[src.Kind]
|
||||||
add(cmpOr(label, "vaults"), fmt.Sprint(len(e.value)))
|
add(cmp.Or(label, "vaults"), fmt.Sprint(len(e.value)))
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -158,7 +159,7 @@ func (m Model) containerFields(c provider.Container) []panes.Field {
|
|||||||
case provider.KindRepo:
|
case provider.KindRepo:
|
||||||
add("format", c.Detail)
|
add("format", c.Detail)
|
||||||
}
|
}
|
||||||
add("source", cmpOr(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
|
add("source", cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
|
||||||
add("web", c.URL)
|
add("web", c.URL)
|
||||||
for _, t := range c.ObjectTypes() {
|
for _, t := range c.ObjectTypes() {
|
||||||
if e, ok := m.cache.objects[cachefile.ObjectsKey(c, t)]; ok {
|
if e, ok := m.cache.objects[cachefile.ObjectsKey(c, t)]; ok {
|
||||||
@@ -221,5 +222,5 @@ func (m Model) problemFields(problem string) []panes.Field {
|
|||||||
// fieldName names a copied field: its label, or a row of several under
|
// fieldName names a copied field: its label, or a row of several under
|
||||||
// one label (a tag), its value.
|
// one label (a tag), its value.
|
||||||
func fieldName(f panes.Field) string {
|
func fieldName(f panes.Field) string {
|
||||||
return cmpOr(f.Label, f.Value)
|
return cmp.Or(f.Label, f.Value)
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -16,7 +17,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// The help bar shows the keys that matter in the focused pane; ? lists all
|
// The help bar shows the keys that matter in the focused pane; ? lists all
|
||||||
// of them in a menu (showKeybindings).
|
// of them in a menu (showKeybindingsFor).
|
||||||
|
|
||||||
// newHelp styles the help bar to read clearly: keys like labels elsewhere,
|
// newHelp styles the help bar to read clearly: keys like labels elsewhere,
|
||||||
// descriptions in the normal text colour, only the separators dimmed. The
|
// descriptions in the normal text colour, only the separators dimmed. The
|
||||||
@@ -84,11 +85,6 @@ func (c contextHelp) FullHelp() [][]key.Binding { return [][]key.Binding{c.Short
|
|||||||
|
|
||||||
var paneNames = [paneCount]string{"Sources", "Vaults", "Secrets", "Detail", "Status"}
|
var paneNames = [paneCount]string{"Sources", "Vaults", "Secrets", "Detail", "Status"}
|
||||||
|
|
||||||
// showKeybindings opens the ? menu, like lazygit's: every binding with
|
|
||||||
// what it does, the focused pane's first, filterable by key, description
|
|
||||||
// and group. Enter runs the selected one.
|
|
||||||
func (m *Model) showKeybindings() tea.Cmd { return m.showKeybindingsFor("") }
|
|
||||||
|
|
||||||
// showKeybindingsFor opens the ? menu, like lazygit's: every binding with
|
// showKeybindingsFor opens the ? menu, like lazygit's: every binding with
|
||||||
// what it does, filterable by key, description and group; enter runs the
|
// what it does, filterable by key, description and group; enter runs the
|
||||||
// selected one. Opened from the panes, it lists the focused pane's keys
|
// selected one. Opened from the panes, it lists the focused pane's keys
|
||||||
@@ -154,7 +150,7 @@ func (m *Model) showKeybindingsFor(ctx string) tea.Cmd {
|
|||||||
if cmds := m.commandsHere(); ctx == "" && len(cmds) > 0 {
|
if cmds := m.commandsHere(); ctx == "" && len(cmds) > 0 {
|
||||||
opts = append(opts, option{label: "Custom commands", header: true})
|
opts = append(opts, option{label: "Custom commands", header: true})
|
||||||
for _, c := range cmds {
|
for _, c := range cmds {
|
||||||
label := m.st.Label.Render(fmt.Sprintf("%-14s", c.Key)) + fmt.Sprintf("%-26s", cmpOr(c.Description, "command")) + m.st.Muted.Render("custom")
|
label := m.st.Label.Render(fmt.Sprintf("%-14s", c.Key)) + fmt.Sprintf("%-26s", cmp.Or(c.Description, "command")) + m.st.Muted.Render("custom")
|
||||||
opts = append(opts, option{label: label, tip: c.Command, run: func(m *Model) tea.Cmd { return m.startCommand(c) }})
|
opts = append(opts, option{label: label, tip: c.Command, run: func(m *Model) tea.Cmd { return m.startCommand(c) }})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,11 +136,11 @@ func TestLayoutIsSaved(t *testing.T) {
|
|||||||
func TestContextHelpBar(t *testing.T) {
|
func TestContextHelpBar(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
d := newDriver(t)
|
d := newDriver(t)
|
||||||
if bar := lastLine(d.screen()); !strings.Contains(bar, "S share") || !strings.HasSuffix(strings.TrimSpace(bar), "? help • q quit") || strings.Contains(bar, "space show") {
|
if bar := lastLines(d.screen(), 1); !strings.Contains(bar, "S share") || !strings.HasSuffix(strings.TrimSpace(bar), "? help • q quit") || strings.Contains(bar, "space show") {
|
||||||
t.Errorf("Sources help bar: %q", bar)
|
t.Errorf("Sources help bar: %q", bar)
|
||||||
}
|
}
|
||||||
d.keys("3")
|
d.keys("3")
|
||||||
if bar := lastLine(d.screen()); !strings.Contains(bar, "s copy link") || !strings.Contains(bar, "S share") {
|
if bar := lastLines(d.screen(), 1); !strings.Contains(bar, "s copy link") || !strings.Contains(bar, "S share") {
|
||||||
t.Errorf("Secrets help bar: %q", bar)
|
t.Errorf("Secrets help bar: %q", bar)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,11 +225,6 @@ func TestKeyPress(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func lastLine(s string) string {
|
|
||||||
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
|
|
||||||
return lines[len(lines)-1]
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNoHighlightWhileTerminalBlurred(t *testing.T) {
|
func TestNoHighlightWhileTerminalBlurred(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
d := newDriver(t)
|
d := newDriver(t)
|
||||||
@@ -264,7 +259,7 @@ func TestVersionInStatus(t *testing.T) {
|
|||||||
if !strings.Contains(d.screen(), "[0] lazysecrets v0.1.0") {
|
if !strings.Contains(d.screen(), "[0] lazysecrets v0.1.0") {
|
||||||
t.Errorf("no version in the status pane:\n%s", d.screen())
|
t.Errorf("no version in the status pane:\n%s", d.screen())
|
||||||
}
|
}
|
||||||
if bar := lastLine(d.screen()); strings.Contains(bar, "v0.1.0") || !strings.Contains(bar, "? help • q quit") {
|
if bar := lastLines(d.screen(), 1); strings.Contains(bar, "v0.1.0") || !strings.Contains(bar, "? help • q quit") {
|
||||||
t.Errorf("help line %q", bar)
|
t.Errorf("help line %q", bar)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"charm.land/bubbles/v2/textinput"
|
"charm.land/bubbles/v2/textinput"
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
||||||
@@ -433,7 +434,7 @@ func (m *Model) handleResolved(msg resolvedMsg) {
|
|||||||
// (ref.Ref.Account), if it's one of them.
|
// (ref.Ref.Account), if it's one of them.
|
||||||
func preferredRow(r ref.Ref, choices []choice) (string, bool) {
|
func preferredRow(r ref.Ref, choices []choice) (string, bool) {
|
||||||
for _, c := range choices {
|
for _, c := range choices {
|
||||||
if r.Account != "" && providerOf(c.sourceRow) == r.Account {
|
if r.Account != "" && cachefile.ProviderOf(c.sourceRow) == r.Account {
|
||||||
return c.sourceRow, true
|
return c.sourceRow, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -686,7 +687,7 @@ func (m *Model) share(menu bool) tea.Cmd {
|
|||||||
share := func(k string) func(*Model) tea.Cmd {
|
share := func(k string) func(*Model) tea.Cmd {
|
||||||
return func(m *Model) tea.Cmd { return m.shareContainer(c, k) }
|
return func(m *Model) tea.Cmd { return m.shareContainer(c, k) }
|
||||||
}
|
}
|
||||||
opts := []option{option{key: "o", label: "Lazysecrets link", run: share("o")}}
|
opts := []option{{key: "o", label: "Lazysecrets link", run: share("o")}}
|
||||||
if c.Source.Kind == provider.KindAzure && m.objType != provider.TypeSecret {
|
if c.Source.Kind == provider.KindAzure && m.objType != provider.TypeSecret {
|
||||||
opts = append(opts, option{key: "t", label: "Lazysecrets link to the " + tabLabels[m.objType] + " tab", run: share("t")})
|
opts = append(opts, option{key: "t", label: "Lazysecrets link to the " + tabLabels[m.objType] + " tab", run: share("t")})
|
||||||
}
|
}
|
||||||
@@ -714,7 +715,7 @@ func (m *Model) share(menu bool) tea.Cmd {
|
|||||||
return func(m *Model) tea.Cmd { return m.shareObject(o, k) }
|
return func(m *Model) tea.Cmd { return m.shareObject(o, k) }
|
||||||
}
|
}
|
||||||
opts := []option{
|
opts := []option{
|
||||||
option{key: "o", label: "Lazysecrets link", run: share("o")},
|
{key: "o", label: "Lazysecrets link", run: share("o")},
|
||||||
}
|
}
|
||||||
if o.Container.Source.Kind == provider.KindAzure {
|
if o.Container.Source.Kind == provider.KindAzure {
|
||||||
opts = append(opts, option{key: "u", label: "Key Vault URL", run: share("u")})
|
opts = append(opts, option{key: "u", label: "Key Vault URL", run: share("u")})
|
||||||
|
|||||||
@@ -77,17 +77,15 @@ func TestNotesOnRows(t *testing.T) {
|
|||||||
func TestWithNoteGivesWay(t *testing.T) {
|
func TestWithNoteGivesWay(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
st := styles.Default()
|
st := styles.Default()
|
||||||
if got := withNote(st, "db-password", "prod SQL admin", 30); len([]rune(stripANSI(got))) != 30 ||
|
if got := withNote(st, "db-password", "prod SQL admin", 30); len([]rune(ansi.Strip(got))) != 30 ||
|
||||||
!strings.HasSuffix(stripANSI(got), "prod SQL admin") {
|
!strings.HasSuffix(ansi.Strip(got), "prod SQL admin") {
|
||||||
t.Errorf("fits: %q", stripANSI(got))
|
t.Errorf("fits: %q", ansi.Strip(got))
|
||||||
}
|
}
|
||||||
if got := stripANSI(withNote(st, "db-password", "prod SQL admin", 20)); !strings.HasPrefix(got, "db-password") ||
|
if got := ansi.Strip(withNote(st, "db-password", "prod SQL admin", 20)); !strings.HasPrefix(got, "db-password") ||
|
||||||
!strings.HasSuffix(got, "…") {
|
!strings.HasSuffix(got, "…") {
|
||||||
t.Errorf("tight: %q", got)
|
t.Errorf("tight: %q", got)
|
||||||
}
|
}
|
||||||
if got := stripANSI(withNote(st, "a-very-long-secret-name", "note", 20)); got != "a-very-long-secret-name" {
|
if got := ansi.Strip(withNote(st, "a-very-long-secret-name", "note", 20)); got != "a-very-long-secret-name" {
|
||||||
t.Errorf("no room: %q", got)
|
t.Errorf("no room: %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func stripANSI(s string) string { return ansi.Strip(s) }
|
|
||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||||
|
"github.com/charmbracelet/x/ansi"
|
||||||
)
|
)
|
||||||
|
|
||||||
const sopsFile = `db:
|
const sopsFile = `db:
|
||||||
@@ -225,9 +226,9 @@ func TestSearchAllRepos(t *testing.T) {
|
|||||||
var got []string
|
var got []string
|
||||||
for _, o := range d.m.search.shown {
|
for _, o := range d.m.search.shown {
|
||||||
if o.kind != provider.KindRepo {
|
if o.kind != provider.KindRepo {
|
||||||
t.Errorf("not a repo: %s", ansiStrip(o.label))
|
t.Errorf("not a repo: %s", ansi.Strip(o.label))
|
||||||
}
|
}
|
||||||
got = append(got, ansiStrip(o.label))
|
got = append(got, ansi.Strip(o.label))
|
||||||
}
|
}
|
||||||
if len(got) != 2 {
|
if len(got) != 2 {
|
||||||
t.Errorf("want db.password in both repos: %v", got)
|
t.Errorf("want db.password in both repos: %v", got)
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
@@ -46,10 +45,6 @@ type crawlMsg struct {
|
|||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
func contextWithTimeout(d time.Duration) (context.Context, context.CancelFunc) {
|
|
||||||
return context.WithTimeout(context.Background(), d)
|
|
||||||
}
|
|
||||||
|
|
||||||
// searchEntries lists everything loaded: the sources (with how much of
|
// searchEntries lists everything loaded: the sources (with how much of
|
||||||
// each is loaded) and the vaults, scopes and secrets in them, each with
|
// each is loaded) and the vaults, scopes and secrets in them, each with
|
||||||
// its source's row key as group.
|
// its source's row key as group.
|
||||||
@@ -67,7 +62,7 @@ func (m *Model) searchEntries() (sources, items []option) {
|
|||||||
sort := strings.ToLower(strings.Join(path, "/")) + " " + account
|
sort := strings.ToLower(strings.Join(path, "/")) + " " + account
|
||||||
es = append(es, entry{depth, sort,
|
es = append(es, entry{depth, sort,
|
||||||
option{label: m.pathLabel(kind, path, account), note: n.Note, star: key,
|
option{label: m.pathLabel(kind, path, account), note: n.Note, star: key,
|
||||||
starAccount: providerOf(row), starred: n.StarredFor(providerOf(row)),
|
starAccount: cachefile.ProviderOf(row), starred: n.StarredFor(cachefile.ProviderOf(row)),
|
||||||
mark: mark, run: run, reload: reload, id: sort, group: row, kind: kind}})
|
mark: mark, run: run, reload: reload, id: sort, group: row, kind: kind}})
|
||||||
return &es[len(es)-1].opt
|
return &es[len(es)-1].opt
|
||||||
}
|
}
|
||||||
@@ -134,8 +129,6 @@ func (c coverage) complete() bool {
|
|||||||
return c.sourcesListed == c.sources && c.vaultsLoaded == c.vaults
|
return c.sourcesListed == c.sources && c.vaultsLoaded == c.vaults
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) searchCoverage() coverage { return m.coverageOf(nil) }
|
|
||||||
|
|
||||||
// providerFilter picks the providers a search or a load covers; nil is
|
// providerFilter picks the providers a search or a load covers; nil is
|
||||||
// all of them.
|
// all of them.
|
||||||
type providerFilter func(provider.Provider) bool
|
type providerFilter func(provider.Provider) bool
|
||||||
@@ -148,7 +141,7 @@ func ofProvider(id string) providerFilter {
|
|||||||
return func(p provider.Provider) bool { return p.ID() == id }
|
return func(p provider.Provider) bool { return p.ID() == id }
|
||||||
}
|
}
|
||||||
|
|
||||||
// coverageOf is searchCoverage for the providers in.
|
// coverageOf is the coverage of the providers in.
|
||||||
func (m *Model) coverageOf(in providerFilter) coverage {
|
func (m *Model) coverageOf(in providerFilter) coverage {
|
||||||
var c coverage
|
var c coverage
|
||||||
for _, p := range m.providers {
|
for _, p := range m.providers {
|
||||||
@@ -183,7 +176,7 @@ func (m *Model) coverageOf(in providerFilter) coverage {
|
|||||||
|
|
||||||
// searchLines says what the search covers.
|
// searchLines says what the search covers.
|
||||||
func (m *Model) searchLines() []string {
|
func (m *Model) searchLines() []string {
|
||||||
c := m.searchCoverage()
|
c := m.coverageOf(nil)
|
||||||
switch {
|
switch {
|
||||||
case m.crawl.running:
|
case m.crawl.running:
|
||||||
return []string{m.st.Notice.Render(fmt.Sprintf("Loading… %d lists left; results appear as they arrive.",
|
return []string{m.st.Notice.Render(fmt.Sprintf("Loading… %d lists left; results appear as they arrive.",
|
||||||
@@ -210,11 +203,8 @@ func plural(n int, word string) string {
|
|||||||
return fmt.Sprintf("%d %ss", n, word)
|
return fmt.Sprintf("%d %ss", n, word)
|
||||||
}
|
}
|
||||||
|
|
||||||
// startCrawl loads every unlisted subscription's vaults and every vault's
|
// startCrawlOf loads, for the providers in, every unlisted subscription's
|
||||||
// secrets not loaded yet.
|
// vaults and every vault's secrets not loaded yet.
|
||||||
func (m *Model) startCrawl() tea.Cmd { return m.startCrawlOf(nil) }
|
|
||||||
|
|
||||||
// startCrawlOf is startCrawl for the providers in.
|
|
||||||
func (m *Model) startCrawlOf(in providerFilter) tea.Cmd {
|
func (m *Model) startCrawlOf(in providerFilter) tea.Cmd {
|
||||||
for _, p := range m.providers {
|
for _, p := range m.providers {
|
||||||
if in != nil && !in(p) {
|
if in != nil && !in(p) {
|
||||||
@@ -301,7 +291,7 @@ func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
|
|||||||
src := *job.src
|
src := *job.src
|
||||||
timeout := m.timeout.of(src.Kind)
|
timeout := m.timeout.of(src.Kind)
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
ctx, cancel := contextWithTimeout(timeout)
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
items, err := p.Containers(ctx, src)
|
items, err := p.Containers(ctx, src)
|
||||||
for i := range items {
|
for i := range items {
|
||||||
@@ -314,7 +304,7 @@ func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
|
|||||||
p := m.providerFor(c.Source)
|
p := m.providerFor(c.Source)
|
||||||
timeout := m.timeout.of(c.Source.Kind)
|
timeout := m.timeout.of(c.Source.Kind)
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
ctx, cancel := contextWithTimeout(timeout)
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
items, err := p.Objects(ctx, c, provider.TypeSecret)
|
items, err := p.Objects(ctx, c, provider.TypeSecret)
|
||||||
for i := range items {
|
for i := range items {
|
||||||
@@ -396,9 +386,3 @@ func (m *Model) vaultMark(c provider.Container) string {
|
|||||||
}
|
}
|
||||||
return " "
|
return " "
|
||||||
}
|
}
|
||||||
|
|
||||||
// providerOf is the provider ID in a source row key.
|
|
||||||
func providerOf(row string) string {
|
|
||||||
p, _ := splitSourceRow(row)
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
|
||||||
"github.com/charmbracelet/x/ansi"
|
"github.com/charmbracelet/x/ansi"
|
||||||
)
|
)
|
||||||
@@ -115,19 +116,19 @@ func TestSearchSourceNarrowsAndLoads(t *testing.T) {
|
|||||||
t.Fatal("platform-dev not in sources")
|
t.Fatal("platform-dev not in sources")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !strings.Contains(ansiStrip(d.m.search.scopes[d.m.search.src].mark), "○") {
|
if !strings.Contains(ansi.Strip(d.m.search.scopes[d.m.search.src].mark), "○") {
|
||||||
t.Errorf("unloaded source mark %q", d.m.search.scopes[d.m.search.src].mark)
|
t.Errorf("unloaded source mark %q", d.m.search.scopes[d.m.search.src].mark)
|
||||||
}
|
}
|
||||||
if len(d.m.search.shown) != 0 {
|
if len(d.m.search.shown) != 0 {
|
||||||
t.Errorf("results not narrowed to platform-dev: %d", len(d.m.search.shown))
|
t.Errorf("results not narrowed to platform-dev: %d", len(d.m.search.shown))
|
||||||
}
|
}
|
||||||
d.send(ctrl('r'))
|
d.send(ctrl('r'))
|
||||||
if !strings.Contains(ansiStrip(d.m.search.scopes[d.m.search.src].mark), "●") {
|
if !strings.Contains(ansi.Strip(d.m.search.scopes[d.m.search.src].mark), "●") {
|
||||||
t.Errorf("loaded source mark %q", d.m.search.scopes[d.m.search.src].mark)
|
t.Errorf("loaded source mark %q", d.m.search.scopes[d.m.search.src].mark)
|
||||||
}
|
}
|
||||||
for _, o := range d.m.search.shown {
|
for _, o := range d.m.search.shown {
|
||||||
if !strings.Contains(ansiStrip(o.label), "platform-dev") {
|
if !strings.Contains(ansi.Strip(o.label), "platform-dev") {
|
||||||
t.Errorf("result from elsewhere: %q", ansiStrip(o.label))
|
t.Errorf("result from elsewhere: %q", ansi.Strip(o.label))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(d.m.search.shown) == 0 {
|
if len(d.m.search.shown) == 0 {
|
||||||
@@ -177,19 +178,17 @@ func TestStarFromSearch(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func ansiStrip(s string) string { return ansi.Strip(s) }
|
|
||||||
|
|
||||||
func TestSearchStartsWhereYouAre(t *testing.T) {
|
func TestSearchStartsWhereYouAre(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
d := newDriver(t)
|
d := newDriver(t)
|
||||||
d.keys("2", "j") // platform-prod / kv-shared-prod
|
d.keys("2", "j") // platform-prod / kv-shared-prod
|
||||||
d.openSearch()
|
d.openSearch()
|
||||||
v := d.m.search
|
v := d.m.search
|
||||||
scope := func() string { return ansiStrip(v.scopes[v.src].label) }
|
scope := func() string { return ansi.Strip(v.scopes[v.src].label) }
|
||||||
if scope() != "All" {
|
if scope() != "All" {
|
||||||
t.Errorf("opened on %q, want All", scope())
|
t.Errorf("opened on %q, want All", scope())
|
||||||
}
|
}
|
||||||
if v.res >= len(v.shown) || !strings.Contains(ansiStrip(v.shown[v.res].label), "kv-shared-prod") {
|
if v.res >= len(v.shown) || !strings.Contains(ansi.Strip(v.shown[v.res].label), "kv-shared-prod") {
|
||||||
t.Errorf("vault not under the cursor:\n%s", d.screen())
|
t.Errorf("vault not under the cursor:\n%s", d.screen())
|
||||||
}
|
}
|
||||||
d.send(ctrl('s')) // narrows to the source, and back
|
d.send(ctrl('s')) // narrows to the source, and back
|
||||||
@@ -233,13 +232,13 @@ func TestSearchNarrowsToAccount(t *testing.T) {
|
|||||||
t.Errorf("no results in the account:\n%s", d.screen())
|
t.Errorf("no results in the account:\n%s", d.screen())
|
||||||
}
|
}
|
||||||
for _, o := range v.shown {
|
for _, o := range v.shown {
|
||||||
if providerOf(o.group) != header.provider {
|
if cachefile.ProviderOf(o.group) != header.provider {
|
||||||
t.Errorf("result from another account: %q", ansiStrip(o.label))
|
t.Errorf("result from another account: %q", ansi.Strip(o.label))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The account's sources are under it, indented.
|
// The account's sources are under it, indented.
|
||||||
if next := v.scopes[v.src+1]; next.group == "" || providerOf(next.group) != header.provider || !strings.HasPrefix(ansiStrip(next.label), " ") {
|
if next := v.scopes[v.src+1]; next.group == "" || cachefile.ProviderOf(next.group) != header.provider || !strings.HasPrefix(ansi.Strip(next.label), " ") {
|
||||||
t.Errorf("after the account: %q", ansiStrip(next.label))
|
t.Errorf("after the account: %q", ansi.Strip(next.label))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,7 +326,7 @@ func TestSearchHighlightsMatches(t *testing.T) {
|
|||||||
t.Fatal("no results")
|
t.Fatal("no results")
|
||||||
}
|
}
|
||||||
o := d.m.search.shown[0]
|
o := d.m.search.shown[0]
|
||||||
plain := []rune(ansiStrip(o.label))
|
plain := []rune(ansi.Strip(o.label))
|
||||||
var got string
|
var got string
|
||||||
for _, h := range o.hits {
|
for _, h := range o.hits {
|
||||||
got += string(plain[h])
|
got += string(plain[h])
|
||||||
@@ -348,14 +347,14 @@ func TestHelpBarFollowsPopup(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
d := newDriver(t)
|
d := newDriver(t)
|
||||||
d.keys("F")
|
d.keys("F")
|
||||||
bar := lastLine(d.screen())
|
bar := lastLines(d.screen(), 1)
|
||||||
for _, want := range []string{"[ prev tab", "] next tab", "/ filter", "f star"} {
|
for _, want := range []string{"[ prev tab", "] next tab", "/ filter", "f star"} {
|
||||||
if !strings.Contains(bar, want) {
|
if !strings.Contains(bar, want) {
|
||||||
t.Errorf("browsing: help bar %q lacks %q", bar, want)
|
t.Errorf("browsing: help bar %q lacks %q", bar, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
d.keys("/")
|
d.keys("/")
|
||||||
if bar := lastLine(d.screen()); !strings.Contains(bar, "ctrl+f star") || !strings.Contains(bar, "esc stop typing") {
|
if bar := lastLines(d.screen(), 1); !strings.Contains(bar, "ctrl+f star") || !strings.Contains(bar, "esc stop typing") {
|
||||||
t.Errorf("typing: help bar %q", bar)
|
t.Errorf("typing: help bar %q", bar)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -375,8 +374,8 @@ func TestHelpFromPopups(t *testing.T) {
|
|||||||
if o.header {
|
if o.header {
|
||||||
sections = append(sections, o.label)
|
sections = append(sections, o.label)
|
||||||
}
|
}
|
||||||
if strings.Contains(ansiStrip(o.label), "copy value") {
|
if strings.Contains(ansi.Strip(o.label), "copy value") {
|
||||||
t.Errorf("pane key listed in Go to's help: %q", ansiStrip(o.label))
|
t.Errorf("pane key listed in Go to's help: %q", ansi.Strip(o.label))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if strings.Join(sections, ",") != "Go to,While typing, after /" {
|
if strings.Join(sections, ",") != "Go to,While typing, after /" {
|
||||||
@@ -428,7 +427,7 @@ func TestGoToHelpBarEndsWithHelp(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
d := newDriver(t) // 100 columns: Go to's keys don't all fit
|
d := newDriver(t) // 100 columns: Go to's keys don't all fit
|
||||||
d.keys("ctrl+s", "esc")
|
d.keys("ctrl+s", "esc")
|
||||||
bar := strings.TrimSpace(lastLine(d.screen()))
|
bar := strings.TrimSpace(lastLines(d.screen(), 1))
|
||||||
if !strings.HasSuffix(bar, "? help") || !strings.Contains(bar, "…") {
|
if !strings.HasSuffix(bar, "? help") || !strings.Contains(bar, "…") {
|
||||||
t.Errorf("help bar %q", bar)
|
t.Errorf("help bar %q", bar)
|
||||||
}
|
}
|
||||||
@@ -443,11 +442,11 @@ func TestLoadWithEmptyResults(t *testing.T) {
|
|||||||
for d.m.search.src > 0 {
|
for d.m.search.src > 0 {
|
||||||
d.send(tea.KeyPressMsg{Code: tea.KeyUp})
|
d.send(tea.KeyPressMsg{Code: tea.KeyUp})
|
||||||
}
|
}
|
||||||
for i := 0; !strings.Contains(ansiStrip(d.m.search.scope().label), "platform-dev"); i++ {
|
for i := 0; !strings.Contains(ansi.Strip(d.m.search.scope().label), "platform-dev"); i++ {
|
||||||
if i > len(d.m.search.scopes) {
|
if i > len(d.m.search.scopes) {
|
||||||
var labels []string
|
var labels []string
|
||||||
for _, o := range d.m.search.scopes {
|
for _, o := range d.m.search.scopes {
|
||||||
labels = append(labels, ansiStrip(o.label))
|
labels = append(labels, ansi.Strip(o.label))
|
||||||
}
|
}
|
||||||
t.Fatalf("no platform-dev scope: %q (at %d, results %v)", labels, d.m.search.src, d.m.search.results)
|
t.Fatalf("no platform-dev scope: %q (at %d, results %v)", labels, d.m.search.src, d.m.search.results)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ func (m *Model) fillSearch(keep bool) {
|
|||||||
id: accountScope + p.ID(), kind: k, mark: m.coverageMark(ofProvider(p.ID())),
|
id: accountScope + p.ID(), kind: k, mark: m.coverageMark(ofProvider(p.ID())),
|
||||||
})
|
})
|
||||||
for _, o := range sources {
|
for _, o := range sources {
|
||||||
if providerOf(o.group) == p.ID() {
|
if cachefile.ProviderOf(o.group) == p.ID() {
|
||||||
o.label = " " + o.label
|
o.label = " " + o.label
|
||||||
scopes = append(scopes, o)
|
scopes = append(scopes, o)
|
||||||
}
|
}
|
||||||
@@ -267,7 +267,7 @@ func (v *searchView) match() []option {
|
|||||||
default:
|
default:
|
||||||
account, ofAccount := v.scopeAccount()
|
account, ofAccount := v.scopeAccount()
|
||||||
for _, o := range v.items {
|
for _, o := range v.items {
|
||||||
if sc.id == allSources || ofAccount && providerOf(o.group) == account || sc.group != "" && o.group == sc.group {
|
if sc.id == allSources || ofAccount && cachefile.ProviderOf(o.group) == account || sc.group != "" && o.group == sc.group {
|
||||||
cand = append(cand, o)
|
cand = append(cand, o)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -523,7 +523,7 @@ func (m *Model) searchLoads() tea.Cmd {
|
|||||||
return m.startCrawlOf(v.scopeFilter())
|
return m.startCrawlOf(v.scopeFilter())
|
||||||
}
|
}
|
||||||
if !v.onSource() {
|
if !v.onSource() {
|
||||||
return m.startCrawl()
|
return m.startCrawlOf(nil)
|
||||||
}
|
}
|
||||||
providerID, sourceID := splitSourceRow(sc.group)
|
providerID, sourceID := splitSourceRow(sc.group)
|
||||||
for _, src := range m.sourceResults[providerID].sources {
|
for _, src := range m.sourceResults[providerID].sources {
|
||||||
|
|||||||
@@ -304,7 +304,7 @@ func (m *Model) handleShareOpened(msg shareOpenedMsg) tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// openFailed says why a share link couldn't be decrypted, in a popup
|
// openFailed says why a share link couldn't be decrypted, in a popup
|
||||||
// where its offer was: its first line (wrapped), or with whole all of it
|
// where its offer was: its first line (wrapped), or, with whole, all of it
|
||||||
// (Key Vault's refusals say what to check on later lines). From there,
|
// (Key Vault's refusals say what to check on later lines). From there,
|
||||||
// the key's secret can be gone to: its version, which may be disabled.
|
// the key's secret can be gone to: its version, which may be disabled.
|
||||||
func (m *Model) openFailed(s share.Sealed, err error, whole bool) {
|
func (m *Model) openFailed(s share.Sealed, err error, whole bool) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -83,7 +84,7 @@ func (m Model) statusFields() []panes.Field {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A release's rows open its release page.
|
// A release's rows open its release page.
|
||||||
version := panes.Field{Label: "version", Value: cmpOr(m.versionLabel(), "dev")}
|
version := panes.Field{Label: "version", Value: cmp.Or(m.versionLabel(), "dev")}
|
||||||
if strings.HasPrefix(version.Value, "v") {
|
if strings.HasPrefix(version.Value, "v") {
|
||||||
version.URL = update.DefaultRepo + "/releases/tag/" + version.Value
|
version.URL = update.DefaultRepo + "/releases/tag/" + version.Value
|
||||||
}
|
}
|
||||||
@@ -159,7 +160,7 @@ func (m Model) accountStatus(p provider.Provider) string {
|
|||||||
return r.err.Error()
|
return r.err.Error()
|
||||||
}
|
}
|
||||||
noun := map[provider.Kind]string{provider.KindAzure: "subscription", provider.KindDatabricks: "workspace", provider.KindRepo: "repo"}[p.Kind()]
|
noun := map[provider.Kind]string{provider.KindAzure: "subscription", provider.KindDatabricks: "workspace", provider.KindRepo: "repo"}[p.Kind()]
|
||||||
return plural(len(r.sources), cmpOr(noun, "source"))
|
return plural(len(r.sources), cmp.Or(noun, "source"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ago is a duration as people say it: "just now", "5m ago", "2h ago".
|
// ago is a duration as people say it: "just now", "5m ago", "2h ago".
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||||
|
"github.com/charmbracelet/x/ansi"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRecentScope(t *testing.T) {
|
func TestRecentScope(t *testing.T) {
|
||||||
@@ -21,7 +22,7 @@ func TestRecentScope(t *testing.T) {
|
|||||||
d.keys("R")
|
d.keys("R")
|
||||||
var recent []string
|
var recent []string
|
||||||
for _, o := range d.m.search.shown {
|
for _, o := range d.m.search.shown {
|
||||||
recent = append(recent, ansiStrip(o.label))
|
recent = append(recent, ansi.Strip(o.label))
|
||||||
}
|
}
|
||||||
if len(recent) < 2 || !strings.Contains(recent[0], "legacy-token") || !strings.Contains(recent[1], "api-key") {
|
if len(recent) < 2 || !strings.Contains(recent[0], "legacy-token") || !strings.Contains(recent[1], "api-key") {
|
||||||
t.Fatalf("want newest first: %q", recent)
|
t.Fatalf("want newest first: %q", recent)
|
||||||
@@ -31,7 +32,7 @@ func TestRecentScope(t *testing.T) {
|
|||||||
t.Errorf("jumped to %q", s)
|
t.Errorf("jumped to %q", s)
|
||||||
}
|
}
|
||||||
d.keys("F")
|
d.keys("F")
|
||||||
if len(d.m.search.shown) != 1 || !strings.Contains(ansiStrip(d.m.search.shown[0].label), "kv-shared-prod") {
|
if len(d.m.search.shown) != 1 || !strings.Contains(ansi.Strip(d.m.search.shown[0].label), "kv-shared-prod") {
|
||||||
t.Errorf("favourites: %d entries\n%s", len(d.m.search.shown), d.screen())
|
t.Errorf("favourites: %d entries\n%s", len(d.m.search.shown), d.screen())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ package keys
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
"slices"
|
"slices"
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"charm.land/bubbles/v2/key"
|
"charm.land/bubbles/v2/key"
|
||||||
@@ -266,11 +266,11 @@ func (m *Map) Apply(cfg map[string]map[string][]string) error {
|
|||||||
byName[a.Context+"."+a.Name] = a
|
byName[a.Context+"."+a.Name] = a
|
||||||
contexts[a.Context] = true
|
contexts[a.Context] = true
|
||||||
}
|
}
|
||||||
for _, ctx := range sortedKeys(cfg) {
|
for _, ctx := range slices.Sorted(maps.Keys(cfg)) {
|
||||||
if !contexts[ctx] {
|
if !contexts[ctx] {
|
||||||
return fmt.Errorf("keybinding: unknown context %q (known: %s)", ctx, strings.Join(sortedKeys(contexts), ", "))
|
return fmt.Errorf("keybinding: unknown context %q (known: %s)", ctx, strings.Join(slices.Sorted(maps.Keys(contexts)), ", "))
|
||||||
}
|
}
|
||||||
for _, name := range sortedKeys(cfg[ctx]) {
|
for _, name := range slices.Sorted(maps.Keys(cfg[ctx])) {
|
||||||
a, ok := byName[ctx+"."+name]
|
a, ok := byName[ctx+"."+name]
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("keybinding.%s: unknown action %q (known: %s)", ctx, name, strings.Join(m.names(ctx), ", "))
|
return fmt.Errorf("keybinding.%s: unknown action %q (known: %s)", ctx, name, strings.Join(m.names(ctx), ", "))
|
||||||
@@ -289,16 +289,7 @@ func (m *Map) names(ctx string) []string {
|
|||||||
out = append(out, a.Name)
|
out = append(out, a.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sort.Strings(out)
|
slices.Sort(out)
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func sortedKeys[V any](set map[string]V) []string {
|
|
||||||
out := make([]string, 0, len(set))
|
|
||||||
for k := range set {
|
|
||||||
out = append(out, k)
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,16 +60,6 @@ func (d *Detail) ShowSecret(s provider.Object) {
|
|||||||
d.container = &s.Container
|
d.container = &s.Container
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh shows s's newer metadata if s is the secret shown, keeping
|
|
||||||
// its account and any value shown or loading.
|
|
||||||
func (d *Detail) Refresh(s provider.Object) {
|
|
||||||
if d.secret == nil || d.secret.ID != s.ID {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.Container = d.secret.Container
|
|
||||||
d.secret = &s
|
|
||||||
}
|
|
||||||
|
|
||||||
// ShowContainer shows a container summary when no secret is selected.
|
// ShowContainer shows a container summary when no secret is selected.
|
||||||
func (d *Detail) ShowContainer(c provider.Container) {
|
func (d *Detail) ShowContainer(c provider.Container) {
|
||||||
d.Clear()
|
d.Clear()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -301,15 +301,6 @@ func matchRanges(plain string, hits []int, style lipgloss.Style) []lipgloss.Rang
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// RuneIndexes converts byte offsets in s to rune offsets.
|
|
||||||
func RuneIndexes(s string, bytes []int) []int {
|
|
||||||
out := make([]int, 0, len(bytes))
|
|
||||||
for _, b := range bytes {
|
|
||||||
out = append(out, utf8.RuneCountInString(s[:b]))
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// highlight returns the rune indexes to highlight in a row's plain text:
|
// highlight returns the rune indexes to highlight in a row's plain text:
|
||||||
// the filter hits, shifted to where the Filter text appears in the row.
|
// the filter hits, shifted to where the Filter text appears in the row.
|
||||||
// It returns nil if the row doesn't contain the Filter text verbatim.
|
// It returns nil if the row doesn't contain the Filter text verbatim.
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package styles
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"image/color"
|
"image/color"
|
||||||
|
"maps"
|
||||||
"slices"
|
"slices"
|
||||||
"sort"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -51,12 +51,7 @@ var Roles = map[string]string{
|
|||||||
func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
|
func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
|
||||||
st := Default()
|
st := Default()
|
||||||
var fg color.Color
|
var fg color.Color
|
||||||
roles := make([]string, 0, len(theme))
|
for _, role := range slices.Sorted(maps.Keys(theme)) {
|
||||||
for r := range theme {
|
|
||||||
roles = append(roles, r)
|
|
||||||
}
|
|
||||||
sort.Strings(roles)
|
|
||||||
for _, role := range roles {
|
|
||||||
if _, ok := Roles[role]; !ok {
|
if _, ok := Roles[role]; !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -131,7 +126,6 @@ type value struct {
|
|||||||
bold, italic, underline, reverse, faint bool
|
bold, italic, underline, reverse, faint bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// fg styles s with the value as foreground, bg as background.
|
|
||||||
func (v value) fg(s lipgloss.Style) lipgloss.Style {
|
func (v value) fg(s lipgloss.Style) lipgloss.Style {
|
||||||
if v.color != nil {
|
if v.color != nil {
|
||||||
s = s.Foreground(v.color)
|
s = s.Foreground(v.color)
|
||||||
|
|||||||
@@ -89,14 +89,9 @@ func (r Release) Notes(current string) string {
|
|||||||
return PlainLinks(strings.TrimSpace(notes))
|
return PlainLinks(strings.TrimSpace(notes))
|
||||||
}
|
}
|
||||||
|
|
||||||
// recentReleases is how many releases Latest reads the changelogs of.
|
|
||||||
const recentReleases = 10
|
|
||||||
|
|
||||||
// PlainLinks shows Markdown links as their text, for a terminal.
|
// PlainLinks shows Markdown links as their text, for a terminal.
|
||||||
func PlainLinks(s string) string { return mdLink.ReplaceAllString(s, "$1") }
|
func PlainLinks(s string) string { return mdLink.ReplaceAllString(s, "$1") }
|
||||||
|
|
||||||
// mdLink is a Markdown link, [text](url): the notes link PRs and commits,
|
|
||||||
// and a terminal shows them as their text.
|
|
||||||
var mdLink = regexp.MustCompile(`\[([^\]]+)\]\((https?://[^)\s]+)\)`)
|
var mdLink = regexp.MustCompile(`\[([^\]]+)\]\((https?://[^)\s]+)\)`)
|
||||||
|
|
||||||
// Client talks to the release servers.
|
// Client talks to the release servers.
|
||||||
@@ -148,6 +143,9 @@ func (c *Client) Latest(ctx context.Context) (Release, error) {
|
|||||||
return Release{}, errors.Join(errs...)
|
return Release{}, errors.Join(errs...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// recentReleases is how many releases Latest reads the changelogs of.
|
||||||
|
const recentReleases = 10
|
||||||
|
|
||||||
func (c *Client) latest(ctx context.Context, repo string) (Release, error) {
|
func (c *Client) latest(ctx context.Context, repo string) (Release, error) {
|
||||||
api, err := API(repo)
|
api, err := API(repo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in new issue
Block a user