Files
lazysecrets/cmd/lazysecrets/main.go
T
kebandClaude Opus 5.5 036f32d9ac
CI / Checks (pull_request) Successful in 21s
PR title / Conventional title (pull_request) Successful in 2s
feat: a status pane, like lazygit's: version, updates, accounts, files and links
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 11:07:36 +02:00

405 lines
12 KiB
Go

// Command lazysecrets is a terminal UI for browsing secrets in Azure Key
// Vault and Databricks secret scopes.
package main
import (
"context"
"flag"
"fmt"
"io"
"log"
"os"
"path/filepath"
"slices"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/commands"
"git.kebcloud.no/keb/lazysecrets/internal/config"
"git.kebcloud.no/keb/lazysecrets/internal/instance"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/azure"
"git.kebcloud.no/keb/lazysecrets/internal/provider/databricks"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store"
"git.kebcloud.no/keb/lazysecrets/internal/ui/app"
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "lazysecrets:", err)
os.Exit(1)
}
}
// looksLikeRef tells a reference given without "open" from a mistyped
// subcommand, so the latter still gets the usage text.
func looksLikeRef(s string) bool {
return strings.Contains(s, "://") || strings.HasPrefix(s, "@Microsoft.KeyVault(") || strings.HasPrefix(s, "{{secrets/")
}
// starredRepos are the repos the user starred something in: the repo, a
// file or a key. Their records keep the repo's folder.
func starredRepos(st *store.Store) []string {
var out []string
for key, n := range st.Nodes() {
r, err := ref.Parse(key)
if err == nil && r.Kind == provider.KindRepo && len(n.StarredBy) > 0 && n.Source != "" && !slices.Contains(out, n.Source) {
out = append(out, n.Source)
}
}
slices.Sort(out)
return out
}
// checkCommandKeys reports a custom command key that a built-in action or
// another command in an overlapping context takes.
func checkCommandKeys(km *keys.Map, cmds []commands.Command) error {
for i, c := range cmds {
if c.Key == "" {
continue
}
name := fmt.Sprintf("custom command %q", cmpOr(c.Description, c.Command))
if err := km.CheckExtra(c.Key, name); err != nil {
return err
}
for _, d := range cmds[:i] {
overlap := c.Context == d.Context || c.Context == commands.Global || d.Context == commands.Global
if d.Key == c.Key && overlap {
return fmt.Errorf("%s: %q is also the key of custom command %q", name, c.Key, cmpOr(d.Description, d.Command))
}
}
}
return nil
}
func cmpOr(a, b string) string {
if a != "" {
return a
}
return b
}
// defaultConfigPath is $LAZYSECRETS_CONFIG_FILE, else config.yaml in the
// config directory.
func defaultConfigPath() string {
if env := os.Getenv("LAZYSECRETS_CONFIG_FILE"); env != "" {
return env
}
dir, err := config.Dir()
if err != nil {
return "config.yaml"
}
return filepath.Join(dir, "config.yaml")
}
func run() error {
configPath := flag.String("config", defaultConfigPath(),
"config file; several, comma-separated, are layered in order (also LAZYSECRETS_CONFIG_FILE)")
showVersion := flag.Bool("version", false, "print the version and exit")
dir := flag.String("dir", "", "start in this directory: its git repo's SOPS files, the ones under it first (default: the current directory)")
demo := flag.Bool("demo", false, "browse built-in sample data instead of real backends")
timeout := flag.Duration("timeout", 30*time.Second, "timeout for each backend request")
icons := flag.Bool("icons", true,
"show Nerd Font icons (needs a Nerd Font v3+); disable with --icons=false or LAZYSECRETS_ICONS=0")
clipClear := flag.Duration("clipboard-clear", 30*time.Second, "clear copied values from the clipboard after this long (0 disables)")
flag.Usage = func() {
fmt.Fprintf(flag.CommandLine.Output(), `Usage:
lazysecrets [flags] browse secrets
lazysecrets [flags] [open] <ref>
open a lazysecrets:// link, Key Vault URL,
@Microsoft.KeyVault(...) or {{secrets/scope/key}}
lazysecrets [flags] get <ref> print the value a lazysecrets:// link points at,
for scripts: VAR=$(lazysecrets get <ref>)
lazysecrets [flags] keybindings print every action and its keys (with your
config applied), as config to copy and edit
lazysecrets handler install [--terminal NAME]
open lazysecrets:// links from the browser and
chat in a new window of this terminal
lazysecrets handler uninstall stop opening them
lazysecrets launch <ref> open a link in a new terminal window, as
the link handler does
lazysecrets update [--check] install the newest release in place of this
one (--check: just say if there is one)
Flags:
`)
flag.PrintDefaults()
}
flag.Parse()
if *showVersion {
fmt.Println("lazysecrets", versionString())
return nil
}
var open, getRef *ref.Ref
printKeys := false
switch args := flag.Args(); {
case len(args) == 1 && args[0] == "keybindings":
printKeys = true
case len(args) == 2 && args[0] == "get":
r, err := ref.Parse(args[1])
if err != nil {
return err
}
getRef = &r
case len(args) > 0 && args[0] == "handler":
return handler(*configPath, args[1:])
case len(args) > 0 && args[0] == "launch":
return launch(*configPath, args[1:])
case len(args) > 0 && args[0] == "update":
return updateCmd(args[1:])
case len(args) == 0:
case args[0] == "open" && len(args) == 2:
r, err := ref.Parse(args[1])
if err != nil {
return err
}
open = &r
case len(args) == 1 && looksLikeRef(args[0]):
// lazysecrets <link>, like lazysecrets open <link>
r, err := ref.Parse(args[0])
if err != nil {
return err
}
open = &r
default:
flag.Usage()
return fmt.Errorf("unexpected arguments: %v", args)
}
cfg, err := config.LoadAll(strings.Split(*configPath, ","))
if err != nil {
return err
}
theme, defaultFg, err := styles.FromTheme(cfg.Theme)
if err != nil {
return err
}
keymap := keys.Default()
if err := keymap.Apply(cfg.Keybinding); err != nil {
return err
}
if err := checkCommandKeys(&keymap, cfg.CustomCommands); err != nil {
return err
}
if printKeys {
fmt.Print(keymap.YAML())
return nil
}
// Precedence: flag > environment > config file > default.
set := map[string]bool{}
flag.Visit(func(f *flag.Flag) { set[f.Name] = true })
if !set["timeout"] && cfg.Timeout > 0 {
*timeout = cfg.Timeout
}
if !set["clipboard-clear"] && cfg.ClipboardClear != nil {
*clipClear = *cfg.ClipboardClear
}
expiryDays := 30
if cfg.ExpiryWarningDays != nil {
expiryDays = *cfg.ExpiryWarningDays
}
revealTimeout := 60 * time.Second
if cfg.RevealTimeout != nil {
revealTimeout = *cfg.RevealTimeout
}
loadDelay := 150 * time.Millisecond
if cfg.LoadDelay != nil {
loadDelay = *cfg.LoadDelay
}
if !set["icons"] {
switch {
case os.Getenv("LAZYSECRETS_ICONS") != "":
*icons = os.Getenv("LAZYSECRETS_ICONS") != "0"
case cfg.Icons != nil:
*icons = *cfg.Icons
}
}
// Stars, notes and the layout. Demo mode keeps them in memory, so
// sample vaults never end up in the real file.
stateDir, err := config.StateDir()
if err != nil {
return err
}
statePath := ""
if !*demo {
statePath = filepath.Join(stateDir, "state.json")
}
st, err := store.Load(statePath)
if err != nil {
return err
}
var providers []provider.Provider
if *demo {
// Two Azure accounts, like a personal and an admin login in az.
providers = []provider.Provider{
fake.Azure(400*time.Millisecond).As("Azure:dev@example.com/demo", "Azure · dev"),
fake.AzureOps(700*time.Millisecond).As("Azure:ops@example.com/demo", "Azure · ops"),
fake.Databricks(600 * time.Millisecond),
}
} else {
pc := cfg.Providers
// One provider per az account. Created even when disabled: AKV-
// backed Databricks scopes fall back to reading the vault through
// the default account.
accounts, err := azure.NewAccounts(azure.Settings{
HideSubscriptions: pc.Azure.HideSubscriptions,
Vaults: pc.Azure.Vaults,
})
if err != nil {
return fmt.Errorf("azure: %w", err)
}
if config.On(pc.Azure.Enabled) {
for _, a := range accounts {
providers = append(providers, a)
}
}
if config.On(pc.Databricks.Enabled) {
dbx, err := databricks.New(accounts[0], pc.Databricks.HideProfiles)
if err != nil {
return fmt.Errorf("databricks: %w", err)
}
providers = append(providers, dbx)
}
if config.On(pc.Repos.Enabled) {
start := *dir
if start == "" {
start, _ = os.Getwd()
}
roots := []string{"~/git"}
if pc.Repos.Roots != nil {
roots = *pc.Repos.Roots
}
repos := repo.New(repo.Settings{
Dir: start, Paths: pc.Repos.Paths, Roots: roots, Starred: starredRepos(st),
Include: pc.Repos.Include, Exclude: pc.Repos.Exclude,
})
// The repo it started in (shown on top as Repo) and the
// others, checked for secret files in the background like
// the other sources; repos without any aren't listed.
providers = append(providers, repos)
}
if len(providers) == 0 {
return fmt.Errorf("all providers are disabled in %s", *configPath)
}
}
// Libraries (notably the Databricks SDK) log token refreshes through
// the standard logger, which would draw over the TUI.
log.SetOutput(io.Discard)
if getRef != nil {
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
return get(ctx, providers, st, *getRef)
}
// Links opened elsewhere (lazysecrets launch) can come here.
var p *tea.Program
var srv *instance.Server
if !*demo {
srv, err = instance.Listen(instance.Dir(stateDir), func(link, front string) error {
r, err := ref.Parse(link)
if err != nil {
return err
}
// Accept at once: launch waits for the answer, and opens a
// window anyway if it's slow. Coming to the front can be
// (activating the terminal app).
go func() {
p.Send(app.OpenLink(r))
instance.Raise(front)
}()
return nil
})
if err == nil {
defer srv.Close()
} // else links open in new windows: not worth failing for
}
var setup *app.HandlerSetup
if !*demo {
setup = handlerSetup(cfg, *configPath)
}
onActive := func() {}
if srv != nil {
onActive = srv.Touch
}
// What was loaded last time, and where it was left in this folder.
var cacheFile, placeDir string
if !*demo {
if d, err := config.CacheDir(); err == nil && config.On(cfg.Cache) {
cacheFile = filepath.Join(d, "cache.json")
}
placeDir = *dir
if placeDir == "" {
placeDir, _ = os.Getwd()
}
if abs, err := filepath.Abs(placeDir); err == nil {
placeDir = abs
}
}
m := app.New(providers, app.Options{
Timeout: *timeout,
Icons: *icons,
ClipboardClear: *clipClear,
FlatSources: cfg.SourcesView == "flat",
CollapseGroups: config.On(cfg.CollapseGroups),
LoadDelay: loadDelay,
NoHistory: !config.On(cfg.History),
OnActive: onActive,
RevealTimeout: revealTimeout,
ExpiryWarning: time.Duration(expiryDays) * 24 * time.Hour,
HideOnBlur: config.On(cfg.HideOnBlur),
HandlerSetup: setup,
Updater: updater(),
UpdateCheck: config.On(cfg.UpdateCheck),
Keys: &keymap,
Commands: cfg.CustomCommands,
Version: versionString(),
Store: st,
CacheFile: cacheFile,
ConfigFiles: configFiles(*configPath),
StatePath: statePath,
Dir: placeDir,
Open: open,
Styles: &theme,
DefaultFg: defaultFg,
})
p = tea.NewProgram(m)
final, err := p.Run()
if fm, ok := final.(app.Model); ok {
_ = fm.Persist() // a cache it couldn't save is no reason to fail
}
return err
}
// configFiles are the config files given, for the status pane: those
// that exist, or the first if none does (where to create one).
func configFiles(list string) []string {
var all, found []string
for _, f := range strings.Split(list, ",") {
if f = strings.TrimSpace(f); f == "" {
continue
}
all = append(all, f)
if _, err := os.Stat(f); err == nil {
found = append(found, f)
}
}
if len(found) == 0 && len(all) > 0 {
return all[:1]
}
return found
}