feat: Refactor PMG to Maintain Separation of Concerns and Clean Architecture (#19)

* feat: Add separate package manager and resolver

* fix: Npm dependency resolver

* feat: Add analyzer for malysis query

* feat: Add package manager guard as the orchestrator

* feat: Add PMG to orchestrate installation

* Add concurrent scan execution

* Introduce package manager interaction abstraction

* feat: Add UI port for guard

* Remove refactored source files

* Update README

* fix: CI script for multi-arch build

* ci: goreleaser CI fix

* fix: npm command parser to extract package names

* feat: Introduce global config primitive

* fix: Close results channel for clean goroutine exit

* ci: Add container image releaser

* test: Improve test for npm resolver

* refactor: Analyzer to generalise

* Improve UI with additional info

* fix: Goreleaser config

* fix: npm resolver bug

* fix: Fail when command exec workflow fails

* fix: Bug with transitive dependency resolution

* fix: Synchronize common data update in dependency resolver

* chore: Improve log handling

* docs: Update README

* fix: UI text wrapping

* fix: UI handling bugs

* feat: Use concurrent dependency resolver
This commit is contained in:
Abhisek Datta
2025-05-15 16:50:59 +05:30
committed by GitHub
parent 8b46964c7a
commit e86b6ef056
47 changed files with 2562 additions and 1788 deletions
+21
View File
@@ -0,0 +1,21 @@
package ui
import "github.com/fatih/color"
type ColorFn func(format string, a ...interface{}) string
type TerminalColors struct {
Normal ColorFn
Red ColorFn
Yellow ColorFn
Cyan ColorFn
Green ColorFn
}
var Colors = TerminalColors{
Normal: color.New().SprintfFunc(),
Red: color.New(color.FgRed, color.Bold).SprintfFunc(),
Yellow: color.New(color.FgYellow).SprintfFunc(),
Cyan: color.New(color.FgCyan).SprintfFunc(),
Green: color.New(color.FgGreen).SprintfFunc(),
}
+53
View File
@@ -0,0 +1,53 @@
package ui
import (
"fmt"
"time"
)
var spinnerChan chan bool
func StartSpinner(msg string) {
StartSpinnerWithColor(msg, Colors.Normal)
}
func StartSpinnerWithColor(msg string, c ColorFn) {
if c == nil {
c = Colors.Normal
}
style := `⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏`
frames := []rune(style)
length := len(frames)
spinnerChan = make(chan bool)
ticker := time.NewTicker(100 * time.Millisecond)
go func() {
pos := 0
for {
select {
case <-spinnerChan:
ticker.Stop()
return
case <-ticker.C:
fmt.Printf("\r%s ... %s", c(msg), string(frames[pos%length]))
pos += 1
}
}
}()
}
func StopSpinner() {
// Gracefully handle the case where the spinner is already stopped
// and the channel is closed, yet client code calls StopSpinner() again.
defer func() {
_ = recover()
}()
close(spinnerChan)
fmt.Printf("\r")
fmt.Println()
}
+150
View File
@@ -0,0 +1,150 @@
package ui
import (
"fmt"
"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
)
var verbosityLevel VerbosityLevel = VerbosityLevelNormal
func SetVerbosityLevel(level VerbosityLevel) {
verbosityLevel = level
}
func ClearStatus() {
StopSpinner()
fmt.Print("\r")
}
func Block(malwarePackages ...*analyzer.PackageVersionAnalysisResult) error {
StopSpinner()
fmt.Println()
fmt.Println(Colors.Red("❌ Malicious package blocked!"))
printMaliciousPackagesList(malwarePackages)
fmt.Println()
os.Exit(1)
return nil
}
func SetStatus(status string) {
if verbosityLevel == VerbosityLevelSilent {
return
}
StopSpinner()
StartSpinnerWithColor(fmt.Sprintf("️ %s", status), Colors.Green)
}
func GetConfirmationOnMalware(malwarePackages []*analyzer.PackageVersionAnalysisResult) (bool, error) {
StopSpinner()
fmt.Println()
fmt.Println(Colors.Red(fmt.Sprintf("🚨 Suspicious package(s) detected: %d", len(malwarePackages))))
printMaliciousPackagesList(malwarePackages)
fmt.Println()
fmt.Print(Colors.Yellow("Do you want to continue with the installation? (y/N) "))
var response string
// We don't care about the error here because we will return false
// if the user doesn't provide a valid response
_, _ = fmt.Scanln(&response)
if len(response) == 0 {
return false, nil
}
response = strings.ToLower(response)
if response == "y" || response == "yes" || response[0] == 'y' {
return true, nil
}
return false, nil
}
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.Println("⚠️ ", Colors.Red(fmt.Sprintf("%s@%s", mp.PackageVersion.GetPackage().GetName(),
mp.PackageVersion.GetVersion())))
if verbosityLevel == VerbosityLevelVerbose {
fmt.Println(Colors.Yellow(termWidthFormatText(mp.Summary, 80)))
if mp.ReferenceURL != "" {
fmt.Println()
fmt.Println(Colors.Yellow(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")
}
+79
View File
@@ -0,0 +1,79 @@
package ui
import (
"testing"
)
// TestTermWidthFormatText is exported for testing
func TestTermWidthFormatTextFunc(t *testing.T) {
tests := []struct {
name string
text string
maxWidth int
expected string
}{
{
name: "empty string",
text: "",
maxWidth: 10,
expected: "",
},
{
name: "single word less than max width",
text: "hello",
maxWidth: 10,
expected: "hello",
},
{
name: "single word longer than max width",
text: "supercalifragilisticexpialidocious",
maxWidth: 10,
expected: "supercalifragilisticexpialidocious",
},
{
name: "multiple words on single line",
text: "hello world",
maxWidth: 20,
expected: "hello world",
},
{
name: "multiple words wrapped to multiple lines",
text: "The quick brown fox jumps over the lazy dog",
maxWidth: 20,
expected: "The quick brown fox\njumps over the lazy\ndog",
},
{
name: "text with existing newlines",
text: "hello\nworld",
maxWidth: 20,
expected: "hello world",
},
{
name: "text with multiple spaces",
text: "hello world test",
maxWidth: 20,
expected: "hello world test",
},
{
name: "very small max width",
text: "hello world",
maxWidth: 3,
expected: "hello\nworld",
},
{
name: "large max width",
text: "The quick brown fox jumps over the lazy dog",
maxWidth: 100,
expected: "The quick brown fox jumps over the lazy dog",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := termWidthFormatText(tt.text, tt.maxWidth)
if result != tt.expected {
t.Errorf("termWidthFormatText() = %q, want %q", result, tt.expected)
}
})
}
}