Files
pmg/internal/ui/ui.go
T
5018f8e2ff fix: Suppress non-PMG error from Critical UI Error (#311)
* fix: Suppress non-PMG error from Critical UI Error

* fix: Code review fixes

---------

Co-authored-by: Sahil Bansal <bansalsahil315@gmail.com>
2026-05-29 14:49:53 +00:00

280 lines
7.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package ui
import (
"bufio"
"fmt"
"io"
"os"
"strings"
"github.com/safedep/pmg/analyzer"
"github.com/safedep/pmg/internal/models"
"golang.org/x/term"
)
// 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))
}
// Infof prints an informational message, suppressed in silent mode.
func Infof(msg string, args ...interface{}) {
if verbosityLevel == VerbosityLevelSilent {
return
}
fmt.Println(fmt.Sprintf(msg, args...))
}
// Successf prints a green success message, suppressed in silent mode.
func Successf(msg string, args ...interface{}) {
if verbosityLevel == VerbosityLevelSilent {
return
}
fmt.Printf("%s %s\n", Colors.Green("✓"), fmt.Sprintf(msg, args...))
}
// PromptInput prints a label and reads a line of visible input from stdin.
func PromptInput(label string) (string, error) {
fmt.Printf("%s %s", Colors.Cyan(""), Colors.Bold(label))
scanner := bufio.NewScanner(os.Stdin)
if scanner.Scan() {
return strings.TrimSpace(scanner.Text()), nil
}
if err := scanner.Err(); err != nil {
return "", err
}
return "", fmt.Errorf("no input received")
}
// PromptSecret prints a label and reads input from stdin with echo disabled.
// Returns an error if stdin is not a terminal (e.g. piped input).
func PromptSecret(label string) (string, error) {
if !term.IsTerminal(int(os.Stdin.Fd())) {
return "", fmt.Errorf("interactive terminal required for secure input")
}
fmt.Printf("%s %s", Colors.Cyan("▪"), Colors.Bold(label))
raw, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println() // newline after hidden input
if err != nil {
return "", err
}
return strings.TrimSpace(string(raw)), nil
}
func Fatalf(msg string, args ...interface{}) {
ClearStatus()
fmt.Fprintln(os.Stderr, 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)))
}
}
}
func printCooldownPackagesList(packages []models.CooldownBlock) {
for _, pkg := range packages {
fmt.Println()
fmt.Printf(" %s %s\n",
Colors.Yellow("⊘"),
Colors.Yellow(fmt.Sprintf("%s@%s", pkg.Name, pkg.Version)))
dateStr := ""
if !pkg.PublishDate.IsZero() {
dateStr = fmt.Sprintf(" (%s)", pkg.PublishDate.Format("2006-01-02"))
}
fmt.Printf(" %s\n", Colors.Dim(fmt.Sprintf(
"Published %s ago%s — available in %s",
pluralizeDays(pkg.DaysAgo), dateStr, pluralizeDays(pkg.DaysLeft),
)))
fmt.Printf(" %s\n", Colors.Dim(fmt.Sprintf(
"Tip: wait %s for cooldown to expire",
pluralizeDays(pkg.DaysLeft),
)))
}
}
func pluralizeDays(n int) string {
if n == 1 {
return "1 day"
}
return fmt.Sprintf("%d days", n)
}
func pluralizePackages(n int) string {
if n == 1 {
return "1 package"
}
return fmt.Sprintf("%d packages", n)
}
// 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")
}