diff --git a/README.md b/README.md index 9b01726..48d313b 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ reproducible. - Configurable **platform-specific behaviors**. - Automatic software updates using custom logic. - Group software installations into logical "steps" with sophisticated orchestration. +- **Category headers** to visually organize your installers list. --- @@ -162,6 +163,19 @@ For a full breakdown with all the supported options, see The `install` field describes the steps to execute. Each step represents an action or group of actions. Steps can be of **several types**, such as `brew`, `rsync`, `shell`, and more. +You can also add **category headers** to organize your installers visually: + +```yaml +install: + - category: Development Tools + desc: Optional description for the category. + + - name: neovim + type: brew +``` + +See [Installer Configuration](./docs/installer-configuration.md#categories) for more details. + | Field | Type | Description | | ------------------ | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | String (required) | Identifier for the step. It does not have to be unique, but is usually used to check for the app's existence, if applicable (can be overridden using `bin_name`) | diff --git a/appconfig/installer_data.go b/appconfig/installer_data.go index 4e828a7..5acb66c 100644 --- a/appconfig/installer_data.go +++ b/appconfig/installer_data.go @@ -44,6 +44,11 @@ func (s *SkipSummary) UnmarshalYAML(unmarshal func(any) error) error { // InstallerData represents the configuration for a single installer. type InstallerData struct { + // Category is a special field for visual organization. When set, this entry only displays + // a bordered header and does not perform any installation. + Category *string `json:"category" yaml:"category"` + // Desc is an optional description shown below the category name in the bordered header. + Desc *string `json:"desc" yaml:"desc"` // Enabled determines if the installer is enabled. Can be a boolean string ("true", "false") or a condition. Enabled *string `json:"enabled" yaml:"enabled"` // Name is the name of the installer. @@ -118,3 +123,8 @@ func (i *InstallerData) GetTagsList() []string { return strings.TrimSpace(tag) }) } + +// IsCategory returns true if this entry is a category header (not an actual installer). +func (i *InstallerData) IsCategory() bool { + return i.Category != nil && len(*i.Category) > 0 +} diff --git a/appconfig/installer_data_test.go b/appconfig/installer_data_test.go index a14ce30..a1f3ac2 100644 --- a/appconfig/installer_data_test.go +++ b/appconfig/installer_data_test.go @@ -213,3 +213,69 @@ func TestSkipSummary_UnmarshalYAML(t *testing.T) { func parseYAML(yamlStr string, v any) error { return yamlPkg.Unmarshal([]byte(yamlStr), v) } + +func TestInstallerData_IsCategory(t *testing.T) { + t.Run("returns true when Category is set", func(t *testing.T) { + category := "Development Tools" + data := &InstallerData{ + Category: &category, + } + assert.True(t, data.IsCategory()) + }) + + t.Run("returns false when Category is nil", func(t *testing.T) { + data := &InstallerData{} + assert.False(t, data.IsCategory()) + }) + + t.Run("returns false when Category is empty string", func(t *testing.T) { + category := "" + data := &InstallerData{ + Category: &category, + } + assert.False(t, data.IsCategory()) + }) + + t.Run("category parsed from YAML", func(t *testing.T) { + yaml := `category: System Utilities` + var data InstallerData + err := parseYAML(yaml, &data) + assert.NoError(t, err) + assert.True(t, data.IsCategory()) + assert.Equal(t, "System Utilities", *data.Category) + }) + + t.Run("category with desc parsed from YAML", func(t *testing.T) { + yaml := `category: System Utilities +desc: These are system tools.` + var data InstallerData + err := parseYAML(yaml, &data) + assert.NoError(t, err) + assert.True(t, data.IsCategory()) + assert.Equal(t, "System Utilities", *data.Category) + assert.Equal(t, "These are system tools.", *data.Desc) + }) + + t.Run("category with multiline desc parsed from YAML", func(t *testing.T) { + yaml := `category: Development +desc: | + First line. + Second line.` + var data InstallerData + err := parseYAML(yaml, &data) + assert.NoError(t, err) + assert.True(t, data.IsCategory()) + assert.Equal(t, "Development", *data.Category) + assert.Contains(t, *data.Desc, "First line.") + assert.Contains(t, *data.Desc, "Second line.") + }) + + t.Run("regular installer is not a category", func(t *testing.T) { + yaml := `name: test +type: shell` + var data InstallerData + err := parseYAML(yaml, &data) + assert.NoError(t, err) + assert.False(t, data.IsCategory()) + }) +} diff --git a/docs/installer-configuration.md b/docs/installer-configuration.md index 6743baf..a2dc73a 100644 --- a/docs/installer-configuration.md +++ b/docs/installer-configuration.md @@ -3,6 +3,79 @@ The `install` field describes the steps to execute. Each step represents an action or group of actions. Steps can be of **several types**, such as `brew`, `rsync`, `shell`, and more. +## Categories + +You can add **category headers** to visually organize your installers list. Categories are special +entries that display a bordered header in the output but don't perform any installation. + +### Fields + +- **`category`** + + - **Type**: String (required for category entries) + - **Description**: The category name to display. When this field is present, the entry is treated + as a category header, not an installer. + +- **`desc`** + + - **Type**: String (optional) + - **Description**: An optional description shown below the category name. Supports multi-line + text with automatic word wrapping. Existing line breaks are preserved. + +### Example + +```yaml +install: + - category: Development Tools + + - name: neovim + type: brew + + - name: lazygit + type: brew + + - category: System Utilities + desc: Tools for system maintenance and monitoring. + + - name: htop + type: brew + + - category: Configuration + desc: | + These installers sync configuration files from dotfiles. + They run on every execution to keep configs up to date. + + - name: nvim-config + type: rsync + opts: + source: ~/.dotfiles/.config/nvim + destination: ~/.config/nvim +``` + +### Output + +Categories are displayed with a bordered header: + +``` +┌──────────────────────────────────────────────────────────┐ +│ Development Tools │ +└──────────────────────────────────────────────────────────┘ +``` + +With a description: + +``` +┌──────────────────────────────────────────────────────────┐ +│ System Utilities │ +├──────────────────────────────────────────────────────────┤ +│ Tools for system maintenance and monitoring. │ +└──────────────────────────────────────────────────────────┘ +``` + +The box width adapts to narrower terminals (minimum of terminal width or 60 characters). + +--- + ## Fields These fields are shared by all installer types. Some fields may vary in behavior depending on the diff --git a/go.mod b/go.mod index 036f0e2..bdaed29 100644 --- a/go.mod +++ b/go.mod @@ -1,21 +1,19 @@ module github.com/chenasraf/sofmani -go 1.23.0 +go 1.24.0 require ( github.com/davecgh/go-spew v1.1.1 github.com/eschao/config v0.1.0 - github.com/fatih/color v1.18.0 github.com/samber/lo v1.47.0 github.com/stretchr/testify v1.9.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( - github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - golang.org/x/sys v0.25.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/term v0.39.0 // indirect golang.org/x/text v0.16.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 6c9bc86..2a54d9e 100644 --- a/go.sum +++ b/go.sum @@ -3,13 +3,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/eschao/config v0.1.0 h1:vtlNamzs6dC9pE0zyplqql16PFUUlst3VttQ+IT2/rk= github.com/eschao/config v0.1.0/go.mod h1:XMilcx0dPfk+tlJowGZPZdmdCRnd7AZuFhYA93tYBgA= -github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= -github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc= @@ -18,10 +11,10 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= -golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/logger/logger.go b/logger/logger.go index 56e7663..6ae4a9c 100644 --- a/logger/logger.go +++ b/logger/logger.go @@ -9,6 +9,7 @@ import ( "github.com/chenasraf/sofmani/platform" "github.com/davecgh/go-spew/spew" + "golang.org/x/term" ) // Highlight markers (using unlikely byte sequences) @@ -219,3 +220,107 @@ func CloseLogger() { } } } + +// Box-drawing characters for category headers +const ( + boxTopLeft = "┌" + boxTopRight = "┐" + boxBottomLeft = "└" + boxBottomRight = "┘" + boxHorizontal = "─" + boxVertical = "│" + boxLeftT = "├" + boxRightT = "┤" + boxDefaultWidth = 60 +) + +// getBoxWidth returns the width to use for category boxes. +// It uses the terminal width if it's narrower than the default, otherwise uses the default. +func getBoxWidth() int { + width, _, err := term.GetSize(int(os.Stdout.Fd())) + if err != nil || width <= 0 { + return boxDefaultWidth + } + if width < boxDefaultWidth { + return width + } + return boxDefaultWidth +} + +// Category logs a category header with a decorative border. +// If desc is provided, it will be displayed below the category name with auto-wrapping. +func Category(name string, desc *string) { + boxWidth := getBoxWidth() + innerWidth := boxWidth - 4 // Account for "│ " and " │" + + // Build the border lines + horizontalLine := strings.Repeat(boxHorizontal, boxWidth-2) + topBorder := boxTopLeft + horizontalLine + boxTopRight + bottomBorder := boxBottomLeft + horizontalLine + boxBottomRight + separator := boxLeftT + horizontalLine + boxRightT + + // Log the header + Info("") + Info("%s", topBorder) + Info("%s", formatBoxLine(name, innerWidth)) + + // Log description if provided + if desc != nil && len(*desc) > 0 { + Info("%s", separator) + for _, line := range wrapText(*desc, innerWidth) { + Info("%s", formatBoxLine(line, innerWidth)) + } + } + + Info("%s", bottomBorder) + Info("") +} + +// formatBoxLine formats a line of text to fit within the box. +func formatBoxLine(text string, innerWidth int) string { + // Truncate if too long + if len(text) > innerWidth { + text = text[:innerWidth] + } + // Pad to fill the width + padding := strings.Repeat(" ", innerWidth-len(text)) + return boxVertical + " " + text + padding + " " + boxVertical +} + +// wrapText wraps text to fit within maxWidth, respecting existing newlines. +func wrapText(text string, maxWidth int) []string { + var result []string + + // Split by existing newlines first to respect user formatting + for paragraph := range strings.SplitSeq(text, "\n") { + if len(paragraph) == 0 { + result = append(result, "") + continue + } + + // Wrap each paragraph + words := strings.Fields(paragraph) + if len(words) == 0 { + result = append(result, "") + continue + } + + var currentLine string + for _, word := range words { + switch { + case currentLine == "": + currentLine = word + case len(currentLine)+1+len(word) <= maxWidth: + currentLine += " " + word + default: + result = append(result, currentLine) + currentLine = word + } + } + if currentLine != "" { + result = append(result, currentLine) + } + } + + return result +} diff --git a/logger/logger_test.go b/logger/logger_test.go new file mode 100644 index 0000000..afe368f --- /dev/null +++ b/logger/logger_test.go @@ -0,0 +1,162 @@ +package logger + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFormatBoxLine(t *testing.T) { + t.Run("formats short text with padding", func(t *testing.T) { + // formatBoxLine adds: "│ " + text + padding + " │" + // where padding = innerWidth - len(text) + result := formatBoxLine("Hello", 20) + // padding = 20 - 5 = 15 spaces + // result = "│ " + "Hello" + 15 spaces + " │" + assert.Equal(t, "│ Hello │", result) + }) + + t.Run("formats empty string", func(t *testing.T) { + result := formatBoxLine("", 10) + // padding = 10 - 0 = 10 spaces + // result = "│ " + "" + 10 spaces + " │" + assert.Equal(t, "│ │", result) + }) + + t.Run("truncates text longer than width", func(t *testing.T) { + result := formatBoxLine("This is a very long text", 10) + // text truncated to "This is a " (10 chars), padding = 0 + // result = "│ " + "This is a " + "" + " │" + assert.Equal(t, "│ This is a │", result) + }) + + t.Run("formats text exactly at width", func(t *testing.T) { + result := formatBoxLine("1234567890", 10) + // padding = 10 - 10 = 0 spaces + assert.Equal(t, "│ 1234567890 │", result) + }) +} + +func TestWrapText(t *testing.T) { + t.Run("returns single line for short text", func(t *testing.T) { + result := wrapText("Hello world", 20) + assert.Equal(t, []string{"Hello world"}, result) + }) + + t.Run("wraps long text", func(t *testing.T) { + result := wrapText("This is a longer sentence that needs wrapping", 20) + assert.Len(t, result, 3) + assert.Equal(t, "This is a longer", result[0]) + assert.Equal(t, "sentence that needs", result[1]) + assert.Equal(t, "wrapping", result[2]) + }) + + t.Run("respects existing newlines", func(t *testing.T) { + result := wrapText("Line one\nLine two\nLine three", 50) + assert.Equal(t, []string{"Line one", "Line two", "Line three"}, result) + }) + + t.Run("preserves blank lines", func(t *testing.T) { + result := wrapText("First paragraph\n\nSecond paragraph", 50) + assert.Equal(t, []string{"First paragraph", "", "Second paragraph"}, result) + }) + + t.Run("wraps each paragraph separately", func(t *testing.T) { + result := wrapText("Short\nThis is a longer line that will need to wrap", 20) + assert.Len(t, result, 4) + assert.Equal(t, "Short", result[0]) + assert.Equal(t, "This is a longer", result[1]) + assert.Equal(t, "line that will need", result[2]) + assert.Equal(t, "to wrap", result[3]) + }) + + t.Run("handles empty input", func(t *testing.T) { + result := wrapText("", 20) + assert.Equal(t, []string{""}, result) + }) + + t.Run("handles single word longer than width", func(t *testing.T) { + result := wrapText("Supercalifragilisticexpialidocious", 10) + // Single word stays on one line even if longer than width + assert.Equal(t, []string{"Supercalifragilisticexpialidocious"}, result) + }) + + t.Run("handles multiple spaces between words", func(t *testing.T) { + result := wrapText("Word1 Word2 Word3", 50) + // strings.Fields collapses multiple spaces + assert.Equal(t, []string{"Word1 Word2 Word3"}, result) + }) +} + +func TestBoxDrawingConstants(t *testing.T) { + t.Run("default box width is 60", func(t *testing.T) { + assert.Equal(t, 60, boxDefaultWidth) + }) + + t.Run("box characters are correct", func(t *testing.T) { + assert.Equal(t, "┌", boxTopLeft) + assert.Equal(t, "┐", boxTopRight) + assert.Equal(t, "└", boxBottomLeft) + assert.Equal(t, "┘", boxBottomRight) + assert.Equal(t, "─", boxHorizontal) + assert.Equal(t, "│", boxVertical) + assert.Equal(t, "├", boxLeftT) + assert.Equal(t, "┤", boxRightT) + }) +} + +func TestGetBoxWidth(t *testing.T) { + t.Run("returns positive width", func(t *testing.T) { + width := getBoxWidth() + assert.Greater(t, width, 0) + }) + + t.Run("returns at most default width", func(t *testing.T) { + width := getBoxWidth() + assert.LessOrEqual(t, width, boxDefaultWidth) + }) +} + +func TestHighlight(t *testing.T) { + t.Run("wraps text with highlight markers", func(t *testing.T) { + result := Highlight("test") + assert.Equal(t, highlightStart+"test"+highlightEnd, result) + }) + + t.Run("H is alias for Highlight", func(t *testing.T) { + assert.Equal(t, Highlight("test"), H("test")) + }) +} + +func TestStripHighlightMarkers(t *testing.T) { + t.Run("removes highlight markers", func(t *testing.T) { + input := highlightStart + "highlighted" + highlightEnd + " normal" + result := stripHighlightMarkers(input) + assert.Equal(t, "highlighted normal", result) + }) + + t.Run("handles text without markers", func(t *testing.T) { + result := stripHighlightMarkers("plain text") + assert.Equal(t, "plain text", result) + }) + + t.Run("handles multiple highlighted sections", func(t *testing.T) { + input := highlightStart + "one" + highlightEnd + " and " + highlightStart + "two" + highlightEnd + result := stripHighlightMarkers(input) + assert.Equal(t, "one and two", result) + }) +} + +func TestProcessHighlights(t *testing.T) { + t.Run("converts markers to ANSI codes", func(t *testing.T) { + input := highlightStart + "text" + highlightEnd + result := processHighlights(input, ansiBlueBold) + assert.Equal(t, ansiHighlight+"text"+ansiReset+ansiBlueBold, result) + }) + + t.Run("handles empty base color", func(t *testing.T) { + input := highlightStart + "text" + highlightEnd + result := processHighlights(input, "") + assert.Equal(t, ansiHighlight+"text"+ansiReset, result) + }) +} diff --git a/machine/machine_id.go b/machine/machine_id.go index ea66565..87d50db 100644 --- a/machine/machine_id.go +++ b/machine/machine_id.go @@ -66,8 +66,7 @@ func getMachineIDDarwin() (string, error) { } // Parse the IOPlatformUUID from the output - lines := strings.Split(string(output), "\n") - for _, line := range lines { + for line := range strings.SplitSeq(string(output), "\n") { if strings.Contains(line, "IOPlatformUUID") { // Format: "IOPlatformUUID" = "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" parts := strings.Split(line, "=") @@ -115,8 +114,7 @@ func getMachineIDWindows() (string, error) { } // Parse the MachineGuid from the output - lines := strings.Split(string(output), "\n") - for _, line := range lines { + for line := range strings.SplitSeq(string(output), "\n") { if strings.Contains(line, "MachineGuid") { fields := strings.Fields(line) if len(fields) >= 3 { diff --git a/main.go b/main.go index e103531..72e67c1 100644 --- a/main.go +++ b/main.go @@ -84,11 +84,26 @@ func main() { } logger.Info("Checking all installers...") - instances := []installer.IInstaller{} + + // First pass: validate all installers (skip category entries) + type installItem struct { + installer installer.IInstaller + isCategory bool + data *appconfig.InstallerData + } + items := []installItem{} hasValidationErrors := false - for _, i := range cfg.Install { - installerInstance, err := installer.GetInstaller(cfg, &i) + for idx := range cfg.Install { + i := &cfg.Install[idx] + + // Handle category entries specially - they don't need validation + if i.IsCategory() { + items = append(items, installItem{isCategory: true, data: i}) + continue + } + + installerInstance, err := installer.GetInstaller(cfg, i) if err != nil { logger.Error("%s", err) return @@ -100,10 +115,10 @@ func main() { if len(errors) > 0 { hasValidationErrors = true for _, e := range errors { - logger.Error(e.Error()) + logger.Error("%s", e.Error()) } } else { - instances = append(instances, installerInstance) + items = append(items, installItem{installer: installerInstance, data: i}) } } } @@ -119,8 +134,8 @@ func main() { interrupted := false installSummary := summary.NewSummary() - for _, i := range instances { - // Check for interrupt before each installer + for _, item := range items { + // Check for interrupt before each item select { case <-sigChan: interrupted = true @@ -131,7 +146,13 @@ func main() { break } - result, err := installer.RunInstaller(cfg, i) + // Handle category entries - just log the header + if item.isCategory { + logger.Category(*item.data.Category, item.data.Desc) + continue + } + + result, err := installer.RunInstaller(cfg, item.installer) if err != nil { logger.Error("%s", err) break