feat(secretstore): add animated bitwarden wait loader

This commit is contained in:
thibaud-lclr 2026-04-20 11:18:14 +02:00
parent 7d159bfdbd
commit 98f07f557d
2 changed files with 155 additions and 0 deletions

View file

@ -10,6 +10,9 @@ import (
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings" "strings"
"sync"
"sync/atomic"
"time"
) )
const ( const (
@ -18,11 +21,19 @@ const (
bitwardenSecretFieldName = "mcp-secret" bitwardenSecretFieldName = "mcp-secret"
bitwardenServiceFieldName = "mcp-service" bitwardenServiceFieldName = "mcp-service"
bitwardenSecretNameFieldName = "mcp-secret-name" bitwardenSecretNameFieldName = "mcp-secret-name"
bitwardenLoaderMessage = "Waiting BitWarden..."
bitwardenLoaderInterval = 90 * time.Millisecond
bitwardenLoaderColorBase = "\033[38;5;39m"
bitwardenLoaderColorWave = "\033[38;5;81m"
bitwardenLoaderColorFocus = "\033[38;5;117m"
bitwardenLoaderResetColor = "\033[0m"
bitwardenLoaderClearLine = "\r\033[2K"
) )
type bitwardenRunner func(command string, stdin []byte, args ...string) ([]byte, error) type bitwardenRunner func(command string, stdin []byte, args ...string) ([]byte, error)
var runBitwardenCLI bitwardenRunner = executeBitwardenCLI var runBitwardenCLI bitwardenRunner = executeBitwardenCLI
var bitwardenLoaderActive atomic.Bool
type bitwardenStore struct { type bitwardenStore struct {
command string command string
@ -511,6 +522,9 @@ func bitwardenItemMatchesMarkers(payload map[string]any, serviceName, secretName
} }
func executeBitwardenCLI(command string, stdin []byte, args ...string) ([]byte, error) { func executeBitwardenCLI(command string, stdin []byte, args ...string) ([]byte, error) {
stopLoader := startBitwardenLoader()
defer stopLoader()
cmd := exec.Command(command, args...) cmd := exec.Command(command, args...)
if stdin != nil { if stdin != nil {
cmd.Stdin = bytes.NewReader(stdin) cmd.Stdin = bytes.NewReader(stdin)
@ -528,6 +542,108 @@ func executeBitwardenCLI(command string, stdin []byte, args ...string) ([]byte,
return stdout.Bytes(), nil return stdout.Bytes(), nil
} }
func startBitwardenLoader() func() {
if !shouldShowBitwardenLoader() {
return func() {}
}
if !bitwardenLoaderActive.CompareAndSwap(false, true) {
return func() {}
}
done := make(chan struct{})
stopped := make(chan struct{})
go func() {
defer close(stopped)
ticker := time.NewTicker(bitwardenLoaderInterval)
defer ticker.Stop()
phase := 0
for {
_, _ = fmt.Fprint(os.Stdout, bitwardenLoaderFrame(phase))
phase++
select {
case <-done:
_, _ = fmt.Fprint(os.Stdout, bitwardenLoaderClearLine)
return
case <-ticker.C:
}
}
}()
var stopOnce sync.Once
return func() {
stopOnce.Do(func() {
close(done)
<-stopped
bitwardenLoaderActive.Store(false)
})
}
}
func shouldShowBitwardenLoader() bool {
term := strings.TrimSpace(os.Getenv("TERM"))
if term == "" || strings.EqualFold(term, "dumb") {
return false
}
info, err := os.Stdout.Stat()
if err != nil {
return false
}
return (info.Mode() & os.ModeCharDevice) != 0
}
func bitwardenLoaderFrame(phase int) string {
chars := []rune(bitwardenLoaderMessage)
if len(chars) == 0 {
return bitwardenLoaderClearLine
}
waveIndex := phase % len(chars)
if waveIndex < 0 {
waveIndex += len(chars)
}
var frame strings.Builder
frame.Grow(len(chars)*14 + len(bitwardenLoaderClearLine) + len(bitwardenLoaderResetColor))
frame.WriteString(bitwardenLoaderClearLine)
for idx, char := range chars {
frame.WriteString(bitwardenLoaderColorForIndex(idx, waveIndex, len(chars)))
frame.WriteRune(char)
}
frame.WriteString(bitwardenLoaderResetColor)
return frame.String()
}
func bitwardenLoaderColorForIndex(idx, waveIndex, length int) string {
if length <= 1 {
return bitwardenLoaderColorFocus
}
distance := idx - waveIndex
if distance < 0 {
distance = -distance
}
if wrapped := length - distance; wrapped < distance {
distance = wrapped
}
switch distance {
case 0:
return bitwardenLoaderColorFocus
case 1:
return bitwardenLoaderColorWave
default:
return bitwardenLoaderColorBase
}
}
func normalizeBitwardenExecutionError(err error, stderrText, stdoutText string) error { func normalizeBitwardenExecutionError(err error, stderrText, stdoutText string) error {
detail := sanitizeBitwardenErrorDetail(stderrText, stdoutText) detail := sanitizeBitwardenErrorDetail(stderrText, stdoutText)
classification := classifyBitwardenError(detail) classification := classifyBitwardenError(detail)

View file

@ -7,8 +7,10 @@ import (
"fmt" "fmt"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"testing" "testing"
"unicode/utf8"
"gitea.lclr.dev/AI/mcp-framework/manifest" "gitea.lclr.dev/AI/mcp-framework/manifest"
) )
@ -365,6 +367,43 @@ func TestExecuteBitwardenCLIClassifiesLoginError(t *testing.T) {
} }
} }
func TestBitwardenLoaderFrameUsesSingleLineRewriteAndMessage(t *testing.T) {
frame := bitwardenLoaderFrame(0)
if !strings.HasPrefix(frame, "\r\033[2K") {
t.Fatalf("frame prefix = %q, want carriage return + clear line", frame)
}
if !strings.Contains(frame, "\033[38;5;117mW") {
t.Fatalf("frame = %q, want highlighted first rune", frame)
}
cleaned := stripANSIControlSequences(frame)
if cleaned != bitwardenLoaderMessage {
t.Fatalf("cleaned frame = %q, want %q", cleaned, bitwardenLoaderMessage)
}
}
func TestBitwardenLoaderFrameMovesAndWrapsTheWave(t *testing.T) {
firstFrame := bitwardenLoaderFrame(0)
secondFrame := bitwardenLoaderFrame(1)
if firstFrame == secondFrame {
t.Fatal("expected different frames between two ticks")
}
if !strings.Contains(secondFrame, "\033[38;5;117ma") {
t.Fatalf("second frame = %q, want highlighted second rune", secondFrame)
}
wrapped := bitwardenLoaderFrame(utf8.RuneCountInString(bitwardenLoaderMessage))
if !strings.Contains(wrapped, "\033[38;5;117mW") {
t.Fatalf("wrapped frame = %q, want wave to wrap to first rune", wrapped)
}
}
var ansiControlSequencePattern = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`)
func stripANSIControlSequences(value string) string {
noANSI := ansiControlSequencePattern.ReplaceAllString(value, "")
return strings.ReplaceAll(noANSI, "\r", "")
}
func TestOpenFromManifestSupportsBitwardenCLIBackendPolicy(t *testing.T) { func TestOpenFromManifestSupportsBitwardenCLIBackendPolicy(t *testing.T) {
withBitwardenSession(t) withBitwardenSession(t)
fakeCLI := newFakeBitwardenCLI("bw") fakeCLI := newFakeBitwardenCLI("bw")