diff --git a/secretstore/bitwarden.go b/secretstore/bitwarden.go index ffcc0f7..acb7f27 100644 --- a/secretstore/bitwarden.go +++ b/secretstore/bitwarden.go @@ -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) diff --git a/secretstore/bitwarden_test.go b/secretstore/bitwarden_test.go index e12a662..9a3e7e6 100644 --- a/secretstore/bitwarden_test.go +++ b/secretstore/bitwarden_test.go @@ -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")