25 Commits
Author SHA1 Message Date
Karl-Einar Bergstrøm 869b201695 Merge branch 'main' into share-files
CI / Checks (pull_request) Successful in 39s
PR title / Conventional title (pull_request) Successful in 1s
2026-10-13 14:54:31 +02:00
kebandClaude Opus 5.5 db8ce90ee7 fix: the details pane follows its secret's list when it reloads
CI / Checks (pull_request) Successful in 39s
PR title / Conventional title (pull_request) Successful in 1s
A cached list is shown at once and reloaded in the background; the
selected secret's details stayed as first shown, old web page
included, until another secret was selected. When the reloaded list
has it changed, the details pane takes the new listing (a value shown
stays shown) and describes it afresh: its cached details came from
the old one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 14:50:59 +02:00
kebandClaude Opus 5.5 40b90df68c feat: a key that no longer decrypts a share link says why
CI / Checks (pull_request) Successful in 39s
PR title / Conventional title (pull_request) Successful in 2s
By where it lives: a Databricks secret or SOPS value has changed
since the link was made, an Azure secret's current version isn't the
link's (unpinned), and a pinned version can't have changed, so the
link may be damaged. The popup wraps the reason instead of cutting
it; r is for what Key Vault adds to check.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 14:30:24 +02:00
kebandClaude Opus 5.5 8757284b0c fix: o on an Azure secret opens its page in the portal
PR title / Conventional title (pull_request) Successful in 2s
CI / Checks (pull_request) Successful in 38s
Not the vault's list of secrets: the secret's own page (asset/
Microsoft_Azure_KeyVault/Secret/<id>), and in the versions view, the
version's (<id>/<version>), keys' and certificates' versions too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 14:27:06 +02:00
kebandClaude Opus 5.5 7d6a095e42 feat: opening a link to a secret ends on its details
CI / Checks (pull_request) Successful in 35s
PR title / Conventional title (pull_request) Successful in 1s
As enter on it would, instead of on the vault's list with the secret
selected: ctrl+o, a paste, a clicked link and the share failure
popup's g. Going back to where lazysecrets was left, and Go to, stay
as they were.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 14:06:16 +02:00
kebandClaude Opus 5.5 2a3379b1c1 feat: a share link that can't be decrypted says why in a popup
CI / Checks (pull_request) Successful in 39s
PR title / Conventional title (pull_request) Successful in 2s
Where its offer was, instead of the footer: the first line of the
reason, r for the whole message (Key Vault's refusals list what to
check), g to go to the key's secret at the link's version, and x to
dismiss.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 13:59:43 +02:00
kebandClaude Opus 5.5 61de5e616d refactor: tidy up sharing after the round of tweaks
CI / Checks (pull_request) Successful in 41s
PR title / Conventional title (pull_request) Successful in 2s
- Go to's picker settings are one picker struct, set while picking.
- Opening a share link plans the save, then asks or saves; no more
  offerWrite doing either by a flag.
- Closing the picker while it encrypts cancels the share, and a
  second pick meanwhile is ignored.
- Favourites and Recent leave out plain env values as keys too.
- share.key in the config is checked with share.ParseKey, so
  {{secrets/…}} is refused there already.
- A key that can't be read reads the same opening as sharing
  ("can't use …"); KeyError moved to share.go, maxFiles is
  maxUnpacked (bytes), and a stray comment is gone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 13:42:47 +02:00
