feat(vault-tui): implement KV client and service for managing secrets

- Added internal/vault/client.go for creating a Vault client with configuration settings.
- Introduced internal/vault/errors.go to classify Vault API errors for better UI handling.
- Created internal/vault/kv.go to manage KV secrets, including listing, reading, writing, and deleting operations.
- Implemented internal/vault/mounts.go to list and describe secret engine mounts.
- Developed internal/vault/service.go to provide a unified entry point for Vault operations.
- Added internal/vault/kv_test.go for comprehensive testing of KV operations.
- Introduced internal/ui/toast.go for transient notifications in the UI.
- Added renovate.json for dependency management and updates.
This commit is contained in:
2026-08-14 11:09:03 +02:00
commit ae30ba1240
85 changed files with 9413 additions and 0 deletions
+233
View File
@@ -0,0 +1,233 @@
// 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
}
+84
View File
@@ -0,0 +1,84 @@
package cli
import (
"context"
"fmt"
"os"
"github.com/urfave/cli/v3"
"git.morlana.online/f.weber/vault-tui/internal/config"
)
func configCommand() *cli.Command {
return &cli.Command{
Name: "config",
Usage: "manage the vault-tui config file",
Commands: []*cli.Command{
{
Name: "init",
Usage: "interactively create a new config file",
Action: func(ctx context.Context, cmd *cli.Command) error {
a, err := appFrom(ctx)
if err != nil {
return err
}
if config.Exists(a.ConfigPath) && !askOverwrite(a.ConfigPath) {
return nil
}
if !IsInteractive() {
return fmt.Errorf("config init requires an interactive terminal")
}
_, err = runWizard(a.ConfigPath)
return err
},
},
{
Name: "path",
Usage: "print the resolved config file path",
Action: func(ctx context.Context, cmd *cli.Command) error {
a, err := appFrom(ctx)
if err != nil {
return err
}
fmt.Println(a.ConfigPath)
return nil
},
},
{
Name: "show",
Usage: "print the fully resolved settings for the active profile",
Flags: []cli.Flag{&cli.BoolFlag{Name: "json"}},
Action: func(ctx context.Context, cmd *cli.Command) error {
a, err := appFrom(ctx)
if err != nil {
return err
}
if cmd.Bool("json") {
return printJSON(os.Stdout, a.Settings)
}
s := a.Settings
out := map[string]string{
"profile": s.Profile,
"address": s.Address,
"namespace": s.Namespace,
"read_only": fmt.Sprint(s.ReadOnly),
"auth.method": s.Auth.Method,
"auth.mount": s.Auth.Mount,
"tls.ca_cert": s.CACert,
"tls.skip_verify": fmt.Sprint(s.SkipVerify),
}
printKV(os.Stdout, out)
return nil
},
},
},
}
}
func askOverwrite(path string) bool {
fmt.Fprintf(os.Stderr, "%s already exists. Overwrite? (y/N): ", path)
var resp string
fmt.Fscanln(os.Stdin, &resp)
return resp == "y" || resp == "yes"
}
+59
View File
@@ -0,0 +1,59 @@
package cli
import (
"context"
"fmt"
"github.com/urfave/cli/v3"
"git.morlana.online/f.weber/vault-tui/internal/vault"
)
func deleteCommand() *cli.Command {
return &cli.Command{
Name: "delete",
Usage: "delete a secret (soft-delete for KV v2; irreversible for KV v1/cubbyhole)",
ArgsUsage: "<path>",
Flags: []cli.Flag{
&cli.BoolFlag{Name: "destroy", Usage: "KV v2: permanently destroy instead of soft-delete (irreversible)"},
&cli.BoolFlag{Name: "metadata", Usage: "KV v2: delete all versions and metadata (irreversible)"},
&cli.IntSliceFlag{Name: "versions", Usage: "KV v2: specific versions to target (default: current)"},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
a, err := appFrom(ctx)
if err != nil {
return err
}
if cmd.Args().Len() == 0 {
return fmt.Errorf("usage: vault-tui delete <path>")
}
if _, err := a.EnsureLoggedIn(ctx, cmd.String("token")); err != nil {
return err
}
svc := a.Service()
if svc.ReadOnly {
return fmt.Errorf("refusing to delete: vault-tui is in read-only mode (pass --write to override)")
}
mount, rel, err := splitMountPath(ctx, svc, cmd.Args().First())
if err != nil {
return err
}
op := vault.OpSoftDelete
if mount.Kind != vault.EngineKVv2 {
op = vault.OpDeleteV1
} else if cmd.Bool("metadata") {
op = vault.OpDeleteMetadata
} else if cmd.Bool("destroy") {
op = vault.OpDestroy
}
ack, err := svc.KV.Delete(ctx, mount, rel, op, cmd.IntSlice("versions"))
if err != nil {
return err
}
fmt.Printf("deleted %s (op=%v)\n", ack.Path, ack.Op)
return nil
},
}
}
+39
View File
@@ -0,0 +1,39 @@
package cli
import (
"context"
"fmt"
"strings"
"git.morlana.online/f.weber/vault-tui/internal/vault"
)
// splitMountPath finds which configured mount a full logical path like
// "secret/team/prod/db" belongs to, and returns the mount plus the
// remaining path relative to it ("team/prod/db"). Mirrors how `vault kv`
// itself resolves a bare path against sys/internal/ui/mounts.
func splitMountPath(ctx context.Context, svc *vault.Service, full string) (vault.Mount, string, error) {
full = strings.TrimPrefix(full, "/")
mounts, err := svc.Mounts(ctx)
if err != nil {
return vault.Mount{}, "", fmt.Errorf("listing mounts: %w", err)
}
var best vault.Mount
for _, m := range mounts {
mp := strings.TrimSuffix(m.Path, "/")
if full == mp || strings.HasPrefix(full, mp+"/") {
if len(mp) > len(strings.TrimSuffix(best.Path, "/")) {
best = m
}
}
}
if best.Path == "" {
return vault.Mount{}, "", fmt.Errorf("no configured mount matches %q", full)
}
if !best.Supported() {
return vault.Mount{}, "", fmt.Errorf("mount %q (type %s) is not supported by vault-tui", best.Path, best.Type)
}
rel := strings.TrimPrefix(full, strings.TrimSuffix(best.Path, "/"))
rel = strings.TrimPrefix(rel, "/")
return best, rel, nil
}
+50
View File
@@ -0,0 +1,50 @@
package cli
import (
"context"
"fmt"
"os"
"github.com/urfave/cli/v3"
)
func listCommand() *cli.Command {
return &cli.Command{
Name: "list",
Aliases: []string{"ls"},
Usage: "list secrets under a path (e.g. `vault-tui list secret/team/`)",
ArgsUsage: "<path>",
Flags: []cli.Flag{&cli.BoolFlag{Name: "json"}},
Action: func(ctx context.Context, cmd *cli.Command) error {
a, err := appFrom(ctx)
if err != nil {
return err
}
if cmd.Args().Len() == 0 {
return fmt.Errorf("usage: vault-tui list <path>")
}
if _, err := a.EnsureLoggedIn(ctx, cmd.String("token")); err != nil {
return err
}
svc := a.Service()
mount, rel, err := splitMountPath(ctx, svc, cmd.Args().First())
if err != nil {
return err
}
l, err := svc.KV.List(ctx, mount, rel)
if err != nil {
return err
}
if cmd.Bool("json") {
return printJSON(os.Stdout, l)
}
for _, d := range l.Dirs {
fmt.Println(d)
}
for _, f := range l.Leaves {
fmt.Println(f)
}
return nil
},
}
}
+110
View File
@@ -0,0 +1,110 @@
package cli
import (
"context"
"fmt"
"os"
"github.com/urfave/cli/v3"
"git.morlana.online/f.weber/vault-tui/internal/auth"
)
func loginCommand() *cli.Command {
return &cli.Command{
Name: "login",
Usage: "authenticate to Vault and save the resulting token",
ArgsUsage: "[key=value ...]",
Flags: []cli.Flag{
&cli.StringFlag{Name: "method", Usage: "auth method name (default: profile's auth.method)"},
&cli.StringFlag{Name: "mount", Usage: "auth mount path (default: profile's auth.mount, or the method's default)"},
&cli.BoolFlag{Name: "no-store", Usage: "print the token instead of saving it"},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
a, err := appFrom(ctx)
if err != nil {
return err
}
m, err := a.Method(cmd.String("method"))
if err != nil {
return err
}
mount := cmd.String("mount")
if mount == "" {
mount = a.Settings.Auth.Mount
}
if mount == "" {
mount = m.DefaultMount()
}
cliArgs, err := parseKVArgs(cmd.Args().Slice())
if err != nil {
return err
}
creds := auth.Prefill(m, auth.PrefillSource{
CLIArgs: cliArgs,
ConfigArgs: a.Settings.Auth.Params,
})
if missing := auth.Missing(m, creds); len(missing) > 0 {
if !IsInteractive() {
names := make([]string, 0, len(missing))
for _, f := range missing {
names = append(names, f.Name)
}
return fmt.Errorf("missing required credentials for method %q: %v (set via VAULT_TUI_AUTH_<NAME>, a method-specific env var, key=value args, or run interactively)", m.Name(), names)
}
if err := promptMissing(m, creds); err != nil {
return err
}
}
if err := auth.Validate(m, creds); err != nil {
return err
}
events := make(chan auth.Event, 16)
go drainEvents(events)
req := auth.Request{Mount: mount, Namespace: a.Settings.Namespace, Creds: creds, Events: events}
sec, err := m.Login(ctx, a.Client, req)
close(events)
if err != nil {
return fmt.Errorf("login failed: %w", err)
}
result, err := auth.NewResult(sec, a.Settings.Namespace)
if err != nil {
return err
}
if cmd.Bool("no-store") {
fmt.Println(result.Token)
return nil
}
if err := a.Store.Store(ctx, result.Token); err != nil {
return fmt.Errorf("saving token to %s: %w", a.Store.Location(), err)
}
fmt.Fprintf(os.Stderr, "Success! Token saved via %s (%s).\n", a.Store.Kind(), a.Store.Location())
if result.TTL > 0 {
fmt.Fprintf(os.Stderr, "token ttl: %s policies: %v\n", result.TTL, result.Policies)
}
return nil
},
}
}
// drainEvents prints login progress (the OIDC URL, Okta push prompts, ...)
// to stderr as it arrives. Used by every headless command that can trigger
// a multi-step login; the TUI instead pumps these into its own model (see
// internal/ui/app — not yet wired here).
func drainEvents(events <-chan auth.Event) {
for e := range events {
switch e.Kind {
case auth.EventOpenURL:
fmt.Fprintf(os.Stderr, "\nOpen this URL to continue:\n\n %s\n\n", e.URL)
case auth.EventWarning:
fmt.Fprintf(os.Stderr, "warning: %s\n", e.Message)
default:
fmt.Fprintf(os.Stderr, "%s\n", e.Message)
}
}
}
+46
View File
@@ -0,0 +1,46 @@
package cli
import (
"context"
"fmt"
"os"
"github.com/urfave/cli/v3"
"git.morlana.online/f.weber/vault-tui/internal/token"
)
func logoutCommand() *cli.Command {
return &cli.Command{
Name: "logout",
Usage: "forget the saved token (does not revoke it in Vault unless -revoke is given)",
Flags: []cli.Flag{
&cli.BoolFlag{Name: "revoke", Usage: "also revoke the token in Vault"},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
a, err := appFrom(ctx)
if err != nil {
return err
}
revoke := cmd.Bool("revoke")
if p := a.File.Profiles[a.Settings.Profile]; p != nil && p.Token.RevokeOnLogout != nil {
revoke = revoke || *p.Token.RevokeOnLogout
}
if revoke {
if _, err := a.EnsureLoggedIn(ctx, cmd.String("token")); err != nil {
fmt.Fprintf(os.Stderr, "warning: could not verify token before revoking: %v\n", err)
}
if err := token.RevokeSelf(ctx, a.Client, a.Store); err != nil {
return fmt.Errorf("revoking token: %w", err)
}
fmt.Println("Token revoked and forgotten.")
return nil
}
if err := a.Store.Erase(ctx); err != nil {
return fmt.Errorf("erasing token from %s: %w", a.Store.Location(), err)
}
fmt.Println("Token forgotten (not revoked in Vault).")
return nil
},
}
}
+47
View File
@@ -0,0 +1,47 @@
package cli
import (
"git.morlana.online/f.weber/vault-tui/internal/auth"
"git.morlana.online/f.weber/vault-tui/internal/auth/oidc"
)
// init registers a default-configured OIDC method so it appears in
// auth.Default() for listing/help purposes. app.Method special-cases
// "oidc" to build a fresh oidc.Method from the profile's actual settings
// (see oidcMethod below) instead of using this registered instance, because
// OIDC's port/callback/timeout are meaningfully per-profile.
func init() {
auth.Register(oidc.Method{})
}
func (a *App) oidcMethod() auth.Method {
o := a.Settings.Auth.OIDC
cfg := oidc.Config{
Mount: a.Settings.Auth.Mount,
Role: a.Settings.Auth.Params["role"],
ListenAddress: o.ListenAddress,
CallbackMethod: o.CallbackMethod,
CallbackHost: o.CallbackHost,
CallbackPath: o.CallbackPath,
BrowserCommand: a.Settings.BrowserCommand,
}
if o.Port != nil {
cfg.Port = *o.Port
}
if o.CallbackPort != nil {
cfg.CallbackPort = *o.CallbackPort
}
if o.SkipBrowser != nil {
cfg.SkipBrowser = *o.SkipBrowser
}
if o.AbortOnBrowserError != nil {
cfg.AbortOnBrowserError = *o.AbortOnBrowserError
}
if o.Timeout != nil {
cfg.Timeout = *o.Timeout
}
if cfg.Mount == "" {
cfg.Mount = "oidc"
}
return oidc.Method{Cfg: cfg}
}
+47
View File
@@ -0,0 +1,47 @@
package cli
import (
"encoding/json"
"fmt"
"io"
"sort"
"strings"
)
// printJSON is the shared -format=json path for every headless command.
func printJSON(w io.Writer, v interface{}) error {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(v)
}
// printKV renders a map as an aligned "key value" table, sorted by key.
// Used by `status` and `read` in their default (non-JSON) output.
func printKV(w io.Writer, m map[string]string) {
keys := make([]string, 0, len(m))
width := 0
for k := range m {
keys = append(keys, k)
if len(k) > width {
width = len(k)
}
}
sort.Strings(keys)
for _, k := range keys {
fmt.Fprintf(w, "%-*s %s\n", width, k, m[k])
}
}
// parseKVArgs turns ["key=value", "other=1"] positional args into a map,
// used by `write` and as auth method params on `login`.
func parseKVArgs(args []string) (map[string]string, error) {
out := map[string]string{}
for _, a := range args {
k, v, ok := strings.Cut(a, "=")
if !ok {
return nil, fmt.Errorf("expected key=value, got %q", a)
}
out[k] = v
}
return out, nil
}
+57
View File
@@ -0,0 +1,57 @@
package cli
import (
"bufio"
"fmt"
"os"
"strings"
"golang.org/x/term"
"git.morlana.online/f.weber/vault-tui/internal/auth"
)
// promptMissing fills in any of m's still-missing required fields by
// prompting on the controlling TTY. It never blocks when stdin is not a
// terminal (e.g. CI/pipes) — callers should check IsInteractive first and
// treat a non-empty Missing() list as a hard error in that case instead of
// calling this.
func promptMissing(m auth.Method, creds auth.Credentials) error {
missing := auth.Missing(m, creds)
if len(missing) == 0 {
return nil
}
reader := bufio.NewReader(os.Stdin)
for _, f := range missing {
if f.Kind == auth.FieldSecret {
v, err := readPassword(f.Label)
if err != nil {
return err
}
creds[f.Name] = v
continue
}
fmt.Fprintf(os.Stderr, "%s: ", f.Label)
line, err := reader.ReadString('\n')
if err != nil {
return fmt.Errorf("reading %s: %w", f.Label, err)
}
creds[f.Name] = strings.TrimSpace(line)
}
return nil
}
func readPassword(label string) (string, error) {
fmt.Fprintf(os.Stderr, "%s (will be hidden): ", label)
b, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
if err != nil {
return "", fmt.Errorf("reading %s: %w", label, err)
}
return strings.TrimSpace(string(b)), nil
}
// IsInteractive reports whether stdin is a terminal we can prompt on.
func IsInteractive() bool {
return term.IsTerminal(int(os.Stdin.Fd()))
}
+66
View File
@@ -0,0 +1,66 @@
package cli
import (
"context"
"fmt"
"os"
"github.com/urfave/cli/v3"
)
func readCommand() *cli.Command {
return &cli.Command{
Name: "read",
Usage: "read a secret (e.g. `vault-tui read secret/team/prod/db`)",
ArgsUsage: "<path>",
Flags: []cli.Flag{
&cli.BoolFlag{Name: "json"},
&cli.IntFlag{Name: "version", Usage: "KV v2 version to read (default: latest)"},
&cli.StringFlag{Name: "field", Usage: "print only this field's raw value"},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
a, err := appFrom(ctx)
if err != nil {
return err
}
if cmd.Args().Len() == 0 {
return fmt.Errorf("usage: vault-tui read <path>")
}
if _, err := a.EnsureLoggedIn(ctx, cmd.String("token")); err != nil {
return err
}
svc := a.Service()
mount, rel, err := splitMountPath(ctx, svc, cmd.Args().First())
if err != nil {
return err
}
sec, err := svc.KV.Read(ctx, mount, rel, int(cmd.Int("version")))
if err != nil {
return err
}
if sec == nil {
return fmt.Errorf("no secret found at %s", cmd.Args().First())
}
if field := cmd.String("field"); field != "" {
v, ok := sec.Data[field]
if !ok {
return fmt.Errorf("field %q not present", field)
}
fmt.Println(v)
return nil
}
if cmd.Bool("json") {
return printJSON(os.Stdout, sec)
}
out := map[string]string{}
for k, v := range sec.Data {
out[k] = fmt.Sprint(v)
}
printKV(os.Stdout, out)
if sec.Version > 0 {
fmt.Printf("\nversion: %d\n", sec.Version)
}
return nil
},
}
}
+60
View File
@@ -0,0 +1,60 @@
package cli
import (
"context"
"github.com/urfave/cli/v3"
)
// Root builds the full command tree. DefaultCommand launches the TUI when
// vault-tui is invoked with no subcommand, matching the plan's "possible
// without any subcommand" requirement.
func Root(version string) *cli.Command {
root := &cli.Command{
Name: "vault-tui",
Usage: "Terminal UI for HashiCorp Vault",
Version: version,
DefaultCommand: "ui",
// Deliberately no Sources: cli.EnvVars(...) here: internal/config.Resolve
// (and config.ResolvePath/SelectProfile for --config/--profile) already
// implements the full flag>env>profile>defaults>builtin chain with
// correct per-field Origin tracking. Letting urfave/cli additionally
// populate these flags from the same env vars would make cmd.IsSet()
// indistinguishable between "user passed --address" and "VAULT_ADDR is
// set", corrupting both precedence and the status bar's Origin display.
Flags: []cli.Flag{
&cli.StringFlag{Name: "config", Usage: "path to config.yaml"},
&cli.StringFlag{Name: "profile", Aliases: []string{"p"}},
&cli.StringFlag{Name: "address"},
&cli.StringFlag{Name: "namespace"},
&cli.StringFlag{Name: "token"},
&cli.StringFlag{Name: "ca-cert"},
&cli.StringFlag{Name: "client-cert"},
&cli.StringFlag{Name: "client-key"},
&cli.BoolFlag{Name: "tls-skip-verify"},
&cli.BoolFlag{Name: "read-only", Usage: "refuse all write operations"},
&cli.BoolFlag{Name: "write", Usage: "allow write operations (overrides a read-only default)"},
&cli.BoolFlag{Name: "no-env", Usage: "ignore VAULT_* environment variables"},
&cli.BoolFlag{Name: "no-color"},
},
Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) {
a, err := bootstrap(cmd)
if err != nil {
return ctx, err
}
return context.WithValue(ctx, appKey, a), nil
},
Commands: []*cli.Command{
uiCommand(),
loginCommand(),
logoutCommand(),
statusCommand(),
listCommand(),
readCommand(),
writeCommand(),
deleteCommand(),
configCommand(),
},
}
return root
}
+61
View File
@@ -0,0 +1,61 @@
package cli
import (
"context"
"fmt"
"os"
"github.com/urfave/cli/v3"
)
func statusCommand() *cli.Command {
return &cli.Command{
Name: "status",
Usage: "show the resolved profile, address, and token status",
Flags: []cli.Flag{
&cli.BoolFlag{Name: "json"},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
a, err := appFrom(ctx)
if err != nil {
return err
}
s := a.Settings
out := map[string]string{
"profile": s.Profile,
"address": fmt.Sprintf("%s (%s)", s.Address, s.OriginOf("address").Key),
"namespace": valueOr(s.Namespace, "(none)"),
"read_only": fmt.Sprint(s.ReadOnly),
"auth_method": valueOr(s.Auth.Method, "(unset)"),
"token_store": fmt.Sprintf("%s (%s)", a.Store.Kind(), a.Store.Location()),
}
info, err := a.EnsureLoggedIn(ctx, cmd.String("token"))
if err != nil {
out["token"] = fmt.Sprintf("none/invalid: %v", err)
if cmd.Bool("json") {
return printJSON(os.Stdout, out)
}
printKV(os.Stdout, out)
return nil
}
out["token_accessor"] = info.Accessor
out["token_policies"] = fmt.Sprint(info.Policies)
out["token_ttl"] = info.TTL.String()
out["token_renewable"] = fmt.Sprint(info.Renewable)
if cmd.Bool("json") {
return printJSON(os.Stdout, out)
}
printKV(os.Stdout, out)
return nil
},
}
}
func valueOr(v, fallback string) string {
if v == "" {
return fallback
}
return v
}
+35
View File
@@ -0,0 +1,35 @@
package cli
import (
"context"
"fmt"
"github.com/urfave/cli/v3"
)
// uiCommand launches the Bubbletea TUI. Implemented in internal/ui; wired
// up once that package exists (see runUI, set from cmd/vault-tui/main.go's
// init-time hook to avoid internal/cli importing the TUI toolkit itself and
// thus keeping headless commands buildable/testable without it).
var runUI func(ctx context.Context, a *App) error
func uiCommand() *cli.Command {
return &cli.Command{
Name: "ui",
Usage: "launch the terminal UI (default when no subcommand is given)",
Action: func(ctx context.Context, cmd *cli.Command) error {
a, err := appFrom(ctx)
if err != nil {
return err
}
if runUI == nil {
return fmt.Errorf("the TUI is not available in this build")
}
return runUI(ctx, a)
},
}
}
// SetUIRunner lets cmd/vault-tui wire the real internal/ui implementation
// into the "ui" command without internal/cli importing a TUI toolkit.
func SetUIRunner(f func(ctx context.Context, a *App) error) { runUI = f }
+107
View File
@@ -0,0 +1,107 @@
package cli
import (
"bufio"
"fmt"
"os"
"strings"
"git.morlana.online/f.weber/vault-tui/internal/auth"
"git.morlana.online/f.weber/vault-tui/internal/config"
)
// runWizard interactively builds one profile and writes it to path. It is
// deliberately generic — it prompts for everything (address, namespace,
// TLS, auth method and that method's fields) rather than assuming any
// particular Vault deployment, per this project's design goal of not
// hard-coding any environment's specifics into the tool itself.
func runWizard(path string) (*config.File, error) {
r := bufio.NewReader(os.Stdin)
fmt.Fprintln(os.Stderr, "No config found — let's set up a Vault profile (you can edit it later at "+path+").")
profileName := ask(r, "Profile name", "default")
addrDefault := "https://127.0.0.1:8200"
if v, ok := config.OSEnviron(config.EnvAddress); ok && v != "" {
addrDefault = v
}
address := ask(r, "Vault address", addrDefault)
namespace := ask(r, "Namespace (blank for none)", "")
prof := &config.Profile{Address: address, Namespace: namespace}
if askYesNo(r, "Configure custom TLS (CA cert / client cert)?", false) {
prof.TLS.CACert = ask(r, "CA certificate path (blank to use system trust)", "")
prof.TLS.ClientCert = ask(r, "Client certificate path (blank for none)", "")
if prof.TLS.ClientCert != "" {
prof.TLS.ClientKey = ask(r, "Client key path", "")
}
if askYesNo(r, "Skip TLS verification (insecure, testing only)?", false) {
t := true
prof.TLS.SkipVerify = &t
}
}
names := auth.Default().Names()
fmt.Fprintln(os.Stderr, "Available auth methods: "+strings.Join(names, ", "))
method := ask(r, "Auth method", "oidc")
m, ok := auth.Default().Get(method)
if !ok {
return nil, fmt.Errorf("unknown auth method %q", method)
}
prof.Auth.Method = method
mount := ask(r, "Auth mount path", m.DefaultMount())
prof.Auth.Mount = mount
params := map[string]string{}
for _, f := range m.Fields() {
if f.Kind == auth.FieldSecret {
// Secrets are never written to the config file; the user supplies
// them at login time (env var, flag, or interactive prompt).
continue
}
v := ask(r, f.Label, f.Default)
if v != "" {
params[f.Name] = v
}
}
if len(params) > 0 {
prof.Auth.Params = params
}
file := &config.File{
Version: config.SchemaVersion,
CurrentProfile: profileName,
Profiles: map[string]*config.Profile{profileName: prof},
}
if err := config.Save(path, file); err != nil {
return nil, err
}
fmt.Fprintln(os.Stderr, "Saved "+path)
return file, nil
}
func ask(r *bufio.Reader, label, def string) string {
if def != "" {
fmt.Fprintf(os.Stderr, "%s [%s]: ", label, def)
} else {
fmt.Fprintf(os.Stderr, "%s: ", label)
}
line, _ := r.ReadString('\n')
line = strings.TrimSpace(line)
if line == "" {
return def
}
return line
}
func askYesNo(r *bufio.Reader, label string, def bool) bool {
d := "y/N"
if def {
d = "Y/n"
}
line := strings.ToLower(ask(r, label+" ("+d+")", ""))
if line == "" {
return def
}
return line == "y" || line == "yes"
}
+69
View File
@@ -0,0 +1,69 @@
package cli
import (
"context"
"fmt"
"github.com/urfave/cli/v3"
)
func writeCommand() *cli.Command {
return &cli.Command{
Name: "write",
Usage: "create or update a secret (e.g. `vault-tui write secret/team/prod/db user=admin pass=hunter2`)",
ArgsUsage: "<path> key=value [key=value ...]",
Flags: []cli.Flag{
&cli.IntFlag{Name: "cas", Usage: "KV v2 check-and-set version (default: current version, if require_cas is on)"},
&cli.BoolFlag{Name: "force", Usage: "skip check-and-set even if require_cas is on"},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
a, err := appFrom(ctx)
if err != nil {
return err
}
args := cmd.Args().Slice()
if len(args) < 2 {
return fmt.Errorf("usage: vault-tui write <path> key=value [key=value ...]")
}
if _, err := a.EnsureLoggedIn(ctx, cmd.String("token")); err != nil {
return err
}
svc := a.Service()
if svc.ReadOnly {
return fmt.Errorf("refusing to write: vault-tui is in read-only mode (pass --write to override)")
}
mount, rel, err := splitMountPath(ctx, svc, args[0])
if err != nil {
return err
}
data, err := parseKVArgs(args[1:])
if err != nil {
return err
}
iface := make(map[string]interface{}, len(data))
for k, v := range data {
iface[k] = v
}
useCAS := a.Settings.RequireCAS && !cmd.Bool("force")
cas := int(cmd.Int("cas"))
if useCAS && !cmd.IsSet("cas") {
current, err := svc.KV.Read(ctx, mount, rel, 0)
if err == nil && current != nil {
cas = current.Version
}
}
ack, err := svc.KV.Write(ctx, mount, rel, iface, useCAS, cas)
if err != nil {
return err
}
if ack.Version > 0 {
fmt.Printf("wrote %s (version %d)\n", args[0], ack.Version)
} else {
fmt.Printf("wrote %s\n", args[0])
}
return nil
},
}
}