Files
pmg/internal/ui/ui.go
T
70db1b3078 fix: redirect spinner output from stdout to stderr (#176)
The spinner goroutine was writing carriage-return + status text to
os.Stdout every 100ms. In the non-interactive TTY path, the child
process also writes directly to os.Stdout, causing both writers to
race on the same file descriptor. The \r emitted by the spinner
resets the cursor to column 0 mid-line, corrupting and truncating
the child process output.

Fix by writing all spinner/status output to os.Stderr, which is the
standard Unix convention for diagnostic and status messages. This is
also consistent with how progress.go and ShowWarning already behave.

https://claude.ai/code/session_012jMiRSS4Jx9Bs7a2F4S6KN

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-06 08:27:11 +05:30

196 lines
5.2 KiB
Go

package ui
import (
"bufio"
"fmt"
"io"
"os"
"strings"
"github.com/safedep/pmg/analyzer"
)
// The UI is internal to PMG and opinionated for the CLI.
// It is not intended to be used outside of PMG.
type VerbosityLevel int
const (
// PMG is hidden from the user except for errors
// and when malicious packages are detected
VerbosityLevelSilent VerbosityLevel = iota
// Show minimal status updates
VerbosityLevelNormal
// Show verbose status updates and information including
// information about malicious packages
VerbosityLevelVerbose
)
type BlockConfig struct {
// ShowReference determines whether to show detailed information for suspicious packages.
// If false, the details are omitted to avoid repeating information already shown to the user.
ShowReference bool
MalwarePackages []*analyzer.PackageVersionAnalysisResult
}
func NewDefaultBlockConfig() *BlockConfig {
return &BlockConfig{
ShowReference: true,
}
}
var verbosityLevel VerbosityLevel = VerbosityLevelNormal
func SetVerbosityLevel(level VerbosityLevel) {
verbosityLevel = level
}
func ClearStatus() {
StopSpinner()
fmt.Fprint(os.Stderr, "\r")
}
func Block(config *BlockConfig) error {
return blockWithExit(config, true)
}
func BlockNoExit(config *BlockConfig) error {
return blockWithExit(config, false)
}
func blockWithExit(config *BlockConfig, exit bool) error {
StopSpinner()
// We show the block message only in normal mode to avoid repeating information
// already shown to the user in verbose mode as part of the reporting.
if verbosityLevel != VerbosityLevelVerbose {
fmt.Println()
fmt.Printf("%s %s\n", Colors.Red("✗"), Colors.Red("Malicious package blocked"))
if config.ShowReference {
printMaliciousPackagesList(config.MalwarePackages)
fmt.Println()
}
}
if exit {
os.Exit(1)
}
return nil
}
func SetStatus(status string) {
if verbosityLevel == VerbosityLevelSilent {
return
}
StopSpinner()
StartSpinnerWithColor(status, Colors.Dim)
}
// GetConfirmationOnMalware prompts the user to confirm installation of suspicious packages.
// It reads from os.Stdin. Use GetConfirmationOnMalwareWithReader for custom input sources.
func GetConfirmationOnMalware(malwarePackages []*analyzer.PackageVersionAnalysisResult) (bool, error) {
return GetConfirmationOnMalwareWithReader(malwarePackages, os.Stdin)
}
// GetConfirmationOnMalwareWithReader prompts the user to confirm installation of suspicious packages.
// It reads from the provided reader, allowing for PTY input routing during proxy mode.
func GetConfirmationOnMalwareWithReader(malwarePackages []*analyzer.PackageVersionAnalysisResult, reader io.Reader) (bool, error) {
StopSpinner()
fmt.Println()
fmt.Printf("%s %s\n", Colors.Yellow("!"), Colors.Yellow(fmt.Sprintf("Suspicious package(s) detected: %d", len(malwarePackages))))
printMaliciousPackagesList(malwarePackages)
fmt.Println()
fmt.Print(Colors.Normal("Do you want to continue with the installation? (y/N) "))
// Use Scanner on the provided reader to support PTY input routing
scanner := bufio.NewScanner(reader)
if scanner.Scan() {
response := strings.ToLower(strings.TrimSpace(scanner.Text()))
if response == "y" || response == "yes" || (len(response) > 0 && response[0] == 'y') {
return true, nil
}
}
// Check for scanner errors, but don't treat them as fatal
if err := scanner.Err(); err != nil {
// On EOF or interrupted read, just return false (deny)
return false, nil
}
return false, nil
}
func ShowWarning(message string) {
// Print colored warning to stderr immediately - it won't be cleared by other output
fmt.Fprintf(os.Stderr, "PMG: %s\n", Colors.Red(message))
}
func Fatalf(msg string, args ...interface{}) {
ClearStatus()
fmt.Println(Colors.Red(fmt.Sprintf(msg, args...)))
os.Exit(1)
}
func printMaliciousPackagesList(malwarePackages []*analyzer.PackageVersionAnalysisResult) {
for _, mp := range malwarePackages {
fmt.Println()
fmt.Printf(" %s %s\n", Colors.Red("-"),
Colors.Red(fmt.Sprintf("%s@%s", mp.PackageVersion.GetPackage().GetName(),
mp.PackageVersion.GetVersion())))
if verbosityLevel == VerbosityLevelVerbose {
fmt.Printf(" %s\n", Colors.Dim(termWidthFormatText(mp.Summary, 76)))
}
if mp.ReferenceURL != "" {
fmt.Printf(" %s\n", Colors.Dim(fmt.Sprintf("Reference: %s", mp.ReferenceURL)))
}
}
}
// Format the string to be maximum maxWidth. Use newlines to wrap the text.
func termWidthFormatText(text string, maxWidth int) string {
// Replace all newlines with spaces so that we can split the text into words
// This is to ensure that we don't split the text at the newlines
text = strings.ReplaceAll(text, "\n", " ")
words := strings.Split(text, " ")
lines := []string{}
currentLine := ""
for i, word := range words {
// Skip empty words that might result from multiple spaces
if word == "" {
continue
}
if i == 0 {
// First word doesn't need a leading space
currentLine = word
} else if len(currentLine)+len(word)+1 > maxWidth {
// +1 for the space we would add
lines = append(lines, currentLine)
currentLine = word
} else {
currentLine += " " + word
}
}
// Don't forget to add the last line
if currentLine != "" {
lines = append(lines, currentLine)
}
return strings.Join(lines, "\n")
}