Add the thin CLI: login, projects, secrets list, get

Read-path commands over the library. Credentials never come from flags
or arguments; get prints the bare value for $(...) plumbing and nothing
else ever touches stdout; stderr carries only redacted diagnostics. Exit
codes mirror keyproxy (0 ok, 1 usage/config, 2 auth or resolution
failure). CLI tests drive the fake server through a real 0600 env file.
This commit is contained in:
2026-08-29 00:08:38 -05:00
parent a9b08b96c4
commit edf056eb9f
3 changed files with 436 additions and 0 deletions
+187
View File
@@ -0,0 +1,187 @@
// Package cli implements the bitwarden-go command line: a thin read-path
// front end over the library (login check, project/secret discovery,
// value fetch). Credentials come from the environment or a 0600 env file
// — never from flags or arguments. Values are printed to stdout bare
// (exec-style plumbing) and never to stderr; stderr carries only
// redacted diagnostics.
package cli
import (
"context"
"flag"
"fmt"
"io"
"os"
"path/filepath"
bw "git.knownelement.com/ukrrs/mopac-bitwarden-go"
"git.knownelement.com/ukrrs/mopac-bitwarden-go/internal/config"
)
const usage = `bitwarden-go: Bitwarden Secrets Manager read client (plain REST, no SDK)
Usage:
bitwarden-go login [-config PATH] verify credentials (no output secrets)
bitwarden-go projects [-config PATH] list projects: "<id> <name>"
bitwarden-go secrets list [-config PATH] list secrets: "<id> <name>"
bitwarden-go get <key> [-config PATH] print one secret value (bare, no newline)
bitwarden-go help print this usage
<key> is a secret name or secret uuid. Credentials come from BW_* env
vars or a 0600 env file (BW_SERVER_URL, BW_ACCESS_TOKEN, or
BW_CLIENTID+BW_CLIENTSECRET) — NEVER from flags or arguments. Tokens are
memory-only and refreshed before expiry; nothing is ever written to disk.
Exit codes: 0 ok, 1 usage/config error, 2 auth or resolution failure.
`
// Run executes one command; it returns the process exit code.
func Run(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
fmt.Fprint(stderr, usage)
return 1
}
switch args[0] {
case "help", "-h", "--help":
fmt.Fprint(stdout, usage)
return 0
case "login":
return cmdLogin(args[1:], stdout, stderr)
case "projects":
return cmdProjects(args[1:], stdout, stderr)
case "secrets":
return cmdSecrets(args[1:], stdout, stderr)
case "get":
return cmdGet(args[1:], stdout, stderr)
default:
fmt.Fprintf(stderr, "bitwarden-go: unknown command %q\n\n%s", args[0], usage)
return 1
}
}
// configPath resolves -config / $BITWARDENGO_CONFIG / default location.
func configPath(explicit string) string {
if explicit != "" {
return explicit
}
if p := os.Getenv("BITWARDENGO_CONFIG"); p != "" {
return p
}
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".config", "bitwarden-go", "env")
}
func newFlags(name string) *flag.FlagSet {
fs := flag.NewFlagSet(name, flag.ContinueOnError)
fs.SetOutput(io.Discard)
return fs
}
func authenticate(stderr io.Writer, explicitConfig string) (*bw.Token, error) {
cfg, _, err := config.Load(configPath(explicitConfig))
if err != nil {
return nil, err
}
return bw.Authenticate(context.Background(), bw.Credentials{
BaseURL: cfg.ServerURL,
AccessToken: cfg.AccessToken,
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
})
}
func cmdLogin(args []string, stdout, stderr io.Writer) int {
fs := newFlags("login")
configFlag := fs.String("config", "", "credentials env file (default $BITWARDENGO_CONFIG, then ~/.config/bitwarden-go/env)")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
fmt.Fprintln(stderr, "bitwarden-go login: no arguments expected")
return 1
}
tok, err := authenticate(stderr, *configFlag)
if err != nil {
fmt.Fprintf(stderr, "bitwarden-go: %v\n", err)
return 2
}
defer tok.Zero()
fmt.Fprintf(stdout, "authenticated: account %s, %s\n", tok.AccountID, tok)
return 0
}
func cmdProjects(args []string, stdout, stderr io.Writer) int {
fs := newFlags("projects")
configFlag := fs.String("config", "", "credentials env file")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
fmt.Fprintln(stderr, "bitwarden-go projects: no arguments expected")
return 1
}
tok, err := authenticate(stderr, *configFlag)
if err != nil {
fmt.Fprintf(stderr, "bitwarden-go: %v\n", err)
return 2
}
defer tok.Zero()
projects, err := bw.ListProjects(context.Background(), tok)
if err != nil {
fmt.Fprintf(stderr, "bitwarden-go: %v\n", err)
return 2
}
for _, p := range projects {
fmt.Fprintf(stdout, "%s %s\n", p.ID, p.Name)
}
return 0
}
func cmdSecrets(args []string, stdout, stderr io.Writer) int {
fs := newFlags("secrets")
configFlag := fs.String("config", "", "credentials env file")
if err := fs.Parse(args); err != nil {
fmt.Fprintln(stderr, "bitwarden-go secrets: usage: bitwarden-go secrets list")
return 1
}
if fs.NArg() != 1 || fs.Arg(0) != "list" {
fmt.Fprintln(stderr, "bitwarden-go secrets: usage: bitwarden-go secrets list")
return 1
}
tok, err := authenticate(stderr, *configFlag)
if err != nil {
fmt.Fprintf(stderr, "bitwarden-go: %v\n", err)
return 2
}
defer tok.Zero()
secrets, err := bw.ListSecrets(context.Background(), tok)
if err != nil {
fmt.Fprintf(stderr, "bitwarden-go: %v\n", err)
return 2
}
for _, s := range secrets {
fmt.Fprintf(stdout, "%s %s\n", s.ID, s.Name)
}
return 0
}
func cmdGet(args []string, stdout, stderr io.Writer) int {
fs := newFlags("get")
configFlag := fs.String("config", "", "credentials env file")
if err := fs.Parse(args); err != nil || fs.NArg() != 1 {
fmt.Fprintln(stderr, "bitwarden-go get: exactly one secret name or id required")
return 1
}
key := fs.Arg(0)
tok, err := authenticate(stderr, *configFlag)
if err != nil {
fmt.Fprintf(stderr, "bitwarden-go: %v\n", err)
return 2
}
defer tok.Zero()
value, err := bw.GetSecret(context.Background(), tok, key)
if err != nil {
fmt.Fprintf(stderr, "bitwarden-go: %v\n", err)
return 2
}
// Bare value, no trailing newline: $(bitwarden-go get name) plumbing.
fmt.Fprint(stdout, value)
return 0
}