From 14d8fbbab1e26329273214d078fb9d603200961b Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Fri, 17 Jan 2025 00:31:09 +0200 Subject: [PATCH] feat: tag field --- README.md | 2 + ...pconfig_installer.go => installer_data.go} | 10 ++ docs/README.md | 7 +- ...g-started.md => command-line-interface.md} | 80 ++++++---------- ...eference.md => configuration-reference.md} | 0 docs/getting-started.md | 45 +++++++++ ...-installer-types.md => installer-types.md} | 7 ++ go.mod | 2 + go.sum | 4 + installer/filter.go | 56 ++++++++--- installer/filter_test.go | 93 ++++++++++++++++--- installer/installer.go | 24 +++-- installer/installer_test.go | 8 +- 13 files changed, 238 insertions(+), 100 deletions(-) rename appconfig/{appconfig_installer.go => installer_data.go} (88%) rename docs/{01-getting-started.md => command-line-interface.md} (53%) rename docs/{02-configuration-reference.md => configuration-reference.md} (100%) create mode 100644 docs/getting-started.md rename docs/{03-installer-types.md => installer-types.md} (96%) diff --git a/README.md b/README.md index d3d4d14..5dfdb4c 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,8 @@ If a configuration file is not explicitly provided, `sofmani` attempts to locate If no file is found or provided, sofmani will fail to start. +For more information, see [Configuration Reference](./docs/02-configuration-reference.md) + --- ## 📚 Configuration Reference diff --git a/appconfig/appconfig_installer.go b/appconfig/installer_data.go similarity index 88% rename from appconfig/appconfig_installer.go rename to appconfig/installer_data.go index 587e3c6..3d9f5e5 100644 --- a/appconfig/appconfig_installer.go +++ b/appconfig/installer_data.go @@ -1,13 +1,17 @@ package appconfig import ( + "strings" + "github.com/chenasraf/sofmani/platform" "github.com/chenasraf/sofmani/utils" + "github.com/samber/lo" ) type InstallerData struct { Name *string `json:"name" yaml:"name"` Type InstallerType `json:"type" yaml:"type"` + Tags *string `json:"tags" yaml:"tags"` Env *map[string]string `json:"env" yaml:"env"` PlatformEnv *platform.PlatformMap[map[string]string] `json:"platform_env" yaml:"platform_env"` Platforms *platform.Platforms `json:"platforms" yaml:"platforms"` @@ -41,3 +45,9 @@ const ( func (i *InstallerData) Environ() []string { return utils.EnvMapAsSlice(utils.CombineEnvMaps(i.Env, i.PlatformEnv.Resolve())) } + +func (i *InstallerData) GetTagsList() []string { + return lo.Map(strings.Split(*i.Tags, " "), func(tag string, i int) string { + return strings.TrimSpace(tag) + }) +} diff --git a/docs/README.md b/docs/README.md index b42f3ff..c0f8c53 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,7 +4,8 @@ For a general overview, see the [README](/README.md). **Docs Table of Contents** -- [Getting Started](./01-getting-started.md) -- [Configuration Reference](./02-configuration-reference.md) -- [Installer Types](./03-installer-types.md) +- [Getting Started](./getting-started.md) +- [Command Line Interface (CLI)](./command-line-interface.md) +- [Configuration Reference](./configuration-reference.md) +- [Installer Types](./installer-types.md) - [Recipes](./recipes) - Installer groups you can use immediately as remote manifests diff --git a/docs/01-getting-started.md b/docs/command-line-interface.md similarity index 53% rename from docs/01-getting-started.md rename to docs/command-line-interface.md index ea32f1c..a8c3138 100644 --- a/docs/01-getting-started.md +++ b/docs/command-line-interface.md @@ -1,50 +1,4 @@ -# Getting Started - -## Installation - -### Download Precompiled Binaries - -Precompiled binaries for `sofmani` are available for **Linux**, **macOS**, and **Windows**: - -- Visit the [Releases Page](https://github.com/chenasraf/sofmani/releases/latest) to download the - latest version for your platform. - -### Homebrew (macOS/Linux only) - -Install from a custom tap: - -```bash -brew install chenasraf/tap/sofmani -``` - ---- - -### Linux - -You can install `sofmani` by downloading the release tar, and extracting it to your preferred -location. - -- You can see an example script for install here: [install.sh](/install.sh) -- The example script can be used for actual install, use this command to download and execute the - file (use at your own discretion): - - ```sh - curl https://raw.githubusercontent.com/chenasraf/sofmani/master/install.sh | sh - ``` - -## Config file location - -The config file can be in YAML or JSON format. - -You can place the config file anywhere, and provide the path to sofmani CLI to load. If you don't -give it an explicit path, the CLI will attempt to find a `sofmani.yml` or `sofmani.json` file ine -the following directories (ordered by priority): - -1. Current working directory -1. `$HOME/.config` directory -1. Home directory - -## Using sofmani +# Command Line Interface (CLI) The sofmani CLI will iterate through each of your install steps (called "Installers") and execute them in sequence. @@ -53,7 +7,7 @@ Installers can be grouped and nested arbitrarily, and loaded either directly fro loaded from an external additional config, either a local file or a remote file hosted on a git repository. -### CLI Flags +## CLI Flags You can call `sofmani` with the following flags to alter the behavior for the current run: @@ -71,14 +25,32 @@ Each of these flags overrides the loaded config file, so while your default conf check for updates by default, you or another user can add the `--update` flag to override this behavior for a single run of the CLI. -\* The filter argument accepts multiple values. +### Installer Filters + +The filter argument accepts multiple values. + +The following filter types are available: + +- `-f ` - filter by name +- `-f tag:` - filter by tag name +- `-f type:` - filter by type (brew, shell, etc) + +Each of the above filters can be negated by prefixing with `!`. For example, to exclude installers +containing the tag `"system"`, use `-f "!tag:system"`. See more information about tags in the +documentation for (Installer Types)[./installer-types.md#fields]. + +If there are no filters in the command flags, then all the installers will run. + +If there are filters, each installer will have to match an inclusion filter. Exclusion filters can +be combined to then remove from the filtered installers, even if they already matched to be +included. - To only run installers that contain "sofmani" in their name, use `-f sofmani`. - To run all installers except those that contain "sofmani", use `-f "!sofmani"`. -- To only installers that contain "sofmani", but exclude "sofmani-config", use - `-f sofmani -f "!sofmani-config"`. +- To only installers that contain "sofmani", but exclude ones tagged "config", use + `-f sofmani -f "!tag:config"`. -#### Examples +## Examples Search for the config in one of the default directories, and enable update checking: @@ -92,8 +64,8 @@ Load a specific config file, and enable debug mode: sofmani -d sofmani.yml ``` -Load a config file, and only run installers matching `brew` in their name: +Load a config file, and only run installers of type `brew`: ```sh -sofmani -f brew sofmani.yml +sofmani -f type:brew sofmani.yml ``` diff --git a/docs/02-configuration-reference.md b/docs/configuration-reference.md similarity index 100% rename from docs/02-configuration-reference.md rename to docs/configuration-reference.md diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..47d9a4a --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,45 @@ +# Getting Started + +## Installation + +### Download Precompiled Binaries + +Precompiled binaries for `sofmani` are available for **Linux**, **macOS**, and **Windows**: + +- Visit the [Releases Page](https://github.com/chenasraf/sofmani/releases/latest) to download the + latest version for your platform. + +### Homebrew (macOS/Linux only) + +Install from a custom tap: + +```bash +brew install chenasraf/tap/sofmani +``` + +--- + +### Linux + +You can install `sofmani` by downloading the release tar, and extracting it to your preferred +location. + +- You can see an example script for install here: [install.sh](/install.sh) +- The example script can be used for actual install, use this command to download and execute the + file (use at your own discretion): + + ```sh + curl https://raw.githubusercontent.com/chenasraf/sofmani/master/install.sh | sh + ``` + +## Config file location + +The config file can be in YAML or JSON format. + +You can place the config file anywhere, and provide the path to sofmani CLI to load. If you don't +give it an explicit path, the CLI will attempt to find a `sofmani.yml` or `sofmani.json` file ine +the following directories (ordered by priority): + +1. Current working directory +1. `$HOME/.config` directory +1. Home directory diff --git a/docs/03-installer-types.md b/docs/installer-types.md similarity index 96% rename from docs/03-installer-types.md rename to docs/installer-types.md index 3f7d0f8..ac38740 100644 --- a/docs/03-installer-types.md +++ b/docs/installer-types.md @@ -20,6 +20,13 @@ These fields are shared by all installer types. Some fields may vary in behavior - **Description**: Type of the step. See [supported types](#supported-type-of-installers) for a comprehensive list of supported values. +- **`tags`** + + - **Type** String (optional) + - **Description**: Arbitrary tags to attach to an installer. These can later be used to filter + this installer in or out when running sofmani. This should be a string containing + space-separated tags. + - **`platforms`** - **Type**: Object (optional) diff --git a/go.mod b/go.mod index cbef31f..036f0e2 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ 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 ) @@ -14,6 +15,7 @@ require ( 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/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 6628d85..6c9bc86 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE 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= +github.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 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= @@ -20,6 +22,8 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc 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/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= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/installer/filter.go b/installer/filter.go index bd95662..342c618 100644 --- a/installer/filter.go +++ b/installer/filter.go @@ -2,26 +2,58 @@ package installer import ( "strings" + + "github.com/samber/lo" ) -func FilterIsMatch(filters []string, name string) bool { +func FilterInstaller(installer IInstaller, filters []string) bool { if len(filters) == 0 { return true } - match := false - for _, f := range filters { - if strings.HasPrefix(f, "!") { - continue - } - if strings.Contains(name, f) { - match = true + positives := lo.Filter(filters, func(filter string, i int) bool { + return filter[0] != '!' + }) + + negatives := lo.FilterMap(filters, func(filter string, i int) (string, bool) { + return filter[1:], filter[0] == '!' + }) + + keep := false + if len(positives) == 0 { + keep = true + } + + for _, f := range positives { + if isFilteredIn(installer, f) { + keep = true break } } - for _, f := range filters { - if strings.HasPrefix(f, "!") && strings.Contains(name, f[1:]) { - return false + for _, f := range negatives { + if isFilteredIn(installer, f) { + keep = false + break } } - return match + + return keep +} + +func isFilteredIn(installer IInstaller, filter string) bool { + data := installer.GetData() + if strings.HasPrefix(filter, "type:") { + typeName := filter[len("type:"):] + if strings.ToLower(string(data.Type)) == strings.ToLower(typeName) { + return true + } + } + if strings.HasPrefix(filter, "tag:") { + tagName := filter[len("tag:"):] + if lo.SomeBy(data.GetTagsList(), func(tag string) bool { + return strings.ToLower(tag) == tagName + }) { + return true + } + } + return strings.Contains(*data.Name, filter) } diff --git a/installer/filter_test.go b/installer/filter_test.go index f0a1325..4b8eae8 100644 --- a/installer/filter_test.go +++ b/installer/filter_test.go @@ -3,29 +3,94 @@ package installer import ( "testing" + "github.com/chenasraf/sofmani/appconfig" "github.com/stretchr/testify/assert" ) -func TestFilterIsMatch(t *testing.T) { +func TestFilterInstaller(t *testing.T) { tests := []struct { - name string - filters []string - input string - expected bool + name string + installer IInstaller + filters []string + expected bool }{ - {"No filters", []string{}, "test", true}, - {"Match found", []string{"test"}, "test", true}, - {"No match", []string{"example"}, "test", false}, - {"Negation filter", []string{"!test"}, "test", false}, - {"Negation filter with match", []string{"example", "!test"}, "test", false}, - {"Negation filter without match", []string{"example", "!test"}, "example", true}, - {"Negation on included filter", []string{"example", "!example-test"}, "example-test", false}, - {"Partial match", []string{"config"}, "example-config", true}, + { + name: "No filters", + installer: &MockInstaller{ + data: &appconfig.InstallerData{Name: strPtr("test"), Tags: strPtr("tag1")}, + }, + filters: []string{}, + expected: true, + }, + { + name: "Positive filter match", + installer: &MockInstaller{ + data: &appconfig.InstallerData{Name: strPtr("test"), Tags: strPtr("tag1")}, + }, + filters: []string{"test"}, + expected: true, + }, + { + name: "Positive filter no match", + installer: &MockInstaller{ + data: &appconfig.InstallerData{Name: strPtr("test"), Tags: strPtr("tag1")}, + }, + filters: []string{"example"}, + expected: false, + }, + { + name: "Negative filter match", + installer: &MockInstaller{ + data: &appconfig.InstallerData{Name: strPtr("test"), Tags: strPtr("tag1")}, + }, + filters: []string{"!test"}, + expected: false, + }, + { + name: "Negative filter no match", + installer: &MockInstaller{ + data: &appconfig.InstallerData{Name: strPtr("test"), Tags: strPtr("tag1")}, + }, + filters: []string{"!example"}, + expected: true, + }, + { + name: "Tag filter match", + installer: &MockInstaller{ + data: &appconfig.InstallerData{Name: strPtr("test"), Tags: strPtr("tag1")}, + }, + filters: []string{"tag:tag1"}, + expected: true, + }, + { + name: "Tag filter no match", + installer: &MockInstaller{ + data: &appconfig.InstallerData{Name: strPtr("test"), Tags: strPtr("tag1")}, + }, + filters: []string{"tag:tag2"}, + expected: false, + }, + { + name: "Type filter match", + installer: &MockInstaller{ + data: &appconfig.InstallerData{Name: strPtr("test"), Type: appconfig.InstallerTypeBrew}, + }, + filters: []string{"type:brew"}, + expected: true, + }, + { + name: "Type filter no match", + installer: &MockInstaller{ + data: &appconfig.InstallerData{Name: strPtr("test"), Type: appconfig.InstallerTypeBrew}, + }, + filters: []string{"type:npm"}, + expected: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := FilterIsMatch(tt.filters, tt.input) + result := FilterInstaller(tt.installer, tt.filters) assert.Equal(t, tt.expected, result) }) } diff --git a/installer/installer.go b/installer/installer.go index 1f6f35b..fcd5ae3 100644 --- a/installer/installer.go +++ b/installer/installer.go @@ -112,21 +112,16 @@ func InstallerWithDefaults( func FillDefaults(data *appconfig.InstallerData) { if data.Env == nil { - env := make(map[string]string) - data.Env = &env + data.Env = &map[string]string{} } if data.Opts == nil { - opts := make(map[string]any) - data.Opts = &opts + data.Opts = &map[string]any{} } if data.PlatformEnv == nil { - mapLinux := map[string]string{} - mapMacos := map[string]string{} - mapWindows := map[string]string{} env := platform.PlatformMap[map[string]string]{ - MacOS: &mapMacos, - Linux: &mapLinux, - Windows: &mapWindows, + MacOS: &map[string]string{}, + Linux: &map[string]string{}, + Windows: &map[string]string{}, } data.PlatformEnv = &env } @@ -135,8 +130,11 @@ func FillDefaults(data *appconfig.InstallerData) { data.Platforms = &platforms } if data.Steps == nil { - steps := make([]appconfig.InstallerData, 0) - data.Steps = &steps + data.Steps = &[]appconfig.InstallerData{} + } + if data.Tags == nil { + str := "" + data.Tags = &str } } @@ -150,7 +148,7 @@ func RunInstaller(config *appconfig.AppConfig, installer IInstaller) error { logger.Debug("%s should not run on %s, skipping", name, curOS) return nil } - if !FilterIsMatch(config.Filter, name) { + if !FilterInstaller(installer, config.Filter) { logger.Debug("%s is filtered, skipping", name) return nil } diff --git a/installer/installer_test.go b/installer/installer_test.go index 6faaa9b..137d436 100644 --- a/installer/installer_test.go +++ b/installer/installer_test.go @@ -10,7 +10,7 @@ import ( ) type MockInstaller struct { - info *appconfig.InstallerData + data *appconfig.InstallerData isInstalled bool needsUpdate bool installError error @@ -20,7 +20,7 @@ type MockInstaller struct { } func (m *MockInstaller) GetData() *appconfig.InstallerData { - return m.info + return m.data } func (m *MockInstaller) CheckIsInstalled() (error, bool) { @@ -63,7 +63,7 @@ func TestInstallerWithDefaults(t *testing.T) { func TestRunInstaller(t *testing.T) { config := &appconfig.AppConfig{} mockInstaller := &MockInstaller{ - info: &appconfig.InstallerData{Name: strPtr("test"), Type: appconfig.InstallerTypeBrew}, + data: &appconfig.InstallerData{Name: strPtr("test"), Type: appconfig.InstallerTypeBrew}, isInstalled: false, } err := RunInstaller(config, mockInstaller) @@ -72,7 +72,7 @@ func TestRunInstaller(t *testing.T) { func TestGetShouldRunOnOS(t *testing.T) { installer := &MockInstaller{ - info: &appconfig.InstallerData{ + data: &appconfig.InstallerData{ Platforms: &platform.Platforms{ Only: &[]platform.Platform{platform.PlatformMacos}, },