feat(secretstore): add animated bitwarden wait loader
This commit is contained in:
parent
7d159bfdbd
commit
98f07f557d
2 changed files with 155 additions and 0 deletions
|
|
@ -10,6 +10,9 @@ import (
|
|||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -18,11 +21,19 @@ const (
|
|||
bitwardenSecretFieldName = "mcp-secret"
|
||||
bitwardenServiceFieldName = "mcp-service"
|
||||
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)
|
||||
|
||||
var runBitwardenCLI bitwardenRunner = executeBitwardenCLI
|
||||
var bitwardenLoaderActive atomic.Bool
|
||||
|
||||
type bitwardenStore struct {
|
||||
command string
|
||||
|
|
@ -511,6 +522,9 @@ func bitwardenItemMatchesMarkers(payload map[string]any, serviceName, secretName
|
|||
}
|
||||
|
||||
func executeBitwardenCLI(command string, stdin []byte, args ...string) ([]byte, error) {
|
||||
stopLoader := startBitwardenLoader()
|
||||
defer stopLoader()
|
||||
|
||||
cmd := exec.Command(command, args...)
|
||||
if stdin != nil {
|
||||
cmd.Stdin = bytes.NewReader(stdin)
|
||||
|
|
@ -528,6 +542,108 @@ func executeBitwardenCLI(command string, stdin []byte, args ...string) ([]byte,
|
|||
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 {
|
||||
detail := sanitizeBitwardenErrorDetail(stderrText, stdoutText)
|
||||
classification := classifyBitwardenError(detail)
|
||||
|
|
|
|||
|
|
@ -7,8 +7,10 @@ import (
|
|||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"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) {
|
||||
withBitwardenSession(t)
|
||||
fakeCLI := newFakeBitwardenCLI("bw")
|
||||
|
|
|
|||
Loading…
Reference in a new issue