feat: tag field

This commit is contained in:
2025-01-17 00:31:09 +02:00
parent d7d251b679
commit 14d8fbbab1
13 changed files with 238 additions and 100 deletions

View File

@@ -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

View File

@@ -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)
})
}

View File

@@ -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

View File

@@ -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 <name>` - filter by name
- `-f tag:<tag>` - filter by tag name
- `-f type:<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
```

45
docs/getting-started.md Normal file
View File

@@ -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

View File

@@ -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)

2
go.mod
View File

@@ -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
)

4
go.sum
View File

@@ -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=

View File

@@ -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)
}

View File

@@ -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)
})
}

View File

@@ -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
}

View File

@@ -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},
},