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