// 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`) 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" { 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 }