From 365340b37d0d80a47ea006342fc0c5c71bae162b Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Wed, 24 Jun 2026 10:20:05 +0000 Subject: [PATCH] feat(cli): homelab vault get with TTY-aware return --- cli/cmd_vault.go | 106 ++++++++++++++++++++++++++++++++++++++++-- cli/cmd_vault_test.go | 37 +++++++++++++++ 2 files changed, 138 insertions(+), 5 deletions(-) diff --git a/cli/cmd_vault.go b/cli/cmd_vault.go index 825f14da..b575f4b9 100644 --- a/cli/cmd_vault.go +++ b/cli/cmd_vault.go @@ -269,10 +269,106 @@ func openSession(run cmdRunner, user, uid string) (session, error) { return session{env: bwSecretEnv(appdata, creds, sess)}, nil } -func vaultSetup(args []string) error { return fmt.Errorf("not implemented") } +type getOpts struct { + name string + field string + json bool +} + +var validGetFields = map[string]bool{"password": true, "username": true, "uri": true, "notes": true, "totp": true} + +func parseGetArgs(args []string) (getOpts, error) { + o := getOpts{field: "password"} + for i := 0; i < len(args); i++ { + a := args[i] + switch { + case a == "--json": + o.json = true + case a == "--field" && i+1 < len(args): + o.field = args[i+1] + i++ + case strings.HasPrefix(a, "--field="): + o.field = strings.TrimPrefix(a, "--field=") + case !strings.HasPrefix(a, "-") && o.name == "": + o.name = a + } + } + if o.name == "" { + return o, fmt.Errorf("usage: homelab vault get [--field password|username|uri|notes|totp] [--json]") + } + if !validGetFields[o.field] { + return o, fmt.Errorf("invalid --field %q (want password|username|uri|notes|totp)", o.field) + } + return o, nil +} + +// getValue opens a session and fetches one field. Pure of I/O side effects +// besides the runner, so it is unit-tested with a fake runner. +func getValue(run cmdRunner, user, uid string, o getOpts) (string, error) { + s, err := openSession(run, user, uid) + if err != nil { + return "", err + } + return bwGet(run, s.env, o.field, o.name) +} + +// emitSecret returns it TTY-aware: clipboard (OSC52, gated, auto-clear) on a +// terminal; stdout otherwise. Returns the human-facing status string (never the +// secret) for the clipboard path. +func emitSecret(value string) { + if returnMode(stdoutIsTTY()) == "stdout" { + fmt.Println(value) + return + } + if !terminalAllowed(os.Getenv("TERM"), os.Getenv("TERM_PROGRAM")) { + fmt.Fprintln(os.Stderr, "refusing to print secret: this terminal can't do OSC52 clipboard safely; pipe the command or use a supported terminal") + return + } + fmt.Fprint(os.Stderr, osc52(value)) + fmt.Fprintln(os.Stderr, "copied to clipboard; clearing in 30s") + clearClipboardAfter(30) +} + +// clearClipboardAfter spawns a detached background clear so the secret doesn't +// linger in the clipboard. Best-effort. +func clearClipboardAfter(seconds int) { + exec.Command("sh", "-c", fmt.Sprintf("sleep %d; printf '%s'", seconds, osc52clear())).Start() +} + +func vaultSetup(args []string) error { return fmt.Errorf("not implemented") } + func vaultStatus(args []string) error { return fmt.Errorf("not implemented") } -func vaultList(args []string) error { return fmt.Errorf("not implemented") } -func vaultGet(args []string) error { return fmt.Errorf("not implemented") } + +func vaultList(args []string) error { return fmt.Errorf("not implemented") } + +func vaultGet(args []string) error { + hardenProcess() + o, err := parseGetArgs(args) + if err != nil { + return err + } + uid := vaultCurrentUID() + unlock, err := withUserLock(uid) + if err != nil { + return err + } + defer unlock() + user := vaultCurrentUser() + val, err := getValue(realRunner, user, uid, o) + if err != nil { + return err + } + writeOpLog(opRecord{User: user, Verb: "get", PID: os.Getpid(), PPID: os.Getppid(), ParentComm: parentComm(os.Getppid()), ItemName: o.name}) + if o.json { + fmt.Printf("{%q:%q}\n", o.field, val) + return nil + } + emitSecret(val) + return nil +} + func vaultSearch(args []string) error { return fmt.Errorf("not implemented") } -func vaultCode(args []string) error { return fmt.Errorf("not implemented") } -func vaultLock(args []string) error { return fmt.Errorf("not implemented") } + +func vaultCode(args []string) error { return fmt.Errorf("not implemented") } + +func vaultLock(args []string) error { return fmt.Errorf("not implemented") } diff --git a/cli/cmd_vault_test.go b/cli/cmd_vault_test.go index da22692b..09721ef7 100644 --- a/cli/cmd_vault_test.go +++ b/cli/cmd_vault_test.go @@ -2,6 +2,8 @@ package main import ( "encoding/base64" + "fmt" + "os" "reflect" "strings" "testing" @@ -196,3 +198,38 @@ func TestLockPath(t *testing.T) { t.Fatalf("vaultLockPath = %q", got) } } + +func TestParseGetArgs(t *testing.T) { + o, err := parseGetArgs([]string{"github", "--field", "username", "--json"}) + if err != nil || o.name != "github" || o.field != "username" || !o.json { + t.Fatalf("parseGetArgs = %+v err=%v", o, err) + } + d, _ := parseGetArgs([]string{"github"}) + if d.field != "password" || d.json { + t.Fatalf("defaults wrong: %+v", d) + } + if _, err := parseGetArgs([]string{}); err == nil { + t.Fatal("get with no name must error") + } + if _, err := parseGetArgs([]string{"x", "--field", "evil"}); err == nil { + t.Fatal("invalid --field must error") + } +} + +// getValue is the testable core: given a runner + opts, returns the secret value. +func TestGetValueFlow(t *testing.T) { + f := &fakeRunner{out: map[string]string{ + "vault kv get -field=vaultwarden_master_password secret/workstation/claude-users/emo": "pw", + "vault kv get -field=vaultwarden_client_id secret/workstation/claude-users/emo": "user.x", + "vault kv get -field=vaultwarden_client_secret secret/workstation/claude-users/emo": "cs", + "bw status": `{"status":"locked"}`, + "bw unlock": "SESS", + "bw get password github": "p@ss", + }} + // Use real UID so os.MkdirAll(/run/user//homelab-bw) succeeds. + uid := fmt.Sprintf("%d", os.Getuid()) + val, err := getValue(f.run, "emo", uid, getOpts{name: "github", field: "password"}) + if err != nil || val != "p@ss" { + t.Fatalf("getValue = %q, %v", val, err) + } +}