Files
f.weber 333c7468e9
Test / testing (push) Successful in 5m30s
Release / build-and-upload (release) Failing after 9m9s
feat: add Homebrew and Scoop publishing scripts, enhance versioning metadata
2026-08-14 11:35:52 +02:00

235 lines
7.6 KiB
Go

// Package cli wires urfave/cli/v3 commands around internal/config,
// internal/vault, internal/token, and internal/auth so every operation
// (login, list, read, write, status, logout, and the "ui" command that
// launches the Bubbletea TUI) is available both from the terminal directly
// and, unchanged, from inside the TUI's screens.
package cli
import (
"context"
"fmt"
"os"
"github.com/hashicorp/vault/api"
"github.com/urfave/cli/v3"
"git.morlana.online/f.weber/vault-tui/internal/auth"
"git.morlana.online/f.weber/vault-tui/internal/config"
"git.morlana.online/f.weber/vault-tui/internal/token"
vaultsvc "git.morlana.online/f.weber/vault-tui/internal/vault"
)
// App is the resolved, shared state every command's Action reads from. It
// is built once in the root Command's Before hook and threaded through
// context.Context (see appKey), which is the idiomatic urfave/cli/v3
// pattern since BeforeFunc returns the (possibly-derived) context that
// subsequent Action funcs receive.
type App struct {
ConfigPath string
File *config.File
Settings *config.Settings
Client *api.Client // unauthenticated until EnsureLoggedIn/SetToken
Store token.Store
// Overrides is the flag layer bootstrap resolved Settings with. Kept
// around so SwitchProfile can re-run config.Resolve for a different
// profile with identical flag/env precedence.
Overrides config.Overrides
}
type appKeyType struct{}
var appKey = appKeyType{}
func appFrom(ctx context.Context) (*App, error) {
a, ok := ctx.Value(appKey).(*App)
if !ok || a == nil {
return nil, fmt.Errorf("internal error: app not initialized")
}
return a, nil
}
// overridesFromCommand reads the global flags declared in root.go into a
// config.Overrides — the "flag" layer of config.Resolve's precedence chain.
func overridesFromCommand(cmd *cli.Command) config.Overrides {
ov := config.Overrides{
Profile: cmd.String("profile"),
Address: cmd.String("address"),
Namespace: cmd.String("namespace"),
Token: cmd.String("token"),
CACert: cmd.String("ca-cert"),
ClientCert: cmd.String("client-cert"),
ClientKey: cmd.String("client-key"),
NoEnv: cmd.Bool("no-env"),
NoColor: cmd.Bool("no-color"),
}
if cmd.IsSet("tls-skip-verify") {
v := cmd.Bool("tls-skip-verify")
ov.SkipVerify = &v
}
if cmd.IsSet("read-only") {
v := cmd.Bool("read-only")
ov.ReadOnly = &v
} else if cmd.IsSet("write") {
v := !cmd.Bool("write")
ov.ReadOnly = &v
}
return ov
}
// bootstrap builds an *App from global flags: loads the config file,
// resolves Settings, and builds an (unauthenticated) *api.Client. It does
// NOT resolve or attach a token — see EnsureLoggedIn, which most commands
// call explicitly so that `vault-tui config init` and similar can run
// before any Vault connectivity exists.
func bootstrap(cmd *cli.Command) (*App, error) {
path, err := config.ResolvePath(cmd.String("config"))
if err != nil {
return nil, err
}
// First-run UX: if there is genuinely no config yet, an interactive
// session gets an inline wizard instead of a wall of "no profile
// configured" errors — but never for `config ...` itself (that
// subtree has its own explicit `init`), never for `version` (which
// shouldn't need a Vault connection at all), and never in a
// non-interactive context (CI, pipes), where the tool must stay
// driven entirely by flags/env instead of blocking on stdin.
leaf := cmd.Args().First()
if !config.Exists(path) && IsInteractive() && leaf != "config" && leaf != "version" {
if _, err := runWizard(path); err != nil {
return nil, err
}
}
file, err := config.Load(path)
if err != nil {
return nil, err
}
ov := overridesFromCommand(cmd)
settings, err := config.Resolve(file, ov.Profile, ov, config.OSEnviron)
if err != nil {
return nil, err
}
client, err := vaultsvc.NewClient(settings)
if err != nil {
return nil, err
}
store, err := storeFor(settings)
if err != nil {
return nil, err
}
return &App{ConfigPath: path, File: file, Settings: settings, Client: client, Store: store, Overrides: ov}, nil
}
// SwitchProfile re-resolves Settings for a different profile using the same
// flag/env overrides bootstrap ran with, and rebuilds Client and Store to
// match — what lets the TUI's profile picker switch profiles in place
// instead of requiring a restart. The caller is responsible for driving a
// fresh login afterwards; this does not touch authentication state beyond
// replacing the (unauthenticated) Client.
func (a *App) SwitchProfile(profile string) error {
ov := a.Overrides
ov.Profile = profile
settings, err := config.Resolve(a.File, profile, ov, config.OSEnviron)
if err != nil {
return err
}
client, err := vaultsvc.NewClient(settings)
if err != nil {
return err
}
store, err := storeFor(settings)
if err != nil {
return err
}
a.Settings, a.Client, a.Store = settings, client, store
return nil
}
func storeFor(s *config.Settings) (token.Store, error) {
switch s.Token.Storage {
case "none":
return token.NewNoneStore(), nil
case "profile":
if s.Token.File != "" {
return token.NewFileStore(expandHome(s.Token.File)), nil
}
dir, err := config.StateDir()
if err != nil {
return nil, err
}
return token.NewProfileStore(dir, s.Profile), nil
default: // "vault-cli" or unset
return token.NewVaultCLIStore()
}
}
func expandHome(p string) string {
if len(p) >= 2 && p[:2] == "~/" {
if home, err := os.UserHomeDir(); err == nil {
return home + p[1:]
}
}
return p
}
// EnsureLoggedIn resolves a token via internal/token.Resolve (flag > env >
// config > store), validates it with token.Lookup, and sets it on a.Client.
// If no token can be resolved at all, it returns a plain error telling the
// caller to run `vault-tui login` — headless commands never block on stdin
// here; interactive login is `login`'s job, not every command's.
func (a *App) EnsureLoggedIn(ctx context.Context, flagToken string) (*token.Info, error) {
envTok, _ := config.OSEnviron(config.EnvToken)
resolved, err := token.Resolve(ctx, token.Options{
Flag: flagToken,
Env: envTok,
Profile: a.File.Profiles[a.Settings.Profile],
Store: a.Store,
})
if err != nil {
return nil, err
}
if resolved.Token == "" {
return nil, fmt.Errorf("no Vault token found (checked --token, VAULT_TOKEN, and %s) — run `vault-tui login`", a.Store.Kind())
}
a.Client.SetToken(resolved.Token)
info, err := token.Lookup(ctx, a.Client)
if err != nil {
a.Client.ClearToken()
return nil, fmt.Errorf("resolved a token via %s but Vault rejected it: %w", resolved.Origin, err)
}
return info, nil
}
// Service builds the KV-facing Service around an already-authenticated
// client. Call after EnsureLoggedIn.
func (a *App) Service() *vaultsvc.Service {
return vaultsvc.New(a.Client, a.Settings.ReadOnly)
}
// Method resolves an auth.Method by name, special-casing "oidc" so that
// per-profile OIDC settings (port, callback host, timeouts — see
// config.OIDCOpts) are honoured even though the globally registered "oidc"
// entry (for picker/help listing) only carries library defaults.
func (a *App) Method(name string) (auth.Method, error) {
if name == "" {
name = a.Settings.Auth.Method
}
if name == "" {
return nil, fmt.Errorf("no auth method configured for profile %q (set auth.method or pass -method)", a.Settings.Profile)
}
if name == "oidc" {
return a.oidcMethod(), nil
}
m, ok := auth.Default().Get(name)
if !ok {
if reason, unavail := auth.Default().Unavailable(name); unavail {
return nil, fmt.Errorf("auth method %q is unavailable in this build: %s", name, reason)
}
return nil, fmt.Errorf("unknown auth method %q", name)
}
return m, nil
}