kebandClaude Opus 5.5 25dbb38842 feat: a chosen folder saves at once, and the prompt stays until it has
CI / Checks (pull_request) Successful in 40s
PR title / Conventional title (pull_request) Successful in 2s
Choosing where to save a share link's files saves them right away,
unless one would replace a file there, which still asks. The folder
prompt stays open until they're written: a folder that isn't there,
or a save that fails, is said under it in red, to fix the path. Its
footer says "enter save", and a file git doesn't ignore is named in
the status line after saving.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 13:35:01 +02:00
kebandClaude Opus 5.5 0757898d61 perf: share links use scrypt 2^15, about 40 ms each way
PR title / Conventional title (pull_request) Successful in 2s
CI / Checks (pull_request) Successful in 39s
Keys keeping to share.keyRules are random and long, so age's 2^18
(a third of a second to share and to open) bought little.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 13:31:34 +02:00
kebandClaude Opus 5.5 80332680e5 fix: a refused share key says what failed, not how to make one
CI / Checks (pull_request) Successful in 42s
PR title / Conventional title (pull_request) Successful in 2s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 13:29:04 +02:00
kebandClaude Opus 5.5 027e748c92 feat: a key that can't be used is explained in the picker
CI / Checks (pull_request) Successful in 40s
PR title / Conventional title (pull_request) Successful in 1s
The key picker stays open until the link is made, saying "encrypting
with …" meanwhile. A key that can't be read, or doesn't keep to
share.keyRules, is shown in it in red with which rule it misses
("its value has 28 characters; share.keyRules needs 32
(minLength)"), to pick another.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 13:24:21 +02:00
kebandClaude Opus 5.5 166790d825 fix: a demo repo's SOPS value can be the share key
CI / Checks (pull_request) Successful in 41s
PR title / Conventional title (pull_request) Successful in 2s
The demo provider finds repos by their remote, as the repo provider
does, so a key in a demo repo's SOPS file can be read. Opening a
share link uses the repo's clone only when it's on disk (the demo's
aren't), else asks where to save. The demo GitHub client secret is 40
characters, like real ones, so it passes share.keyRules.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 13:17:04 +02:00
kebandClaude Opus 5.5 777c8cb532 feat: share env files in --demo
CI / Checks (pull_request) Successful in 42s
PR title / Conventional title (pull_request) Successful in 2s
Providers whose containers are files give their contents through
provider.Contents, which sharing reads instead of the disk. The demo
repos build their env files from their keys, and the second demo
account has a key to share with (kv-shared-prod / env-share), so S → f
and opening the link work in --demo; the demo repos aren't on disk,
so opening asks where to save.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 13:11:59 +02:00
kebandClaude Opus 5.5 86fb79a915 fix: the key picker leaves share.key to the docs
PR title / Conventional title (pull_request) Successful in 2s
CI / Checks (pull_request) Successful in 39s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 12:48:05 +02:00
kebandClaude Opus 5.5 8d65b60e69 feat: the key picker has its own border in the share popup
CI / Checks (pull_request) Successful in 40s
PR title / Conventional title (pull_request) Successful in 2s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 12:42:33 +02:00
kebandClaude Opus 5.5 176600be54 fix: opening a share link reads right for one file or several
CI / Checks (pull_request) Successful in 37s
PR title / Conventional title (pull_request) Successful in 2s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 12:40:18 +02:00
kebandClaude Opus 5.5 5c97a8e249 feat: picking the key shares, with no second popup
CI / Checks (pull_request) Successful in 40s
PR title / Conventional title (pull_request) Successful in 2s
The picker already shows what's shared. The confirmation stays for a
key from share.key, which wasn't picked. The share format's version
is documented as kept readable.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 12:36:06 +02:00
kebandClaude Opus 5.5 8cd0003f8c feat: the key picker's tabs go under its heading, with help text
CI / Checks (pull_request) Successful in 39s
PR title / Conventional title (pull_request) Successful in 2s
The share popup is titled for what's shared; above the search, "Pick
a secret to encrypt with" says the recipient needs read access to it,
with Go to's tabs under it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 12:24:10 +02:00
kebandClaude Opus 5.5 cd265a632e feat: share popups draw repos and files like everywhere else
CI / Checks (pull_request) Successful in 38s
PR title / Conventional title (pull_request) Successful in 2s
repoLabel draws a repo, or a file in it, with pathLabel: the repo icon
and colour. The share popups use it for what's shared, where it's
from and where it's saved, and are titled for what's shared
("Share .env", "Share 2 env files"), the key picker too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 12:22:23 +02:00
kebandClaude Opus 5.5 0bfa2a3141 feat: share.keyRules, how long and varied a share key must be
CI / Checks (pull_request) Successful in 39s
PR title / Conventional title (pull_request) Successful in 2s
Sharing refuses a key shorter than minLength characters (default 32,
never under 16) or of fewer than minClasses kinds of lowercase,
uppercase, digits and symbols (default 2, so random hex passes),
saying so without showing it. Opening doesn't check.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 12:16:12 +02:00
kebandClaude Opus 5.5 b46991c7c2 feat: the key picker says what sharing does, and skips plain env values
CI / Checks (pull_request) Successful in 37s
PR title / Conventional title (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 12:13:16 +02:00
kebandClaude Opus 5.5 68fc76d938 docs: items can be shared as links, not have them
CI / Checks (pull_request) Successful in 35s
PR title / Conventional title (pull_request) Successful in 2s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 12:10:17 +02:00
kebandClaude Opus 5.5 bc691d786a feat: pick the share key in Go to, or by reference
CI / Checks (pull_request) Successful in 38s
PR title / Conventional title (pull_request) Successful in 1s
Sharing without share.key opens Go to as a picker of secrets instead
of asking for a link: it lists only secrets, enter picks one, and a
pasted reference picks it too. The key, there and in share.key or
--key, can be a secret's lazysecrets:// link, Key Vault URL or
@Microsoft.KeyVault(...) reference.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 12:09:08 +02:00
kebandClaude Opus 5.5 4d57c19772 fix: staticcheck and go mod tidy
CI / Checks (pull_request) Successful in 26s
PR title / Conventional title (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 12:00:33 +02:00
kebandClaude Opus 5.5 4b2f685921 feat: share env files as encrypted links
CI / Checks (pull_request) Failing after 10s
PR title / Conventional title (pull_request) Successful in 0s
S → f on a repo, an env file or a key in one copies a
lazysecrets://share link holding the env files, encrypted (age) with
a secret's value: whoever can read that secret can open it. The key is
share.key in the repo's .lazysecrets.yaml, else in the config, else
picked when sharing; on Azure the link pins its version. Opening one
says what it holds, decrypts when asked, and saves the files in the
clone of the repo (or a folder chosen), replacing existing ones only
when asked. lazysecrets share prints a link from the command line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 11:59:39 +02:00
43 changed files with 278 additions and 695 deletions

No files matched your search

-2
View File
@@ -1,2 +0,0 @@
<1+keb@noreply.git.kebcloud.no> <1+keb@noreply.localhost>
<3+renovate-bot@noreply.git.kebcloud.no> <3+renovate-bot@noreply.localhost>
-20
View File
@@ -2,26 +2,6 @@
Every release of lazysecrets, newest first.
## v0.1.2 – 2026-10-13
### Added
- Look for repos in the usual folders, not just ~/git ([#57](https://git.kebcloud.no/keb/lazysecrets/pulls/57)) ([47ef862](https://git.kebcloud.no/keb/lazysecrets/commit/47ef86293d757f965069b1ea100a4d53d5a9b876))
- Config grouped by area, with per-provider timeouts and an update interval ([#60](https://git.kebcloud.no/keb/lazysecrets/pulls/60)) ([10d1fc6](https://git.kebcloud.no/keb/lazysecrets/commit/10d1fc63c75fbb91088119a2e81884e44c36c4a3))
- The update changelog covers every version you missed ([#61](https://git.kebcloud.no/keb/lazysecrets/pulls/61)) ([75bab25](https://git.kebcloud.no/keb/lazysecrets/commit/75bab2528677086219817ff9bc08a36755c96dcd))
### Fixed
- Help and errors name sharing.key and sharing.keyRules, not share.* ([#64](https://git.kebcloud.no/keb/lazysecrets/pulls/64)) ([6b3edd4](https://git.kebcloud.no/keb/lazysecrets/commit/6b3edd45d83b9b2af06a40f1f7e67f35c6861a69))
- Unambiguous repo key names, clearer config and key names, and a newer lazysecrets's files no longer stop an older one ([#66](https://git.kebcloud.no/keb/lazysecrets/pulls/66)) ([a81fcaa](https://git.kebcloud.no/keb/lazysecrets/commit/a81fcaa5a6cef3ce0d07d6195f3e7bbb98808253))
## v0.1.1 – 2026-10-13
### Added
- `e` on the status pane edits the config ([#55](https://git.kebcloud.no/keb/lazysecrets/pulls/55)) ([2044341](https://git.kebcloud.no/keb/lazysecrets/commit/2044341b20d795fa7cadcabb9514631990993e0a))
- Share env files as encrypted links ([#54](https://git.kebcloud.no/keb/lazysecrets/pulls/54)) ([4377eb9](https://git.kebcloud.no/keb/lazysecrets/commit/4377eb98bf0dd523d5c4371bf84668412053290e))
## v0.1.0 – 2026-10-12
Hello world 👋
+6 -8
View File
@@ -34,20 +34,18 @@ A terminal UI for finding, reading and sharing secrets. Inspired by the fantasti
## Install
On macOS or Linux (arm64 or x86_64):
```sh
curl -fsSL https://raw.githubusercontent.com/karleinarb/lazysecrets/main/install.sh | sh
```
Or
```sh
curl -fsSL https://git.kebcloud.no/keb/lazysecrets/raw/branch/main/install.sh | sh
```
Both approaches will install the latest release from [GitHub](https://github.com/karleinarb/lazysecrets/releases) with fallback to
It installs the latest release, from GitHub or else
[git.kebcloud.no](https://git.kebcloud.no/keb/lazysecrets/releases), into
`~/.local/bin` after checking it against the release's checksums.
`sh -s -- v0.2.0` installs a given version, `LAZYSECRETS_INSTALL_DIR`
somewhere else.
lazysecrets looks for a newer release once a day: the footer then shows
`↑ v0.2.0`, and `U` shows its changelog and updates in place (or run
`lazysecrets update`).
@@ -92,17 +90,17 @@ use most:
secret
- [Config](docs/config.md): every option, themes, layered configs, the
state file
- [Development](docs/development.md): building, tests, how changes land and
- [Development](docs/development.md): building, tests, pull requests and
releasing
## Privacy
lazysecrets never stores, caches or sends your secret values. It keeps the
names and metadata of what you've loaded in a cache file readable only by
you, to show it at once next time (`storage.cache: false` turns it off). It talks
you, to show it at once next time (`cache: false` turns it off). It talks
only to the services you use it with, and collects no usage data. The one
call of its own is a daily check for a new release, on GitHub or else
git.kebcloud.no, which `updates.autoCheck: false` turns off.
git.kebcloud.no, which `updateCheck: false` turns off.
## Repositories
-11
View File
@@ -1,11 +0,0 @@
# Security
Please report a vulnerability privately, not in a public issue:
- on GitHub, with **Report a vulnerability** under the
[Security tab](https://github.com/karleinarb/lazysecrets/security), or
- by email to karl-einarb@outlook.com.
Say what's affected, how to reproduce it and the version
(`lazysecrets --version`). Fixes go into the next release, and only the
latest release gets them.
-2
View File
@@ -51,8 +51,6 @@ commit_preprocessors = [
{ pattern = "^Merge pull request '(.+)' \\((#[0-9]+)\\) from .*", replace = "${1} (${2})" },
# #11 was squash-merged with #10 (the status pane) in it: name both.
{ pattern = "^feat: link each changelog entry to its PR and commit \\(#11\\)", replace = "feat: a status pane (0) with version, updates, accounts, files and links; changelog entries link to their PR and commit (#11)" },
# #55's title starts with a key, which capitalised reads as another.
{ pattern = "^feat: e on the status pane edits the config", replace = "feat: `e` on the status pane edits the config" },
# Titles may be written "[feat] x", "[feat]: x" or "feat:x" (see
# pr-title.yml); all become "feat: x".
{ pattern = "^\\[(feat|fix|perf|refactor|docs|test|ci|chore|build|style|revert)(\\([^)]*\\))?(!?)\\](!?):?\\s*", replace = "${1}${2}${3}${4}: " },
+27 -67
View File
@@ -3,9 +3,7 @@
package main
import (
"cmp"
"context"
"errors"
"flag"
"fmt"
"io"
@@ -108,7 +106,7 @@ func run() error {
showVersion := flag.Bool("version", false, "print the version and exit")
dir := flag.String("dir", "", "start in this directory: its git repo's SOPS files, the ones under it first (default: the current directory)")
demo := flag.Bool("demo", false, "browse built-in sample data instead of real backends")
timeout := flag.Duration("timeout", 30*time.Second, "timeout for each backend request, every provider's (overrides the config)")
timeout := flag.Duration("timeout", 30*time.Second, "timeout for each backend request")
icons := flag.Bool("icons", true,
"show Nerd Font icons (needs a Nerd Font v3+); disable with --icons=false or LAZYSECRETS_ICONS=0")
clipClear := flag.Duration("clipboard-clear", 30*time.Second, "clear copied values from the clipboard after this long (0 disables)")
@@ -123,7 +121,7 @@ func run() error {
for scripts: VAR=$(lazysecrets get <ref>)
lazysecrets [flags] share [--key <secret>] <env file>...
print a link to env files of a repo, encrypted
with a secret's value (default: sharing.key)
with a secret's value (default: share.key)
lazysecrets [flags] keybindings print every action and its keys (with your
config applied), as config to copy and edit
lazysecrets handler install [--terminal NAME]
@@ -195,11 +193,6 @@ Flags:
if err != nil {
return err
}
// Warnings show when the window opens.
var warnings []string
if w := styles.Unknown(cfg.Theme); w != "" {
warnings = append(warnings, w)
}
keymap := keys.Default()
if err := keymap.Apply(cfg.Keybinding); err != nil {
return err
@@ -214,52 +207,30 @@ Flags:
// Precedence: flag > environment > config file > default.
set := map[string]bool{}
flag.Visit(func(f *flag.Flag) { set[f.Name] = true })
if *timeout <= 0 {
return fmt.Errorf("--timeout: %s is not positive", *timeout)
if !set["timeout"] && cfg.Timeout > 0 {
*timeout = cfg.Timeout
}
// Each provider's own timeout, unless --timeout sets them all.
timeouts := map[provider.Kind]time.Duration{}
if !set["timeout"] {
if cfg.Providers.Timeout > 0 {
*timeout = cfg.Providers.Timeout
}
for k, d := range map[provider.Kind]time.Duration{
provider.KindAzure: cfg.Providers.Azure.Timeout,
provider.KindDatabricks: cfg.Providers.Databricks.Timeout,
provider.KindRepo: cfg.Providers.Repos.Timeout,
} {
if d > 0 {
timeouts[k] = d
}
}
}
// timeoutFor is how long a request to a provider of kind k may take.
timeoutFor := func(k provider.Kind) time.Duration { return cmp.Or(timeouts[k], *timeout) }
if !set["clipboard-clear"] && cfg.Values.ClipboardClear != nil {
*clipClear = *cfg.Values.ClipboardClear
if !set["clipboard-clear"] && cfg.ClipboardClear != nil {
*clipClear = *cfg.ClipboardClear
}
expiryDays := 30
if cfg.UI.ExpiryWarningDays != nil {
expiryDays = *cfg.UI.ExpiryWarningDays
if cfg.ExpiryWarningDays != nil {
expiryDays = *cfg.ExpiryWarningDays
}
revealTimeout := 60 * time.Second
if cfg.Values.RevealTimeout != nil {
revealTimeout = *cfg.Values.RevealTimeout
if cfg.RevealTimeout != nil {
revealTimeout = *cfg.RevealTimeout
}
loadDelay := 150 * time.Millisecond
if cfg.UI.LoadDelay != nil {
loadDelay = *cfg.UI.LoadDelay
}
var updateEvery time.Duration // 0: the app's default, a day
if cfg.Updates.AutoCheckInterval != nil {
updateEvery = *cfg.Updates.AutoCheckInterval
if cfg.LoadDelay != nil {
loadDelay = *cfg.LoadDelay
}
if !set["icons"] {
switch {
case os.Getenv("LAZYSECRETS_ICONS") != "":
*icons = os.Getenv("LAZYSECRETS_ICONS") != "0"
case cfg.UI.Icons != nil:
*icons = *cfg.UI.Icons
case cfg.Icons != nil:
*icons = *cfg.Icons
}
}
@@ -274,9 +245,7 @@ Flags:
statePath = filepath.Join(stateDir, "state.json")
}
st, err := store.Load(statePath)
if errors.Is(err, store.ErrNewer) {
warnings = append(warnings, err.Error()+": stars, notes and the layout won't be saved; update lazysecrets")
} else if err != nil {
if err != nil {
return err
}
@@ -318,7 +287,7 @@ Flags:
if start == "" {
start, _ = os.Getwd()
}
roots := repo.DefaultRoots
roots := []string{"~/git"}
if pc.Repos.Roots != nil {
roots = *pc.Repos.Roots
}
@@ -341,20 +310,14 @@ Flags:
log.SetOutput(io.Discard)
if getRef != nil {
ctx, cancel := context.WithTimeout(context.Background(), timeoutFor(getRef.Kind))
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
return get(ctx, providers, st, *getRef)
}
if shareArgs != nil {
// The files from a repo, the key from whichever provider it's
// in: the longest timeout.
d := *timeout
for _, t := range timeouts {
d = max(d, t)
}
ctx, cancel := context.WithTimeout(context.Background(), d)
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
return shareFiles(ctx, providers, st, cfg.Sharing.Key, shareRules(cfg), shareArgs, os.Stdout)
return shareFiles(ctx, providers, st, cfg.Share.Key, shareRules(cfg), shareArgs, os.Stdout)
}
// Links opened elsewhere (lazysecrets launch) can come here.
@@ -391,7 +354,7 @@ Flags:
// What was loaded last time, and where it was left in this folder.
var cacheFile, placeDir string
if !*demo {
if d, err := config.CacheDir(); err == nil && config.On(cfg.Storage.Cache) {
if d, err := config.CacheDir(); err == nil && config.On(cfg.Cache) {
cacheFile = filepath.Join(d, "cache.json")
}
placeDir = *dir
@@ -405,32 +368,29 @@ Flags:
m := app.New(providers, app.Options{
Timeout: *timeout,
Timeouts: timeouts,
Icons: *icons,
ClipboardClear: *clipClear,
FlatSources: cfg.UI.SourcesView == "flat",
CollapseGroups: config.On(cfg.UI.CollapseGroups),
FlatSources: cfg.SourcesView == "flat",
CollapseGroups: config.On(cfg.CollapseGroups),
LoadDelay: loadDelay,
NoHistory: !config.On(cfg.Storage.History),
NoHistory: !config.On(cfg.History),
OnActive: onActive,
RevealTimeout: revealTimeout,
ExpiryWarning: time.Duration(expiryDays) * 24 * time.Hour,
HideOnBlur: config.On(cfg.Values.HideOnBlur),
HideOnBlur: config.On(cfg.HideOnBlur),
HandlerSetup: setup,
Updater: updater(cfg.Updates.Sources),
UpdateCheck: config.On(cfg.Updates.AutoCheck),
UpdateEvery: updateEvery,
Updater: updater(cfg.Releases),
UpdateCheck: config.On(cfg.UpdateCheck),
Keys: &keymap,
Commands: cfg.CustomCommands,
Version: versionString(),
Store: st,
Warning: strings.Join(warnings, " · "),
CacheFile: cacheFile,
ConfigFiles: configFiles(*configPath),
StatePath: statePath,
Changelog: lazysecrets.Changelog,
Dir: placeDir,
ShareKey: cfg.Sharing.Key,
ShareKey: cfg.Share.Key,
ShareRules: shareRules(cfg),
Open: open,
Styles: &theme,
+5 -5
View File
@@ -17,12 +17,12 @@ import (
)
// shareFiles prints a share link to env files of one repo, encrypted with
// the --key secret's value, else the repo's or the config's sharing.key:
// the --key secret's value, else the repo's or the config's share.key:
//
// lazysecrets share .env apps/api/.env
func shareFiles(ctx context.Context, providers []provider.Provider, st *store.Store, general string, rules share.Rules, args []string, out io.Writer) error {
fs := flag.NewFlagSet("share", flag.ContinueOnError)
keyLink := fs.String("key", "", "the secret to encrypt with: its lazysecrets:// link, Key Vault URL or reference (default: sharing.key)")
keyLink := fs.String("key", "", "the secret to encrypt with: its lazysecrets:// link, Key Vault URL or reference (default: share.key)")
if err := fs.Parse(args); err != nil {
return err
}
@@ -73,7 +73,7 @@ func shareFiles(ctx context.Context, providers []provider.Provider, st *store.St
return err
}
if !ok {
return errors.New("which secret to encrypt with? --key <secret>, or sharing.key in the repo's .lazysecrets.yaml or the config")
return errors.New("which secret to encrypt with? --key <secret>, or share.key in the repo's .lazysecrets.yaml or the config")
}
v, version, err := readKey(ctx, providers, st, key)
if err != nil {
@@ -87,7 +87,7 @@ func shareFiles(ctx context.Context, providers []provider.Provider, st *store.St
return err
}
// shareRules are the config's sharing.keyRules.
// shareRules are the config's share.keyRules.
func shareRules(cfg config.Config) share.Rules {
return share.Rules{MinLength: cfg.Sharing.KeyRules.MinLength, MinClasses: cfg.Sharing.KeyRules.MinClasses}
return share.Rules{MinLength: cfg.Share.KeyRules.MinLength, MinClasses: cfg.Share.KeyRules.MinClasses}
}
+2 -2
View File
@@ -43,7 +43,7 @@ func updateCmd(configPath string, args []string) error {
current := versionString()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
c := &update.Client{Servers: cfg.Updates.Sources}
c := &update.Client{Servers: cfg.Releases}
r, err := c.Latest(ctx)
if err != nil {
return fmt.Errorf("looking for a newer lazysecrets: %w", err)
@@ -55,7 +55,7 @@ func updateCmd(configPath string, args []string) error {
fmt.Printf("lazysecrets %s is the latest (%s)\n", current, r.Tag)
return nil
}
fmt.Printf("lazysecrets %s is out (you have %s):\n\n%s\n\n", r.Tag, current, indent(r.Notes(current)))
fmt.Printf("lazysecrets %s is out (you have %s):\n\n%s\n\n", r.Tag, current, indent(r.Notes()))
if check {
fmt.Println("lazysecrets update installs it.")
return nil
+1 -1
View File
@@ -63,7 +63,7 @@ Secrets, keys and certificates, each on their own tab of the Secrets pane
(`[`/`]`). `space` reveals a secret's value, or the public material of a
key (PEM public key) or certificate; private key material is never
fetched. `v` lists an item's versions. Items expiring within
`ui.expiryWarningDays` (30 by default) are marked `⚠ 12d`, expired ones `✗`.
`expiryWarningDays` (30 by default) are marked `⚠ 12d`, expired ones `✗`.
The status pane (`0`) lists them across every vault loaded, opened or
cached, soonest first, and its line counts them (`⚠ 3 expiring`); `enter`
+26 -40
View File
@@ -7,45 +7,37 @@ status pane (`0`) opens it in your editor, as in lazygit (making it if
it isn't there yet); changes apply when lazysecrets starts again.
```yaml
ui:
icons: true # Nerd Font icons (v3+); also --icons=false or LAZYSECRETS_ICONS=0
sourcesView: grouped # or flat; ` toggles it
collapseGroups: true # start with the Sources pane's groups folded; enter or space unfolds one
loadDelay: 150ms # how long the cursor rests on a row before it loads; 0s loads at once
expiryWarningDays: 30 # mark secrets, keys and certificates expiring this soon (⚠ 12d); expired ones get ✗
values:
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)
clipboardClear: 30s # clear copied values from the clipboard after this long; 0s disables
storage:
cache: true # keep what was loaded (never values) for the next start
history: true # count uses of items for Recent (R)
updates:
autoCheck: true # look for a newer release at startup (U and lazysecrets update work either way)
autoCheckInterval: 24h # at most this often
sources: # where updates come from, tried in order (GitHub, or a Gitea or Forgejo server)
- https://github.com/karleinarb/lazysecrets
- https://git.kebcloud.no/keb/lazysecrets
icons: true # Nerd Font icons (v3+); also --icons=false or LAZYSECRETS_ICONS=0
clipboardClear: 30s # clear copied values from the clipboard after this long; 0s disables
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)
expiryWarningDays: 30 # mark secrets, keys and certificates expiring this soon (⚠ 12d); expired ones get ✗
timeout: 30s # per backend request
loadDelay: 150ms # how long the cursor rests on a row before it loads; 0s loads at once
sourcesView: grouped # or flat; ` toggles it
collapseGroups: true # start with the Sources pane's groups folded; enter or space unfolds one
updateCheck: true # look for a newer release once a day (U and lazysecrets update work either way)
releases: # where updates come from, tried in order (GitHub, or a Gitea or Forgejo server)
- https://github.com/karleinarb/lazysecrets
- https://git.kebcloud.no/keb/lazysecrets
history: true # count uses of items for Recent (R)
cache: true # keep what was loaded (never values) for the next start
providers:
timeout: 30s # per backend request; a provider's own timeout overrides it
azure: # applies to every az account
enabled: true # default
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
share: # 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
@@ -53,7 +45,7 @@ sharing: # sharing env files as encrypted links
```
More, each on its own page: [repos](repos.md) (`providers.repos`),
[sharing env files](links.md#sharing-env-files) (`sharing`), the
[sharing env files](links.md#sharing-env-files) (`share`), the
[link handler](links.md#from-the-browser-and-chat) (`linkHandler`),
[keybindings](keys.md#rebinding-keys) (`keybinding`) and
[custom commands](custom-commands.md) (`customCommands`).
@@ -91,11 +83,8 @@ theme:
repoColor: ["#c099ff"]
```
A role lazysecrets doesn't know, a typo or one from a newer version, is
skipped with a warning.
Icons need a [Nerd Font](https://www.nerdfonts.com) (v3+). If they render
as boxes, turn them off with `ui.icons: false`.
as boxes, turn them off with `icons: false`.
## Layering
@@ -119,13 +108,11 @@ 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
JSON so an editor plugin can read it with a built-in parser; several
lazysecrets windows can save to it without losing each other's changes.
One written by a newer lazysecrets is read but not saved to, with a
warning, until you update.
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,
for Recent (`R`). Records kept only for usage are dropped after 90 days
unused, and beyond the 100 most recent. `storage.history: false` stops counting.
unused, and beyond the 100 most recent. `history: false` stops counting.
It also keeps where you left lazysecrets in each folder (the selected
source, vault or secret), and goes back there the next time it starts in
@@ -145,18 +132,17 @@ and copying always fetch the value live.
Lists not loaded for 30 days are dropped, and so is everything from an
account you're no longer signed in to. Repos aren't cached: their files
are read from disk anyway. Deleting the file is always safe, and
`storage.cache: false` stops using it.
`cache: false` stops using it.
## Updates
At startup, in the background, lazysecrets asks for the latest release
(`updates.autoCheck`), once a day or every `updates.autoCheckInterval`;
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
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
Once a day (`updateCheck`), in the background, lazysecrets asks for the
latest release; within the day it reuses the answer from the state file.
Releases are published on GitHub and on git.kebcloud.no, and it asks
them in that order, the next when one doesn't answer within 5 seconds;
`releases` lists others, such as a mirror of your own. Offline, or with
every server down, it says nothing. A newer release shows in the footer, `v0.1.1 ↑ v0.2.0`; `U` (or
`lazysecrets update --check`) shows what changed since your version, across the last 10 releases.
`lazysecrets update --check`) shows its changelog.
Updating (`u` there, or `lazysecrets update`) downloads the release for
your OS and CPU from the first server that has it, checks it against
+1 -1
View File
@@ -22,7 +22,7 @@ Development builds show `dev-<commit>` as their version (`-dirty` with
uncommitted changes); `go install …@v0.2.0` shows the tag, releases the
version they were built for.
## How changes land
## Pull requests
Pull requests into `main` are squash-merged, so each is one commit titled
like the PR. The title is a [conventional commit](https://www.conventionalcommits.org)
+2 -2
View File
@@ -65,8 +65,8 @@ certificates as their subject, issuer, dates, names and thumbprint, with
any other PEM block (a private key) named but not shown. `y` still copies
it exactly as stored.
A revealed value hides again after `values.revealTimeout` (60s) and
when the terminal loses focus (`values.hideOnBlur`); see [config](config.md).
A revealed value hides again after `revealTimeout` (60s) and when the
terminal loses focus (`hideOnBlur`); see [config](config.md).
## Stars and notes
+2 -2
View File
@@ -54,7 +54,7 @@ uses it) or your config (for repos that don't name one), by its
reference:
```yaml
sharing:
share:
key: https://kv-team.vault.azure.net/secrets/env-share
```
@@ -67,7 +67,7 @@ isn't long and varied enough, saying how (never showing it). By default
that's 32 characters of two kinds; your config can ask for more, or less:
```yaml
sharing:
share:
keyRules:
minLength: 32 # characters; 16 at least
minClasses: 2 # kinds of character: lowercase, uppercase, digits, symbols
+7 -9
View File
@@ -3,16 +3,14 @@
lazysecrets shows git repositories under **Repos**: their
[SOPS](https://github.com/getsops/sops)-encrypted files (YAML, JSON,
`.env`, INI) and plain env files as vaults, and the keys in them
(`db.password`, `list[0]`) as secrets. A dot or bracket inside a key is
escaped, `app\.name`, so it doesn't read as nesting.
(`db.password`, `list[0]`) as secrets.
## Which repos
**Repos** lists every repo with secret files under the `roots` (by
default whichever of `~/git`, `~/repo`, `~/repos`, `~/projects`, `~/code`,
`~/source` and `~/src` exist; one or two levels down, `repo` or
`org/repo`), any listed in `paths`, and any you starred something in
(the repo, a file or a key), wherever it is. They're checked in the background.
**Repos** lists every repo with secret files under the `roots` (`~/git`
by default; one or two levels down, `repo` or `org/repo`), any listed in
`paths`, and any you starred something in (the repo, a file or a key),
wherever it is. They're checked in the background.
Started inside a repo, that repo gets a row of its own at the top,
`Repo · <name>`, and lazysecrets opens on it, with the files under the
@@ -51,7 +49,7 @@ Folders of dependencies and caches are never looked in, tracked or not:
```yaml
providers:
repos:
roots: [~/git] # folders of repos (replaces the defaults; [] turns it off)
roots: [~/git] # folders of repos (the default; [] turns it off)
paths: [~/work/infra] # single repos elsewhere
include: ["secrets/**", "**/*.sops.yaml"] # globs from the repo root; ** for any depth
exclude: ["**/testdata/**"]
@@ -61,7 +59,7 @@ A repo's `.lazysecrets.yaml` can also name the secret its env files are
[shared](links.md#sharing-env-files) with:
```yaml
sharing:
share:
key: https://kv-team.vault.azure.net/secrets/env-share # or its lazysecrets:// link
```
+2 -2
View File
@@ -18,8 +18,8 @@ require (
github.com/atotto/clipboard v0.1.4
github.com/charmbracelet/x/ansi v0.11.9
github.com/charmbracelet/x/exp/golden v0.0.0-20251109135125-8916d276318f
github.com/databricks/databricks-sdk-go v0.188.0
github.com/junegunn/fzf v0.74.5
github.com/databricks/databricks-sdk-go v0.187.0
github.com/junegunn/fzf v0.74.4
go.yaml.in/yaml/v3 v3.0.5
golang.org/x/mod v0.42.0
golang.org/x/term v0.47.0
+4 -4
View File
@@ -72,8 +72,8 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/databricks/databricks-sdk-go v0.188.0 h1:1nxBqlctGnPzlReyziG199abHs9cR/KNjsCnSM+oDGA=
github.com/databricks/databricks-sdk-go v0.188.0/go.mod h1:udBw6nxkaqa0fgPQ7ZW1kGFkltQncYx+qFJeVFmxtz8=
github.com/databricks/databricks-sdk-go v0.187.0 h1:V1rGbEUaEdGS7tTZSyplDLnE2Vp5N1aIPiIh/LzjOC8=
github.com/databricks/databricks-sdk-go v0.187.0/go.mod h1:udBw6nxkaqa0fgPQ7ZW1kGFkltQncYx+qFJeVFmxtz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
@@ -100,8 +100,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dq
github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8=
github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
github.com/junegunn/fzf v0.74.5 h1:mpJlCdgGuddKerHrdjvV3cnezEBgyjZAVn8KCo/4OMc=
github.com/junegunn/fzf v0.74.5/go.mod h1:TBRY54j3mGjAm4JcpDyiUIuDq38QlHvAzrB3GLKxQXM=
github.com/junegunn/fzf v0.74.4 h1:DSqIW3ZxtkuuPzeLKrVnHdoWqT9VCIGRbjc/lXiGCso=
github.com/junegunn/fzf v0.74.4/go.mod h1:TBRY54j3mGjAm4JcpDyiUIuDq38QlHvAzrB3GLKxQXM=
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 h1:7dYDtfMDfKzjT+DVfIS4iqknSEKtZpEcXtu6vuaasHs=
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741/go.mod h1:6EILKtGpo5t+KLb85LNZLAF6P9LKp78hJI80PXMcn3c=
github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
+56 -98
View File
@@ -19,18 +19,44 @@ import (
)
type Config struct {
UI UI `yaml:"ui"`
// Values keeps revealed and copied values from lingering.
Values Values `yaml:"values"`
// Storage is what's kept between runs.
Storage Storage `yaml:"storage"`
// Updates sets how lazysecrets looks for newer releases.
Updates Updates `yaml:"updates"`
Providers Providers `yaml:"providers"`
Icons *bool `yaml:"icons"`
ClipboardClear *time.Duration `yaml:"clipboardClear"`
// RevealTimeout hides a revealed value after this long; 0s never.
// Defaults to 60s.
RevealTimeout *time.Duration `yaml:"revealTimeout"`
// ExpiryWarningDays marks secrets, keys and certificates expiring
// within this many days; 0 marks only expired ones. Defaults to 30.
ExpiryWarningDays *int `yaml:"expiryWarningDays"`
// HideOnBlur hides a revealed value when the terminal loses focus.
// Defaults to true.
HideOnBlur *bool `yaml:"hideOnBlur"`
Timeout time.Duration `yaml:"timeout"`
// LoadDelay is how long the cursor must rest on a row before its
// contents load; 0s loads at once. Defaults to 150ms.
LoadDelay *time.Duration `yaml:"loadDelay"`
// SourcesView is "grouped" (default) or "flat"; ` + "`" + ` toggles it.
SourcesView string `yaml:"sourcesView"`
// History counts uses of items for the Recent list; defaults to true.
History *bool `yaml:"history"`
// UpdateCheck looks for a newer release once a day, in the
// background; defaults to true. U and lazysecrets update work either
// way.
UpdateCheck *bool `yaml:"updateCheck"`
// Releases are the repositories updates come from, tried in order:
// on GitHub, or a Gitea or Forgejo server. Defaults to GitHub, then
// git.kebcloud.no (update.DefaultServers).
Releases []string `yaml:"releases"`
// Cache keeps what was loaded (never values) between runs, so lists
// show at once and refresh in the background; defaults to true.
Cache *bool `yaml:"cache"`
// CollapseGroups starts the Sources pane with its groups folded;
// defaults to true.
CollapseGroups *bool `yaml:"collapseGroups"`
Providers Providers `yaml:"providers"`
// LinkHandler sets how lazysecrets:// links open; see linkhandler.
LinkHandler LinkHandler `yaml:"linkHandler"`
// Sharing sets how env files are shared; see package share.
Sharing Sharing `yaml:"sharing"`
// Share sets how env files are shared; see package share.
Share Share `yaml:"share"`
// CustomCommands run shell commands on the selected item; see
// package commands.
CustomCommands []commands.Command `yaml:"customCommands"`
@@ -42,52 +68,6 @@ type Config struct {
Theme map[string][]string `yaml:"theme"`
}
type UI struct {
Icons *bool `yaml:"icons"`
// SourcesView is "grouped" (default) or "flat"; ` + "`" + ` toggles it.
SourcesView string `yaml:"sourcesView"`
// CollapseGroups starts the Sources pane with its groups folded;
// defaults to true.
CollapseGroups *bool `yaml:"collapseGroups"`
// LoadDelay is how long the cursor must rest on a row before its
// contents load; 0s loads at once. Defaults to 150ms.
LoadDelay *time.Duration `yaml:"loadDelay"`
// ExpiryWarningDays marks secrets, keys and certificates expiring
// within this many days; 0 marks only expired ones. Defaults to 30.
ExpiryWarningDays *int `yaml:"expiryWarningDays"`
}
type Values struct {
// RevealTimeout hides a revealed value after this long; 0s never.
// Defaults to 60s.
RevealTimeout *time.Duration `yaml:"revealTimeout"`
// HideOnBlur hides a revealed value when the terminal loses focus.
// Defaults to true.
HideOnBlur *bool `yaml:"hideOnBlur"`
ClipboardClear *time.Duration `yaml:"clipboardClear"`
}
type Storage struct {
// Cache keeps what was loaded (never values) between runs, so lists
// show at once and refresh in the background; defaults to true.
Cache *bool `yaml:"cache"`
// History counts uses of items for the Recent list; defaults to true.
History *bool `yaml:"history"`
}
type Updates struct {
// AutoCheck looks for a newer release in the background at startup,
// at most once per AutoCheckInterval; defaults to true. U and
// lazysecrets update work either way.
AutoCheck *bool `yaml:"autoCheck"`
// AutoCheckInterval defaults to 24h.
AutoCheckInterval *time.Duration `yaml:"autoCheckInterval"`
// Sources are the repositories updates come from, tried in
// order: on GitHub, or a Gitea or Forgejo server. Defaults to GitHub,
// then git.kebcloud.no (update.DefaultServers).
Sources []string `yaml:"sources"`
}
type LinkHandler struct {
// Terminal to open links in, overriding the one handler install
// detected: kitty, ghostty, wezterm, iterm2, terminal, alacritty,
@@ -105,7 +85,7 @@ type LinkHandler struct {
Prompt *bool `yaml:"prompt"`
}
type Sharing struct {
type Share struct {
// Key is the secret whose value encrypts shared files (its
// lazysecrets:// link, Key Vault URL or reference), for repos whose
// .lazysecrets.yaml doesn't name one.
@@ -123,25 +103,20 @@ type KeyRules struct {
}
type Providers struct {
// Timeout bounds each backend request; defaults to 30s. Each
// provider's own timeout overrides it for that provider.
Timeout time.Duration `yaml:"timeout"`
Azure Azure `yaml:"azure"`
Databricks Databricks `yaml:"databricks"`
Repos Repos `yaml:"repos"`
Azure Azure `yaml:"azure"`
Databricks Databricks `yaml:"databricks"`
Repos Repos `yaml:"repos"`
}
// Repos are git repositories' secret files (SOPS, .env). Inside a repo,
// that repo is the only one shown; elsewhere, the ones in Paths and under
// Roots.
type Repos struct {
// Timeout overrides providers.timeout for these requests.
Timeout time.Duration `yaml:"timeout"`
// Enabled defaults to true.
Enabled *bool `yaml:"enabled"`
Paths []string `yaml:"paths"`
// Roots are folders of repos. Defaults to repo.DefaultRoots (~/git,
// ~/src, ~/code and the like); an empty list turns it off.
// Roots are folders of repos. Defaults to ~/git; an empty list
// turns it off.
Roots *[]string `yaml:"roots"`
// Include and Exclude are globs relative to a repo's root (** for any
// depth), for repos without their own .lazysecrets.yaml. Without them,
@@ -151,8 +126,6 @@ type Repos struct {
}
type Azure struct {
// Timeout overrides providers.timeout for these requests.
Timeout time.Duration `yaml:"timeout"`
// Enabled defaults to true.
Enabled *bool `yaml:"enabled"`
// HideSubscriptions lists subscriptions to leave out, by name or ID.
@@ -163,8 +136,6 @@ type Azure struct {
}
type Databricks struct {
// Timeout overrides providers.timeout for these requests.
Timeout time.Duration `yaml:"timeout"`
// Enabled defaults to true.
Enabled *bool `yaml:"enabled"`
HideProfiles []string `yaml:"hideProfiles"`
@@ -251,48 +222,35 @@ func decodeInto(c *Config, path string) error {
}
func (c Config) validate(path string) error {
if v := c.UI.SourcesView; v != "" && v != "grouped" && v != "flat" {
return fmt.Errorf("%s: ui.sourcesView: %q is not grouped or flat", path, v)
if v := c.SourcesView; v != "" && v != "grouped" && v != "flat" {
return fmt.Errorf("%s: sourcesView: %q is not grouped or flat", path, v)
}
for _, cmd := range c.CustomCommands {
if err := cmd.Validate(); err != nil {
return fmt.Errorf("%s: %w", path, err)
}
}
if d := c.UI.ExpiryWarningDays; d != nil && *d < 0 {
return fmt.Errorf("%s: ui.expiryWarningDays: %d is negative", path, *d)
if d := c.ExpiryWarningDays; d != nil && *d < 0 {
return fmt.Errorf("%s: expiryWarningDays: %d is negative", path, *d)
}
if d := c.UI.LoadDelay; d != nil && (*d < 0 || *d > 5*time.Second) {
return fmt.Errorf("%s: ui.loadDelay: %s is not between 0s and 5s", path, *d)
if d := c.LoadDelay; d != nil && (*d < 0 || *d > 5*time.Second) {
return fmt.Errorf("%s: loadDelay: %s is not between 0s and 5s", path, *d)
}
if d := c.Updates.AutoCheckInterval; d != nil && *d <= 0 {
return fmt.Errorf("%s: updates.autoCheckInterval: %s is not positive (autoCheck: false turns it off)", path, *d)
}
for i, r := range c.Updates.Sources {
for i, r := range c.Releases {
if _, err := update.API(r); err != nil || !strings.HasPrefix(r, "https://") {
return fmt.Errorf("%s: updates.sources[%d]: %q is not an https:// repository address, like %s", path, i, r, update.GitHubRepo)
return fmt.Errorf("%s: releases[%d]: %q is not an https:// repository address, like %s", path, i, r, update.GitHubRepo)
}
}
if k := c.Sharing.Key; k != "" {
if k := c.Share.Key; k != "" {
if _, err := share.ParseKey(k); err != nil {
return fmt.Errorf("%s: sharing.key: %w", path, err)
return fmt.Errorf("%s: share.key: %w", path, err)
}
}
if n := c.Sharing.KeyRules.MinLength; n != 0 && n < 16 {
return fmt.Errorf("%s: sharing.keyRules.minLength: %d is under 16", path, n)
if n := c.Share.KeyRules.MinLength; n != 0 && n < 16 {
return fmt.Errorf("%s: share.keyRules.minLength: %d is under 16", path, n)
}
if n := c.Sharing.KeyRules.MinClasses; n < 0 || n > 4 {
return fmt.Errorf("%s: sharing.keyRules.minClasses: %d is not between 1 and 4", path, n)
}
for name, d := range map[string]time.Duration{
"providers.timeout": c.Providers.Timeout,
"providers.azure.timeout": c.Providers.Azure.Timeout,
"providers.databricks.timeout": c.Providers.Databricks.Timeout,
"providers.repos.timeout": c.Providers.Repos.Timeout,
} {
if d < 0 {
return fmt.Errorf("%s: %s: %s is negative", path, name, d)
}
if n := c.Share.KeyRules.MinClasses; n < 0 || n > 4 {
return fmt.Errorf("%s: share.keyRules.minClasses: %d is not between 1 and 4", path, n)
}
for i, v := range c.Providers.Azure.Vaults {
if !strings.HasPrefix(v, "https://") {
+27 -39
View File
@@ -19,17 +19,12 @@ func write(t *testing.T, content string) string {
func TestLoad(t *testing.T) {
c, err := Load(write(t, `
ui:
icons: false
sourcesView: flat
values:
clipboardClear: 45s
storage:
history: false
icons: false
sourcesView: flat
clipboardClear: 45s
timeout: 1m
providers:
timeout: 1m
azure:
timeout: 2m
hideSubscriptions: [platform-mgmt]
vaults:
- https://kv-foo.vault.azure.net/
@@ -40,12 +35,12 @@ providers:
if err != nil {
t.Fatal(err)
}
if c.UI.Icons == nil || *c.UI.Icons || c.Values.ClipboardClear == nil || *c.Values.ClipboardClear != 45*time.Second ||
c.Providers.Timeout != time.Minute || c.Providers.Azure.Timeout != 2*time.Minute || len(c.Providers.Azure.Vaults) != 1 || c.Providers.Databricks.HideProfiles[0] != "test" {
if c.Icons == nil || *c.Icons || c.ClipboardClear == nil || *c.ClipboardClear != 45*time.Second ||
c.Timeout != time.Minute || len(c.Providers.Azure.Vaults) != 1 || c.Providers.Databricks.HideProfiles[0] != "test" {
t.Errorf("config = %+v", c)
}
if c.UI.SourcesView != "flat" || On(c.Storage.History) || !On(c.Storage.Cache) {
t.Errorf("ui = %+v, storage = %+v", c.UI, c.Storage)
if c.SourcesView != "flat" {
t.Errorf("sourcesView = %q", c.SourcesView)
}
if !On(c.Providers.Azure.Enabled) || On(c.Providers.Databricks.Enabled) {
t.Error("enabled flags wrong")
@@ -62,7 +57,7 @@ func TestLoadMissingAndEmpty(t *testing.T) {
"empty": write(t, ""),
} {
c, err := Load(path)
if err != nil || c.UI.Icons != nil || c.Values.ClipboardClear != nil {
if err != nil || c.Icons != nil || c.ClipboardClear != nil {
t.Errorf("%s: %+v, %v", name, c, err)
}
}
@@ -73,17 +68,14 @@ func TestLoadRejectsMistakes(t *testing.T) {
"typo": "icon: true\n",
"bad vault": "providers:\n azure:\n vaults: [kv-foo]\n",
"old layout": "azure:\n vaults: [https://kv.vault.azure.net/]\n",
"flat keys": "icons: false\n",
"duration": "providers:\n timeout: soon\n",
"negative": "providers:\n repos:\n timeout: -1s\n",
"view": "ui:\n sourcesView: tree\n",
"sources": "updates:\n sources: [git.example.com/team/lazysecrets]\n",
"http": "updates:\n sources: [http://git.example.com/team/lazysecrets]\n",
"interval": "updates:\n autoCheckInterval: 0s\n",
"share key": "sharing:\n key: https://kv.vault.azure.net/\n",
"workspace": "sharing:\n key: \"{{secrets/team/env-share}}\"\n",
"short keys": "sharing:\n keyRules:\n minLength: 8\n",
"classes": "sharing:\n keyRules:\n minClasses: 5\n",
"duration": "timeout: soon\n",
"view": "sourcesView: tree\n",
"releases": "releases: [git.example.com/team/lazysecrets]\n",
"http": "releases: [http://git.example.com/team/lazysecrets]\n",
"share key": "share:\n key: https://kv.vault.azure.net/\n",
"workspace": "share:\n key: \"{{secrets/team/env-share}}\"\n",
"short keys": "share:\n keyRules:\n minLength: 8\n",
"classes": "share:\n keyRules:\n minClasses: 5\n",
} {
if _, err := Load(write(t, content)); err == nil {
t.Errorf("%s: no error", name)
@@ -95,8 +87,7 @@ func TestLoadRejectsMistakes(t *testing.T) {
func TestLoadAllLayers(t *testing.T) {
user := write(t, `
ui:
icons: false
icons: false
theme:
azureColor: [blue]
errorColor: [red, bold]
@@ -105,8 +96,6 @@ providers:
hideSubscriptions: [a]
`)
theme := write(t, `
ui:
sourcesView: flat
theme:
azureColor: ["#82aaff"]
activeBorderColor: ["#ff966c", bold]
@@ -115,8 +104,7 @@ theme:
if err != nil {
t.Fatal(err)
}
// A group set in both files merges: icons from one, sourcesView the other.
if c.UI.Icons == nil || *c.UI.Icons || c.UI.SourcesView != "flat" || len(c.Providers.Azure.HideSubscriptions) != 1 {
if c.Icons == nil || *c.Icons || len(c.Providers.Azure.HideSubscriptions) != 1 {
t.Errorf("user settings lost: %+v", c)
}
want := map[string][]string{
@@ -131,19 +119,19 @@ theme:
}
}
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"))
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)
func TestReleases(t *testing.T) {
c, err := Load(write(t, "releases: [https://git.example.com/team/lazysecrets]\n"))
if err != nil || len(c.Releases) != 1 {
t.Errorf("releases = %v, %v", c.Releases, err)
}
}
func TestLoadDelay(t *testing.T) {
c, err := Load(write(t, "ui:\n loadDelay: 300ms\n"))
if err != nil || c.UI.LoadDelay == nil || *c.UI.LoadDelay != 300*time.Millisecond {
t.Errorf("loadDelay = %v, %v", c.UI.LoadDelay, err)
c, err := Load(write(t, "loadDelay: 300ms\n"))
if err != nil || c.LoadDelay == nil || *c.LoadDelay != 300*time.Millisecond {
t.Errorf("loadDelay = %v, %v", c.LoadDelay, err)
}
if _, err := Load(write(t, "ui:\n loadDelay: 1m\n")); err == nil {
if _, err := Load(write(t, "loadDelay: 1m\n")); err == nil {
t.Error("a minute's delay accepted")
}
}
+1 -1
View File
@@ -332,7 +332,7 @@ func Azure(delay time.Duration) *Provider {
}, demoCert)
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 share.keyRules wants.
p.add(shared, provider.TypeSecret, provider.Object{
Name: "env-share", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "env-share"),
}, "q8Zt3VwL1mXc7Rk2Pj9Ys4Hn6Bf0Ga5Ud+Te/OiWlE=")
+23 -69
View File
@@ -80,10 +80,6 @@ type repoInfo struct {
here bool // the repo lazysecrets started in
}
// DefaultRoots are the usual folders of repos, used when the config
// doesn't set roots. Ones that don't exist cost a stat each.
var DefaultRoots = []string{"~/git", "~/repo", "~/repos", "~/projects", "~/code", "~/source", "~/src"}
func New(s Settings) *Provider {
if s.Sops == "" {
s.Sops = "sops"
@@ -120,18 +116,10 @@ func (p *Provider) load() {
}
add(root)
}
// Missing roots are skipped (most of the defaults are), and one
// that's a symlink to another is scanned once.
scanned := map[string]bool{}
for _, dir := range p.s.Roots {
base, err := filepath.EvalSymlinks(expandHome(dir))
if err != nil || scanned[base] {
continue
}
scanned[base] = true
for _, root := range reposIn(base, 2) {
// A repo under two roots keeps the first one's name.
if name, err := filepath.Rel(base, root); err == nil && !strings.HasPrefix(name, "..") && names[root] == "" {
base, _ := filepath.EvalSymlinks(expandHome(dir))
for _, root := range reposIn(expandHome(dir), 2) {
if name, err := filepath.Rel(base, root); err == nil && !strings.HasPrefix(name, "..") {
names[root] = filepath.ToSlash(name)
}
add(root)
@@ -403,11 +391,10 @@ func (p *Provider) Versions(context.Context, provider.Object) ([]provider.Object
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
file, extract, ok := strings.Cut(o.ID, "\x00")
if !ok { // made from a link: the repo's root, the file's path, the key
file = o.Container.ID
file, extract = o.Container.ID, extractFor(o.Name)
if file == "" {
file = filepath.Join(o.Container.Source.ID, filepath.FromSlash(o.Container.Name))
}
extract = extractFor(file, o.Name)
}
if _, isSops := readMeta(file); !isSops {
return plainValue(file, o.Name)
@@ -600,14 +587,14 @@ func normalizeRemote(remote string) (string, string) {
type lazysecretsFile struct {
Include []string `yaml:"include"`
Exclude []string `yaml:"exclude"`
Sharing struct {
Share struct {
// Key is the link to the secret that encrypts the repo's
// shared env files (package share).
Key string `yaml:"key"`
} `yaml:"sharing"`
} `yaml:"share"`
}
// ShareKey is the sharing.key of the repo's .lazysecrets.yaml, if any.
// ShareKey is the share.key of the repo's .lazysecrets.yaml, if any.
func ShareKey(root string) (string, error) {
b, err := os.ReadFile(filepath.Join(root, ".lazysecrets.yaml"))
if errors.Is(err, os.ErrNotExist) {
@@ -620,7 +607,7 @@ func ShareKey(root string) (string, error) {
if err := yaml.Unmarshal(b, &f); err != nil {
return "", fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err)
}
return f.Sharing.Key, nil
return f.Share.Key, nil
}
// selector says which files to look at: user (the include/exclude of the
@@ -1013,7 +1000,7 @@ func formatOf(file string) string {
}
type key struct {
name string // db.password, list[0], with . [ ] \ in keys escaped
name string // db.password, list[0]
extract string // sops --extract: ["db"]["password"]
encrypted bool
line int // 1-based
@@ -1058,7 +1045,7 @@ func parseKeys(file string, b []byte) ([]key, error) {
continue
}
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
out = append(out, key{name: escape(section) + "." + escape(k), extract: quote(section) + quote(k), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1})
out = append(out, key{name: section + "." + k, extract: quote(section) + quote(k), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1})
}
}
return out, nil
@@ -1075,9 +1062,9 @@ func walk(n *yaml.Node, name, extract string, top bool, out *[]key) {
if top && k == "sops" {
continue
}
child := escape(k)
child := k
if name != "" {
child = name + "." + child
child = name + "." + k
}
walk(n.Content[i+1], child, extract+quote(k), false, out)
}
@@ -1099,56 +1086,23 @@ func quote(k string) string {
return `["` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(k) + `"]`
}
// escape keeps a key's dots and brackets from reading as nesting in a
// name: app\.name is one key, app.name two.
func escape(k string) string {
return strings.NewReplacer(`\`, `\\`, `.`, `\.`, `[`, `\[`, `]`, `\]`).Replace(k)
}
// extractFor rebuilds the extract path from a key name, for objects made
// from a link (db.password, list[0], app\.name). A dotenv file is flat:
// its names are the keys as they are.
func extractFor(file, name string) string {
if formatOf(file) == "dotenv" {
return quote(name)
}
var b, part strings.Builder
flush := func() {
if part.Len() > 0 {
b.WriteString(quote(part.String()))
part.Reset()
// from a link (db.password, list[0]). Names with dots in keys can't be
// told apart from nesting: those come from the listing instead.
func extractFor(name string) string {
var b strings.Builder
for _, part := range strings.Split(name, ".") {
base, idx, _ := strings.Cut(part, "[")
if base != "" {
b.WriteString(quote(base))
}
if idx != "" {
b.WriteString("[" + idx)
}
}
for i := 0; i < len(name); i++ {
switch c := name[i]; c {
case '\\':
if i+1 < len(name) {
i++
}
part.WriteByte(name[i])
case '.':
flush()
case '[':
end := strings.IndexByte(name[i:], ']')
if end < 0 || !digits(name[i+1:i+end]) {
part.WriteByte(c)
continue
}
flush()
b.WriteString(name[i : i+end+1])
i += end
default:
part.WriteByte(c)
}
}
flush()
return b.String()
}
func digits(s string) bool {
return s != "" && strings.Trim(s, "0123456789") == ""
}
func absPath(p string) string {
if a, err := filepath.Abs(p); err == nil {
return a
-45
View File
@@ -336,30 +336,6 @@ func TestRoots(t *testing.T) {
}
}
func TestRootsDeduped(t *testing.T) {
root, _ := filepath.EvalSymlinks(t.TempDir())
os.Rename(gitRepo(t, map[string]string{"secrets/a.yaml": sopsYAML}), filepath.Join(root, "infra"))
os.MkdirAll(filepath.Join(root, "acme"), 0o755)
os.Rename(gitRepo(t, map[string]string{"prod.env": sopsEnv}), filepath.Join(root, "acme", "billing"))
// ~/src → ~/git, and a root reaching one repo at another depth.
links := t.TempDir()
alias := filepath.Join(links, "src")
os.Symlink(root, alias)
missing := filepath.Join(links, "nope")
acme := filepath.Join(root, "acme")
srcs, err := New(Settings{Dir: links, Roots: []string{missing, root, alias, acme}}).Sources(context.Background())
if err != nil {
t.Fatal(err)
}
got := names(srcs, func(s provider.Source) string { return s.Name })
slices.Sort(got)
if !slices.Equal(got, []string{"acme/billing", "infra"}) {
t.Errorf("repos: %v, want each once, named from the first root", got)
}
}
func TestStarredReposAlongside(t *testing.T) {
here := gitRepo(t, map[string]string{"secrets/a.yaml": sopsYAML})
starred := gitRepo(t, map[string]string{"prod.env": sopsEnv})
@@ -545,24 +521,3 @@ func TestFileDetails(t *testing.T) {
t.Errorf("untracked env: %+v", d)
}
}
func TestKeyNamesEscaped(t *testing.T) {
keys, err := parseKeys("s.yaml", []byte("app.name: x\ndb:\n password: y\nlist: [a, b]\nodd[1]: z\n"))
if err != nil {
t.Fatal(err)
}
var got []string
for _, k := range keys {
got = append(got, k.name+" "+k.extract)
if e := extractFor("s.yaml", k.name); e != k.extract {
t.Errorf("extractFor(%q) = %s, want %s", k.name, e, k.extract)
}
}
want := `app\.name ["app.name"]|db.password ["db"]["password"]|list[0] ["list"][0]|list[1] ["list"][1]|odd\[1\] ["odd[1]"]`
if strings.Join(got, "|") != want {
t.Errorf("keys = %s\nwant %s", strings.Join(got, "|"), want)
}
if e := extractFor(".env", "spring.url"); e != `["spring.url"]` {
t.Errorf("dotenv: %s", e)
}
}
+1 -1
View File
@@ -310,7 +310,7 @@ func parseLink(s string) (Ref, error) {
}
default:
return Ref{}, fmt.Errorf("unknown provider %q in link: a newer lazysecrets may know it (lazysecrets update)", u.Host)
return Ref{}, fmt.Errorf("unknown provider %q in link", u.Host)
}
return r, nil
}
+1 -1
View File
@@ -53,7 +53,7 @@ func (r Rules) Check(key []byte) error {
if len(has) == 0 {
return nil
}
return fmt.Errorf("its value has %s; sharing.keyRules needs %s",
return fmt.Errorf("its value has %s; share.keyRules needs %s",
strings.Join(has, " and "), strings.Join(needs, " and "))
}
+3 -3
View File
@@ -259,8 +259,8 @@ func CheckPath(p string) error {
return nil
}
// DefaultKey is the key for a repo's files: sharing.key in the repo's
// .lazysecrets.yaml, else general (sharing.key in the config). ok is false
// DefaultKey is the key for a repo's files: share.key in the repo's
// .lazysecrets.yaml, else general (share.key in the config). ok is false
// when neither names one.
func DefaultKey(root, general string) (key ref.Ref, ok bool, err error) {
link, err := repo.ShareKey(root)
@@ -276,7 +276,7 @@ func DefaultKey(root, general string) (key ref.Ref, ok bool, err error) {
}
key, err = ParseKey(link)
if err != nil {
return ref.Ref{}, false, fmt.Errorf("sharing.key in %s: %w", where, err)
return ref.Ref{}, false, fmt.Errorf("share.key in %s: %w", where, err)
}
return key, true, nil
}
+1 -1
View File
@@ -243,7 +243,7 @@ func TestDefaultKey(t *testing.T) {
if err != nil || !ok || k.Name != "env-share" {
t.Errorf("general: %+v %v %v", k, ok, err)
}
os.WriteFile(filepath.Join(dir, ".lazysecrets.yaml"), []byte("sharing:\n key: lazysecrets://azure/kv-app.vault.azure.net/secrets/app-share\n"), 0o644)
os.WriteFile(filepath.Join(dir, ".lazysecrets.yaml"), []byte("share:\n key: lazysecrets://azure/kv-app.vault.azure.net/secrets/app-share\n"), 0o644)
if k, _, _ := DefaultKey(dir, general); k.Name != "app-share" {
t.Errorf("the repo's should win: %+v", k)
}
+6 -13
View File
@@ -145,23 +145,16 @@ type Store struct {
data file
}
// ErrNewer is a state file written by a newer lazysecrets.
var ErrNewer = errors.New("written by a newer lazysecrets")
// Load reads path; a missing file is an empty store. path "" gives a store
// that never saves, for tests and --demo. A file from a newer lazysecrets
// is read but never saved, so nothing it keeps is lost: Load returns the
// store with an ErrNewer error.
// that never saves, for tests and --demo.
func Load(path string) (*Store, error) {
s := &Store{path: path}
d, err := read(path)
if errors.Is(err, ErrNewer) {
s.path = ""
} else if err != nil {
if err != nil {
return nil, err
}
s.data = d
return s, err
return s, nil
}
func read(path string) (file, error) {
@@ -179,12 +172,12 @@ func read(path string) (file, error) {
if err := json.Unmarshal(b, &d); err != nil {
return d, fmt.Errorf("%s: %w", path, err)
}
if d.Version > version {
return d, fmt.Errorf("%s: written by a newer lazysecrets (version %d)", path, d.Version)
}
if d.Nodes == nil {
d.Nodes = map[string]Node{}
}
if d.Version > version {
return d, fmt.Errorf("%s: %w (version %d)", path, ErrNewer, d.Version)
}
return d, nil
}
+4 -11
View File
@@ -1,7 +1,6 @@
package store
import (
"errors"
"fmt"
"os"
"path/filepath"
@@ -66,17 +65,11 @@ func TestTwoWindowsDontLoseEachOthersChanges(t *testing.T) {
}
}
func TestNewerVersionReadOnly(t *testing.T) {
func TestNewerVersionRefused(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.json")
newer := `{"version": 99, "nodes": {"one": {"note": "hi"}}}`
_ = os.WriteFile(path, []byte(newer), 0o600)
s, err := Load(path)
if !errors.Is(err, ErrNewer) || s == nil || s.Node("one").Note != "hi" {
t.Fatalf("Load = %v, %v", s, err)
}
_ = s.SetFlag("x", true)
if b, _ := os.ReadFile(path); string(b) != newer {
t.Errorf("the newer file was overwritten: %s", b)
_ = os.WriteFile(path, []byte(`{"version": 99, "nodes": {}}`), 0o600)
if _, err := Load(path); err == nil {
t.Error("a newer file was accepted")
}
}
+19 -48
View File
@@ -65,8 +65,6 @@ const (
type Options struct {
// Timeout bounds every provider call. Defaults to 30s.
Timeout time.Duration
// Timeouts override Timeout for the providers of a kind.
Timeouts map[provider.Kind]time.Duration
// Icons shows Nerd Font glyphs next to backends.
Icons bool
// Clipboard defaults to the system clipboard, or OSC52 over SSH.
@@ -79,15 +77,12 @@ type Options struct {
// and keeps its state, for the status pane.
ConfigFiles []string
StatePath string
// Warning shows when the window opens: what lazysecrets skipped
// instead of failing to start.
Warning string
// Changelog is CHANGELOG.md, for the status pane.
Changelog string
// CacheFile keeps what was loaded between runs; "" keeps it for this
// run only.
CacheFile string
// ShareKey is the config's sharing.key: the secret that encrypts shared
// ShareKey is the config's share.key: the secret that encrypts shared
// env files, for repos whose .lazysecrets.yaml names none.
ShareKey string
// ShareRules are what a key's value must be to share with it.
@@ -122,11 +117,9 @@ type Options struct {
// Keys, if set, replaces the default keymap (keys.Map.Apply).
Keys *keys.Map
// Updater, if set, finds and installs newer releases (U); with
// UpdateCheck, lazysecrets also looks on its own at startup, at most
// once per UpdateEvery (0 for a day).
// UpdateCheck, lazysecrets also looks once a day on its own.
Updater *Updater
UpdateCheck bool
UpdateEvery time.Duration
// HandlerSetup, if set, offers to set up the link handler at startup.
HandlerSetup *HandlerSetup
// NoHistory stops counting uses of items for the Recent list.
@@ -151,7 +144,7 @@ type Clipboard interface {
type Model struct {
providers []provider.Provider
byID map[string]provider.Provider
timeout timeouts
timeout time.Duration
keys keys.Map
st styles.Styles
@@ -243,7 +236,6 @@ type Model struct {
updater *Updater
updateCheck bool
updateEvery time.Duration
fetchOnStart bool // look for a newer release in Init
newer *update.Release // a newer release to offer
updated string // the release installed, waiting for a restart
@@ -284,8 +276,8 @@ type Model struct {
linked []provider.Container // vaults opened from links, this session
open *ref.Ref // reference to open at startup
shareKey string // the config's sharing.key
shareRules share.Rules // and sharing.keyRules
shareKey string // the config's share.key
shareRules share.Rules // and share.keyRules
// A lazysecrets:// link found on the clipboard, offered in the footer.
dismissedLink string // the last link offered: not offered again
@@ -383,7 +375,7 @@ func New(providers []provider.Provider, opts Options) Model {
m := Model{
providers: providers,
byID: map[string]provider.Provider{},
timeout: timeouts{def: opts.Timeout, byKind: opts.Timeouts},
timeout: opts.Timeout,
keys: keys.Default(),
st: styles.Default(),
spin: spinner.New(spinner.WithSpinner(spinner.Dot)),
@@ -435,10 +427,7 @@ func New(providers []provider.Provider, opts Options) Model {
m.revealTimeout, m.hideOnBlur = opts.RevealTimeout, opts.HideOnBlur
m.expiryWarning = opts.ExpiryWarning
m.handlerSetup = opts.HandlerSetup
m.updater, m.updateCheck, m.updateEvery = opts.Updater, opts.UpdateCheck, opts.UpdateEvery
if m.updateEvery == 0 {
m.updateEvery = updateEvery
}
m.updater, m.updateCheck = opts.Updater, opts.UpdateCheck
m.startUpdateCheck()
m.askHandler = opts.HandlerSetup != nil && !m.store.Flag(flagNoHandlerPrompt)
m.settle = opts.LoadDelay
@@ -477,9 +466,6 @@ func New(providers []provider.Provider, opts Options) Model {
m.cachePath, m.placeDir, m.sourcesAt = opts.CacheFile, opts.Dir, map[string]time.Time{}
m.shareKey, m.shareRules = opts.ShareKey, opts.ShareRules
m.configFiles, m.statePath, m.changelog = opts.ConfigFiles, opts.StatePath, changelogFields(opts.Changelog)
if opts.Warning != "" {
m.flash = warning(opts.Warning)
}
if m.cachePath != "" {
m.loadCacheFile()
if len(m.sourceResults) > 0 {
@@ -1254,7 +1240,7 @@ func (m *Model) showVersions(o provider.Object, key string) tea.Cmd {
}
m.secrets.SetLoading()
p := m.providerFor(o.Container.Source)
ctx, req := m.begin(slotObjects, o.Container.Source.Kind)
ctx, req := m.begin(slotObjects)
return func() tea.Msg {
items, err := p.Versions(ctx, o)
for i := range items {
@@ -1284,7 +1270,7 @@ func (m *Model) countVersions(o provider.Object) tea.Cmd {
}
}
p := m.providerFor(o.Container.Source)
ctx, req := m.begin(slotVersions, o.Container.Source.Kind)
ctx, req := m.begin(slotVersions)
return m.settled(ctx, func() tea.Msg {
items, err := p.Versions(ctx, o)
for i := range items {
@@ -1435,26 +1421,11 @@ func (m *Model) autoLoad(msg sourcesMsg) tea.Cmd {
return m.crawlStart()
}
// timeouts bound each provider request: its kind's, else the default.
type timeouts struct {
def time.Duration
byKind map[provider.Kind]time.Duration
}
// of is how long a request to a provider of kind k may take.
func (t timeouts) of(k provider.Kind) time.Duration {
if d, ok := t.byKind[k]; ok {
return d
}
return t.def
}
// begin starts a cancellable request for pane p to a provider of kind k,
// cancelling any in flight.
func (m *Model) begin(p slot, k provider.Kind) (context.Context, int) {
// begin starts a cancellable request for pane p, cancelling any in flight.
func (m *Model) begin(p slot) (context.Context, int) {
m.cancelReq(p)
m.seq++
ctx, cancel := context.WithTimeout(context.Background(), m.timeout.of(k))
ctx, cancel := context.WithTimeout(context.Background(), m.timeout)
m.reqID[p], m.cancel[p] = m.seq, cancel
return ctx, m.seq
}
@@ -1508,7 +1479,7 @@ func (m Model) sourcesCmd() tea.Cmd {
gen := m.sourcesGen
var cmds []tea.Cmd
for _, p := range m.providers {
ctx, cancel := context.WithTimeout(context.Background(), m.timeout.of(p.Kind()))
ctx, cancel := context.WithTimeout(context.Background(), m.timeout)
cmds = append(cmds, func() tea.Msg {
defer cancel()
s, err := p.Sources(ctx)
@@ -1519,7 +1490,7 @@ func (m Model) sourcesCmd() tea.Cmd {
})
// The source it started in, quickly, on its own.
if c, ok := p.(provider.Current); ok {
ctx, cancel := context.WithTimeout(context.Background(), m.timeout.of(p.Kind()))
ctx, cancel := context.WithTimeout(context.Background(), m.timeout)
cmds = append(cmds, func() tea.Msg {
defer cancel()
src, ok := c.Current(ctx)
@@ -1536,7 +1507,7 @@ func (m Model) sourcesCmd() tea.Cmd {
func (m *Model) loadContainers(src provider.Source, key string) tea.Cmd {
p := m.providerFor(src)
ctx, req := m.begin(slotContainers, src.Kind)
ctx, req := m.begin(slotContainers)
return m.settled(ctx, func() tea.Msg {
items, err := p.Containers(ctx, src)
// Carry the source as given, with its provider ID, so later
@@ -1555,7 +1526,7 @@ func (m *Model) loadContainers(src provider.Source, key string) tea.Cmd {
func (m *Model) loadObjects(c provider.Container, t provider.ObjectType, key string) tea.Cmd {
cands := m.candidates(c.Source)
known := len(cands) == 1 && cands[0].ID() == c.Source.Provider
ctx, req := m.begin(slotObjects, c.Source.Kind)
ctx, req := m.begin(slotObjects)
return m.settled(ctx, func() tea.Msg {
var errs []error
for _, p := range cands {
@@ -1583,7 +1554,7 @@ func (m *Model) loadObjects(c provider.Container, t provider.ObjectType, key str
func (m *Model) fetchValue(s provider.Object) tea.Cmd {
p := m.providerFor(s.Container.Source)
ctx, req := m.begin(slotValue, s.Container.Source.Kind)
ctx, req := m.begin(slotValue)
m.detail.SetLoading()
return func() tea.Msg {
v, err := p.Value(ctx, s)
@@ -1602,7 +1573,7 @@ func (m *Model) describe(o provider.Object) tea.Cmd {
m.detail.SetDescribing()
}
p := m.providerFor(o.Container.Source)
ctx, req := m.begin(slotDescribe, o.Container.Source.Kind)
ctx, req := m.begin(slotDescribe)
return m.settled(ctx, func() tea.Msg {
obj, err := p.Describe(ctx, o)
return describeMsg{req: req, key: key, obj: obj, err: err}
@@ -1630,7 +1601,7 @@ func (m *Model) copyValue() tea.Cmd {
return m.copyText(o.Name, v.Reveal(), true)
}
p := m.providerFor(o.Container.Source)
ctx, req := m.begin(slotCopy, o.Container.Source.Kind)
ctx, req := m.begin(slotCopy)
m.flash = flash{text: "copying " + o.Name + "…"}
return func() tea.Msg {
v, err := p.Value(ctx, o)
+1 -1
View File
@@ -165,7 +165,7 @@ func (m *Model) runCommand(c commands.Command, rendered string, obj *provider.Ob
o := *obj
p := m.providerFor(o.Container.Source)
m.flash = flash{text: "fetching " + o.Name + " for " + c.Description + "…"}
timeout := m.timeout.of(o.Container.Source.Kind)
timeout := m.timeout
m.usedObject(o)
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
+1 -1
View File
@@ -196,7 +196,7 @@ func (m *Model) loadDetails(c provider.Container) tea.Cmd {
}
}
m.details[key] = detailsState{loading: true}
ctx, req := m.begin(slotDetails, c.Source.Kind)
ctx, req := m.begin(slotDetails)
return m.settled(ctx, func() tea.Msg {
attrs, err := d.Details(ctx, c)
if ctx.Err() != nil {
+1 -1
View File
@@ -158,7 +158,7 @@ func (m *Model) copyFromRef(r ref.Ref) tea.Cmd {
m.flash = flash{text: "copy: no account or profile for " + describeRef(r), isErr: true}
return nil
}
ctx, req := m.begin(slotCopy, r.Kind)
ctx, req := m.begin(slotCopy)
m.flash = flash{text: "copying " + r.Name + "…"}
return func() tea.Msg {
var errs []error
+1 -2
View File
@@ -295,10 +295,10 @@ func (m *Model) crawlNext() tea.Cmd {
}
func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
timeout := m.timeout
if job.src != nil {
p := m.providerFor(*job.src)
src := *job.src
timeout := m.timeout.of(src.Kind)
return func() tea.Msg {
ctx, cancel := contextWithTimeout(timeout)
defer cancel()
@@ -311,7 +311,6 @@ func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
}
c := *job.container
p := m.providerFor(c.Source)
timeout := m.timeout.of(c.Source.Kind)
return func() tea.Msg {
ctx, cancel := contextWithTimeout(timeout)
defer cancel()
+4 -4
View File
@@ -38,7 +38,7 @@ type shareFilesMsg struct {
type sealedMsg struct {
link ref.Ref
n int
// from is the picker the key was picked in; nil for sharing.key's.
// from is the picker the key was picked in; nil for share.key's.
from *picker
err error
}
@@ -59,7 +59,7 @@ func (m *Model) shareFiles(src provider.Source, cs []provider.Container) tea.Cmd
m.flash = warning("these files can't be shared")
return nil
}
general, timeout := m.shareKey, m.timeout.of(src.Kind)
general, timeout := m.shareKey, m.timeout
m.flash = flash{text: "reading the files…"}
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
@@ -216,7 +216,7 @@ func (m *Model) handleSealed(msg sealedMsg) tea.Cmd {
func (m *Model) keyReader() func(ref.Ref) (provider.SecretValue, string, error) {
providers, st, timeout := m.providers, m.store, m.timeout
return func(key ref.Ref) (provider.SecretValue, string, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout.of(key.Kind))
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
k := key
k.Version = ""
@@ -258,7 +258,7 @@ func (m *Model) offerShare(r ref.Ref) tea.Cmd {
// openShare reads the key, decrypts the files and finds the repo's clone.
func (m *Model) openShare(s share.Sealed) tea.Cmd {
read := m.keyReader()
providers, timeout := m.providers, m.timeout.of(provider.KindRepo)
providers, timeout := m.providers, m.timeout
m.flash = flash{text: "decrypting with " + s.Key.Name + "…"}
return func() tea.Msg {
msg := shareOpenedMsg{sealed: s}
+3 -3
View File
@@ -50,7 +50,7 @@ func shareFrom(t *testing.T, root string, paste bool) string {
t.Fatalf("selected %v, want the repo", src.ID)
}
d.keys("S", "f")
// No sharing.key set: it asks which secret, in Go to, which lists
// No share.key set: it asks which secret, in Go to, which lists
// only secrets.
v := d.m.search
if v == nil || v.picker == nil {
@@ -137,7 +137,7 @@ func TestShareWithShareKeyConfirms(t *testing.T) {
}
root := cloneWith(t, map[string]string{
".env": "A=1\n",
".lazysecrets.yaml": "sharing:\n key: https://" + kv + "/secrets/db-password\n",
".lazysecrets.yaml": "share:\n key: https://" + kv + "/secrets/db-password\n",
})
clip := &fakeClipboard{}
d := newDriverOpts(t, Options{Clipboard: clip, ShareRules: demoRules}, fake.Azure(0), repo.New(repo.Settings{Dir: root}))
@@ -218,7 +218,7 @@ func TestShareInDemo(t *testing.T) {
}
// Too short a key: the picker stays, saying why.
d.send(tea.PasteMsg{Content: "https://kv-app-prod.vault.azure.net/secrets/db-password"})
if d.m.search == nil || !strings.Contains(d.m.search.picker.err, "Can't use db-password: its value has 28 characters; sharing.keyRules needs 32 (minLength)") {
if d.m.search == nil || !strings.Contains(d.m.search.picker.err, "Can't use db-password: its value has 28 characters; share.keyRules needs 32 (minLength)") {
t.Fatalf("not said in the picker: %v\n%s", d.m.search != nil, d.screen())
}
if !strings.Contains(d.screen(), "28 characters") {
-25
View File
@@ -1,25 +0,0 @@
package app
import (
"strings"
"testing"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
)
// A provider's own timeout applies to it alone.
func TestTimeoutPerProvider(t *testing.T) {
t.Parallel()
d := newDriverOpts(t, Options{Timeout: 5 * time.Second, Timeouts: map[provider.Kind]time.Duration{provider.KindAzure: 10 * time.Millisecond}},
fake.Azure(300*time.Millisecond), fake.Databricks(300*time.Millisecond))
scr := d.screen()
if !strings.Contains(scr, "✗ Azure: context deadline exceeded") || !strings.Contains(scr, "Databricks (…)") ||
strings.Contains(scr, "Databricks: context deadline") {
t.Errorf("want Azure timed out and Databricks still loading:\n%s", scr)
}
if got := (timeouts{def: time.Minute}).of(provider.KindRepo); got != time.Minute {
t.Errorf("no override: %s, want the default", got)
}
}
+4 -12
View File
@@ -20,8 +20,7 @@ type Updater struct {
}
// updateEvery is how often lazysecrets looks for a newer release on its
// own unless the config says otherwise; within that, the last answer (in
// the state file) is used.
// own; within that, the last answer (in the state file) is used.
const updateEvery = 24 * time.Hour
type updateMsg struct {
@@ -36,14 +35,14 @@ type installedMsg struct {
}
// startUpdateCheck sets up the startup look for a newer release: the
// state file's answer when it looked within updateEvery, else a quiet
// state file's answer when it looked less than a day ago, else a quiet
// look at the server (fetchOnStart, for Init).
func (m *Model) startUpdateCheck() {
if m.updater == nil || !m.updateCheck {
return
}
last := m.store.UpdateCheck()
if m.now().Sub(last.At) < m.updateEvery {
if m.now().Sub(last.At) < updateEvery {
m.offer(update.Release{Tag: last.Tag, Body: last.Notes, URL: last.URL})
return
}
@@ -110,22 +109,15 @@ func (m *Model) update() tea.Cmd {
func (m *Model) showUpdate() {
r := *m.newer
lines := []string{m.st.Muted.Render("You have " + m.versionLabel() + ".")}
if notes := r.Notes(m.version); notes != "" {
if notes := r.Notes(); notes != "" {
lines = append(lines, "")
for _, l := range strings.Split(notes, "\n") {
if h, ok := strings.CutPrefix(l, "### "); ok {
l = m.st.Label.Render(h)
} else if h, ok := strings.CutPrefix(l, "## "); ok { // a release, when several are new
l = m.st.TitleFocused.Render(h)
}
lines = append(lines, l)
}
}
// A popup doesn't scroll: several releases behind, the rest is on
// the release pages.
if most := max(6, m.h-12); len(lines) > most {
lines = append(lines[:most-1], m.st.Muted.Render("… and more on the release pages"))
}
opts := []option{{key: "u", label: "Update now", tip: "Download, check and replace this lazysecrets; restart to use it.",
run: func(m *Model) tea.Cmd { return m.install(r) }}}
if r.URL != "" {
-14
View File
@@ -83,12 +83,6 @@ func TestUpdate(t *testing.T) {
if f2.asked != 1 {
t.Errorf("asked %d times a day later", f2.asked)
}
// With autoCheckInterval: 1h, two hours on is long enough.
mem.SetUpdateCheck(store.UpdateCheck{At: time.Now().Add(-2 * time.Hour), Tag: "v0.2.0"})
newDriverOpts(t, Options{Version: "0.1.1", Updater: f2.updater(), UpdateCheck: true, UpdateEvery: time.Hour, Store: mem})
if f2.asked != 2 {
t.Errorf("asked %d times, want a look after the hour", f2.asked)
}
}
func TestUpdateAsked(t *testing.T) {
@@ -122,11 +116,3 @@ func TestUpdateAsked(t *testing.T) {
t.Errorf("U on a dev build:\n%s", dev.screen())
}
}
func TestStartupWarning(t *testing.T) {
t.Parallel()
d := newDriverOpts(t, Options{Warning: "theme: skipped unknown activeBorder"})
if !strings.Contains(d.screen(), "theme: skipped unknown activeBorder") {
t.Errorf("no warning:\n%s", d.screen())
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ import (
// Usage: revealing, copying, opening in the browser, sharing and jumping
// to an item count as using it. The store keeps when each item was last
// used and how often, for the Recent section of the jump list. The config's
// storage.history: false turns this off.
// history: false turns this off.
// used counts a use of the remembered item key, keeping its context.
func (m *Model) used(key string) {
+6 -6
View File
@@ -95,7 +95,7 @@ func (m *Map) Actions() []Action {
a(Lists, "pane3", &m.Pane3, "Secrets pane", "move", "Focus the Secrets pane."),
a(Lists, "pane4", &m.Pane4, "detail pane", "move", "Focus the detail pane."),
a(Lists, "pane0", &m.Pane0, "status", "move", "Focus the status pane: version and updates, accounts, config files, links and the changelog, on the right."),
a(Lists, "open", &m.Enter, "open", "move", "Go into the selected item: from a subscription to its vaults, from a vault to its secrets. On a provider header, fold or unfold it."),
a(Lists, "enter", &m.Enter, "open", "move", "Go into the selected item: from a subscription to its vaults, from a vault to its secrets. On a provider header, fold or unfold it."),
a(Lists, "back", &m.Back, "back", "move", "Go back: clear an applied filter, leave the versions view, or focus the previous pane."),
a(Lists, "filter", &m.Filter, "filter", "lists", "Filter the focused list by name, fuzzily: dbpw finds db-password. In Sources the provider and account match too."),
@@ -103,7 +103,7 @@ func (m *Map) Actions() []Action {
a(Lists, "nextTab", &m.NextTab, "next tab", "lists", "Next tab of the focused pane."),
a(Lists, "sourcesView", &m.SourcesView, "grouped/flat sources", "lists", "Switch the Sources pane between grouped by provider and a flat list."),
a(Lists, "star", &m.Favourite, "star", "lists", "Star or unstar the selected subscription, vault or secret, for the account showing it. Starred items get a ★ and sort first."),
a(Lists, "goTo", &m.Search, "search", "lists", "Go to anything: search every vault and secret loaded and jump to one. Again in Go to: only the source or group you're in, and back to all. On the left, the scope: All, an account (or Databricks, or Repos), or one source (○ ◐ ● for how much of it is loaded)."),
a(Lists, "search", &m.Search, "search", "lists", "Go to anything: search every vault and secret loaded and jump to one. Again in Go to: only the source or group you're in, and back to all. On the left, the scope: All, an account (or Databricks, or Repos), or one source (○ ◐ ● for how much of it is loaded)."),
a(Lists, "favourites", &m.Favourites, "favourites", "lists", "Go to, on Favourites: everything you've starred, to search and jump to."),
a(Lists, "recent", &m.Recent, "recent", "lists", "Go to, on Recent: what you used last, newest first."),
a(Lists, "commands", &m.Commands, "commands", "lists", "List the custom commands for the focused pane (customCommands in the config) and run one."),
@@ -116,7 +116,7 @@ func (m *Map) Actions() []Action {
a(Lists, "openInBrowser", &m.Open, "open in browser", "secrets", "Open the selected subscription, vault or item in the browser (Azure portal or Databricks workspace)."),
a(Lists, "edit", &m.Edit, "open file", "secrets", "Open the selected SOPS or .env file, or the file of the selected secret at its line; on the status pane, your config (made if it isn't there yet). SOPS files open with sops edit. Others open in the nvim lazysecrets runs in (quitting lazysecrets), else in $VISUAL or $EDITOR, else in the system's default app."),
a(Lists, "copyLink", &m.Share, "copy link", "share", "Copy a lazysecrets:// link to the selected subscription, workspace, vault, scope or secret, for sharing. From the versions view the link is pinned to that version."),
a(Lists, "share", &m.Share, "copy link", "share", "Copy a lazysecrets:// link to the selected subscription, workspace, vault, scope or secret, for sharing. From the versions view the link is pinned to that version."),
a(Lists, "shareMenu", &m.ShareMenu, "share", "share", "Choose what to share: the lazysecrets link, the plain Key Vault URL, the config reference, the web page, or env files as an encrypted link."),
a(Lists, "openLink", &m.OpenRef, "open link", "share", "Open a lazysecrets:// link, Key Vault URL or reference. Pasting one into lazysecrets does the same."),
@@ -184,13 +184,13 @@ var defaults = map[string][]string{
"lists.top": {"g", "home"}, "lists.bottom": {"G", "end"},
"lists.nextPane": {"tab"}, "lists.prevPane": {"shift+tab", "h", "left"}, "lists.details": {"l", "right"},
"lists.pane1": {"1"}, "lists.pane2": {"2"}, "lists.pane3": {"3"}, "lists.pane4": {"4"}, "lists.pane0": {"0"},
"lists.open": {"enter"}, "lists.back": {"esc", "backspace"},
"lists.enter": {"enter"}, "lists.back": {"esc", "backspace"},
"lists.filter": {"/"}, "lists.prevTab": {"["}, "lists.nextTab": {"]"}, "lists.sourcesView": {"`"},
"lists.star": {"f"}, "lists.favourites": {"F"}, "lists.recent": {"R"}, "lists.goTo": {"ctrl+s"}, "lists.note": {"n"},
"lists.star": {"f"}, "lists.favourites": {"F"}, "lists.recent": {"R"}, "lists.search": {"ctrl+s"}, "lists.note": {"n"},
"lists.commands": {"x"},
"lists.reveal": {"space"}, "lists.copyValue": {"y"}, "lists.copyReference": {"Y"},
"lists.versions": {"v"}, "lists.openInBrowser": {"o"}, "lists.edit": {"e"},
"lists.copyLink": {"s"}, "lists.shareMenu": {"S"}, "lists.openLink": {"ctrl+o"},
"lists.share": {"s"}, "lists.shareMenu": {"S"}, "lists.openLink": {"ctrl+o"},
"lists.dividerLeft": {"ctrl+left", "<"}, "lists.dividerRight": {"ctrl+right", ">"},
"lists.taller": {"ctrl+down", "}"}, "lists.shorter": {"ctrl+up", "{"},
"lists.zoomIn": {"+"}, "lists.zoomOut": {"_"}, "lists.resetLayout": {"="},
+8 -17
View File
@@ -45,9 +45,7 @@ var Roles = map[string]string{
}
// FromTheme returns the default styles with the theme's roles applied, and
// the default text colour if the theme sets one. Roles it doesn't know are
// skipped (see Unknown): a theme file made for a newer lazysecrets still
// works.
// the default text colour if the theme sets one.
func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
st := Default()
var fg color.Color
@@ -58,7 +56,7 @@ func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
sort.Strings(roles)
for _, role := range roles {
if _, ok := Roles[role]; !ok {
continue
return st, nil, fmt.Errorf("theme: unknown role %q (known: %s)", role, strings.Join(sortedRoles(), ", "))
}
v, err := parse(theme[role])
if err != nil {
@@ -109,20 +107,13 @@ func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
return st, fg, nil
}
// Unknown says which of the theme's roles aren't known, for a warning, or
// is empty.
func Unknown(theme map[string][]string) string {
var unknown []string
for r := range theme {
if _, ok := Roles[r]; !ok {
unknown = append(unknown, r)
}
func sortedRoles() []string {
var out []string
for r := range Roles {
out = append(out, r)
}
if len(unknown) == 0 {
return ""
}
slices.Sort(unknown)
return "theme: skipped unknown " + strings.Join(unknown, ", ") + " (a typo, or from a newer lazysecrets)"
slices.Sort(out)
return out
}
// value is a parsed role: a colour (nil for default) and attributes.
+3 -13
View File
@@ -44,29 +44,19 @@ func TestFromTheme(t *testing.T) {
func TestFromThemeRejectsMistakes(t *testing.T) {
for name, theme := range map[string]map[string][]string{
"role": {"activeBorder": {"red"}},
"colour": {"errorColor": {"redish"}},
"hex": {"errorColor": {"#fff"}},
"palette": {"errorColor": {"300"}},
} {
if _, _, err := FromTheme(theme); err == nil {
t.Errorf("%s: no error", name)
} else if name == "role" && !strings.Contains(err.Error(), "activeBorderColor") {
t.Errorf("unknown role error should list the known ones: %v", err)
}
}
}
func TestUnknownRolesSkipped(t *testing.T) {
theme := map[string][]string{"activeBorder": {"red"}, "errorColor": {"red"}}
if _, _, err := FromTheme(theme); err != nil {
t.Errorf("unknown role failed the theme: %v", err)
}
if w := Unknown(theme); !strings.Contains(w, "unknown activeBorder ") || strings.Contains(w, "errorColor") {
t.Errorf("warning = %q", w)
}
if w := Unknown(map[string][]string{"errorColor": {"red"}}); w != "" {
t.Errorf("warning for known roles: %q", w)
}
}
func TestEmptyThemeIsDefault(t *testing.T) {
st, fg, err := FromTheme(nil)
if err != nil || fg != nil || st.Selected.GetReverse() || st.Selected.GetBackground() != lipgloss.BrightBlack {
+14 -52
View File
@@ -28,7 +28,6 @@ import (
"path/filepath"
"regexp"
"runtime"
"slices"
"strings"
"time"
@@ -54,44 +53,18 @@ const maxDownload = 200 << 20
// Release is a published release.
type Release struct {
Tag string `json:"tag_name"`
// Body is the changelog section, then install notes. From Latest,
// it's the changelogs of the recent releases, each under a "## v…"
// heading, so someone a few versions behind sees all they missed.
Body string `json:"body"`
URL string `json:"html_url"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
Tag string `json:"tag_name"`
Body string `json:"body"` // the changelog section, then install notes
URL string `json:"html_url"`
}
// Notes is the changelog since the version current: of each release
// newer than it (just this one for a dev build), without the install
// notes every release ends with.
func (r Release) Notes(current string) string {
body, _, _ := strings.Cut(r.Body, "## Install")
var keep, headings []string
in := true // before any heading: a single release's notes
for _, l := range strings.Split(body, "\n") {
if tag, ok := strings.CutPrefix(l, "## "); ok && semver.IsValid(tag) {
in = Newer(current, tag) || tag == r.Tag
if in {
headings = append(headings, l)
}
}
if in {
keep = append(keep, l)
}
}
notes := strings.Join(keep, "\n")
if len(headings) == 1 { // the title names it
notes = strings.Replace(notes, headings[0], "", 1)
}
// Notes is the release's changelog: its body without the install notes
// every release ends with.
func (r Release) Notes() string {
notes, _, _ := strings.Cut(r.Body, "## Install")
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.
func PlainLinks(s string) string { return mdLink.ReplaceAllString(s, "$1") }
@@ -155,29 +128,18 @@ func (c *Client) latest(ctx context.Context, repo string) (Release, error) {
}
ctx, cancel := context.WithTimeout(ctx, latestTimeout)
defer cancel()
// The recent releases in one request (per_page on GitHub, limit on
// Gitea), for their changelogs.
b, err := c.get(ctx, fmt.Sprintf("%s/releases?per_page=%d&limit=%[2]d", api, recentReleases), 1<<20)
b, err := c.get(ctx, api+"/releases/latest", 1<<20)
if err != nil {
return Release{}, err
}
var all []Release
if err := json.Unmarshal(b, &all); err != nil {
return Release{}, fmt.Errorf("reading the releases from %s: %w", repo, err)
var r Release
if err := json.Unmarshal(b, &r); err != nil {
return Release{}, fmt.Errorf("reading the latest release from %s: %w", repo, err)
}
all = slices.DeleteFunc(all, func(r Release) bool { return r.Draft || r.Prerelease || !semver.IsValid(r.Tag) })
if len(all) == 0 {
return Release{}, fmt.Errorf("no releases on %s", repo)
if !semver.IsValid(r.Tag) {
return Release{}, fmt.Errorf("latest release %q on %s isn't a version", r.Tag, repo)
}
slices.SortFunc(all, func(a, b Release) int { return semver.Compare(b.Tag, a.Tag) })
var notes strings.Builder
for _, r := range all {
section, _, _ := strings.Cut(r.Body, "## Install")
fmt.Fprintf(&notes, "## %s\n\n%s\n\n", r.Tag, strings.TrimSpace(section))
}
latest := all[0]
latest.Body = strings.TrimSpace(notes.String())
return latest, nil
return r, nil
}
// Newer reports whether latest is newer than current. A build that isn't
+4 -35
View File
@@ -39,42 +39,11 @@ func TestNewer(t *testing.T) {
func TestNotes(t *testing.T) {
r := Release{Body: "### Added\n\n- Install with a one-liner (#7)\n\n## Install\n\nOn macOS…"}
if got := r.Notes("0.1.1"); got != "### Added\n\n- Install with a one-liner (#7)" {
if got := r.Notes(); got != "### Added\n\n- Install with a one-liner (#7)" {
t.Errorf("notes %q", got)
}
}
// Latest reads the recent releases, so the notes cover every version
// since the one running.
func TestNotesSince(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/repos/keb/lazysecrets/releases" {
http.NotFound(w, r)
return
}
fmt.Fprint(w, `[
{"tag_name":"v0.4.0-rc1","body":"### Added\n\n- rc","prerelease":true},
{"tag_name":"v0.2.0","body":"### Added\n\n- two\n\n## Install\n\ncurl"},
{"tag_name":"v0.3.0","body":"### Fixed\n\n- three\n\n## Install\n\ncurl","html_url":"https://example.com/v0.3.0"},
{"tag_name":"v0.1.1","body":"### Added\n\n- one"},
{"tag_name":"v9.0.0","body":"draft","draft":true}]`)
}))
t.Cleanup(srv.Close)
r, err := (&Client{Servers: []string{srv.URL + "/keb/lazysecrets"}}).Latest(context.Background())
if err != nil || r.Tag != "v0.3.0" || r.URL != "https://example.com/v0.3.0" {
t.Fatalf("latest %+v %v", r, err)
}
for current, want := range map[string]string{
"0.1.1": "## v0.3.0\n\n### Fixed\n\n- three\n\n## v0.2.0\n\n### Added\n\n- two",
"0.2.0": "### Fixed\n\n- three",
"dev-2ba762c": "### Fixed\n\n- three",
} {
if got := r.Notes(current); got != want {
t.Errorf("notes since %s: %q, want %q", current, got, want)
}
}
}
func TestManaged(t *testing.T) {
if Managed("/opt/homebrew/Cellar/lazysecrets/0.2.0/bin/lazysecrets") == "" || Managed("/nix/store/abc-lazysecrets/bin/lazysecrets") == "" {
t.Error("package managers not recognised")
@@ -101,8 +70,8 @@ func server(t *testing.T, tamper bool) *httptest.Server {
sum[0] ^= 0xff
}
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/keb/lazysecrets/releases", func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprint(w, `[{"tag_name":"v0.2.0","body":"### Added\n\n- x\n\n## Install\n","html_url":"https://example.com/r"}]`)
mux.HandleFunc("/api/v1/repos/keb/lazysecrets/releases/latest", func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprint(w, `{"tag_name":"v0.2.0","body":"### Added\n\n- x\n\n## Install\n","html_url":"https://example.com/r"}`)
})
mux.HandleFunc("/keb/lazysecrets/releases/download/v0.2.0/checksums.txt", func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprintf(w, "%s %s.tar.gz\n", hex.EncodeToString(sum[:]), name)
@@ -204,7 +173,7 @@ func TestInstallRefusesATamperedArchive(t *testing.T) {
func TestNotesShowLinksAsText(t *testing.T) {
r := Release{Body: "### Added\n\n- Search ([#11](https://git.example.com/pulls/11)) ([65dc2d7](https://git.example.com/commit/65dc2d7abc))\n\n## Install\n\ncurl …"}
if got, want := r.Notes("0.1.1"), "### Added\n\n- Search (#11) (65dc2d7)"; got != want {
if got, want := r.Notes(), "### Added\n\n- Search (#11) (65dc2d7)"; got != want {
t.Errorf("notes %q, want %q", got, want)
}
}