├── .github └── workflows │ └── goreleaser.yml ├── LICENSE ├── README.md ├── go.mod ├── go.sum ├── main.go ├── main_test.go ├── v1 ├── v1.go └── v1_test.go └── v2 ├── v2.go ├── v2_test.go └── v2_zsh_test.go /.github/workflows/goreleaser.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | pull_request: 5 | push: 6 | tags: 7 | - '*' 8 | 9 | permissions: 10 | contents: write 11 | 12 | jobs: 13 | goreleaser: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - 17 | name: Checkout 18 | uses: actions/checkout@v4 19 | with: 20 | fetch-depth: 0 21 | - 22 | name: Set up Go 23 | uses: actions/setup-go@v5 24 | - 25 | name: Run GoReleaser 26 | uses: goreleaser/goreleaser-action@v6 27 | with: 28 | distribution: goreleaser 29 | version: '~> v2' 30 | args: release --clean 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Kate Parkhurst 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dex - Directory Exec 2 | 3 | **dex** is a tool to help you execute sets of commands in your project directories. 4 | 5 | Similar in functionality to **make**, with a much simpler YAML file to define your commands in. It supports nested commands and displays a menu of commands for your current directory. 6 | 7 | **dex** is written in Go, so you just need to install a single binary with no dependencies. 8 | 9 | 10 | ## Installation 11 | 12 | ### Install binary from releases 13 | 14 | 1. Download the latest version [from the releases page](https://github.com/symkat/dex/releases). 15 | 2. Unpack and copy dex to /usr/local/bin, as shown below. 16 | 17 | ``` 18 | $ tar -xzf dex_*.tar.gz 19 | $ sudo cp dex /usr/local/bin/dex 20 | ``` 21 | 22 | ### Build & install from source 23 | ``` 24 | $ git clone https://github.com/symkat/dex.git 25 | $ cd dex 26 | $ go build -o dex main.go 27 | $ sudo cp dex /usr/local/bin/dex 28 | ``` 29 | 30 | ### User-only installation 31 | 32 | Each of the above installation methods will make **dex** available for all users on the system, but requires root permission. 33 | 34 | You can also copy **dex** into ~/bin and make sure that ~/bin is in your $PATH by adding `export PATH="$HOME/bin:$PATH"` to your `~/.bashrc` or `~/.bash_profile` file and restarting your terminal or running `source ~/.bashrc`. 35 | 36 | ## DexFile 37 | 38 | The commands for your project directory are stored in a DexFile, **dex** will check for commands defined in `dex.yaml`, `dex.yml`, `.dex.yaml`, or `.dex.yml` in the current directory. The first one of these files found is the one used. 39 | 40 | The format of the dex file is: 41 | 42 | ```YAML 43 | - name: build 44 | desc: This command will build the project 45 | shell: 46 | - first command to run 47 | - second command to run 48 | ``` 49 | 50 | This file would run `first command to run` and `second command to run` when invoked with `dex build`. 51 | 52 | If you run `dex` it would show the menu 53 | 54 | ``` 55 | $ dex 56 | build : This command will build the project 57 | ``` 58 | 59 | **dex** also supports nested commands. 60 | 61 | Let's build a DexFile to support running ansible from our project directory to explore nested commands. 62 | 63 | ### DexFile for ansible 64 | 65 | When initially developing a project and deploying it with Ansible, I'll use a file like this: 66 | 67 | ```YAML 68 | - name: dev 69 | desc: "Commands on the development machine." 70 | children: 71 | - name: run-playbook 72 | desc: "Run the ansible playbook on the development machine." 73 | shell: 74 | - ansible-playbook -i env/dev/inventory.yml --vault-password-file .vault_password -e @env/dev/vault.yml site.yml 75 | - name: edit-vault 76 | desc: "Edit the vault file." 77 | shell: 78 | - ansible-vault edit --vault-password-file .vault_password env/dev/vault.yml 79 | - name: encrypt-vault 80 | desc: "Encrypt the vault file." 81 | shell: 82 | - ansible-vault encrypt --vault-password-file .vault_password env/dev/vault.yml 83 | - name: decrypt-vault 84 | desc: "Decrypt the vault file." 85 | shell: 86 | - ansible-vault decrypt --vault-password-file .vault_password env/dev/vault.yml 87 | - name: prod 88 | desc: "Manage the production cluster." 89 | children: 90 | - name: run-playbook 91 | desc: "Run the ansible playbook on the development machine." 92 | shell: 93 | - ansible-playbook -i env/prod/inventory.yml --vault-password-file .vault_password -e @env/prod/vault.yml site.yml 94 | - name: edit-vault 95 | desc: "Edit the vault file." 96 | shell: 97 | - ansible-vault edit --vault-password-file .vault_password env/prod/vault.yml 98 | ``` 99 | 100 | Commands under **dev** work for my development environment, while commands under **prod** work for my production environment. 101 | 102 | By codifying the commands in a DexFile I can use simple commands like `dex dev encrypt-vault` to encrypt my fault file, and `dex dev run-playbook` to install my project in my development environment. When I'm ready to deploy to production, I can run `dex prod run-playbook`. 103 | 104 | ``` 105 | $ dex 106 | dev : Commands on the development machine. 107 | run-playbook : Run the ansible playbook on the development machine. 108 | edit-vault : Edit the vault file. 109 | encrypt-vault : Encrypt the vault file. 110 | decrypt-vault : Decrypt the vault file. 111 | prod : Manage the production cluster. 112 | run-playbook : Run the ansible playbook on the development machine. 113 | edit-vault : Edit the vault file. 114 | ``` 115 | ### Home Directory DexFile 116 | 117 | You can keep a DexFile in your home directory to store global commands you might want to use outside a project directory. To use this DexFile just set `~~` as the first parameter in your command list. 118 | 119 | ### Config File Version 2 120 | 121 | `dex` now has a new configuration format. The existing format is still supported and will function the same, but using this new format adds some new options and features that allow you to run more dynamic commands. 122 | 123 | ```YAML 124 | version: 2 125 | vars: 126 | root_var: 'I can be used in every block' 127 | some_list: 128 | - 'this' 129 | - 'that' 130 | work_dir: 131 | from-command: pwd | tr -d '\n' 132 | 133 | blocks: 134 | - name: var-example 135 | desc: An Example block command with global and block variables. 136 | vars: 137 | some_string: 'for this block only' 138 | env_var: 139 | from-env: SECOND_CMD 140 | default: 0 141 | commands: 142 | - exec: echo 'Global var work_dir: [% work_dir %], block variable [% some_string %] ' 143 | - exec: echo 'SECOND_CMD is set' 144 | condition: [%env_var%] -eq 1 145 | - name: loop-example 146 | desc: An Example block command that looks over a list var. 147 | commands: 148 | - exec: echo 'repeating command with variable [% var %] 149 | for-vars: some_list 150 | ``` 151 | 152 | The root `vars` attribute defines variables that can be used in any block by enclosing the name of the variable 153 | within `[%` and `%]`. These variables can be a string, number a list containing a combination of either. 154 | 155 | ```YAML 156 | vars: 157 | string_var: 'I can be used in every block' 158 | number:var: 23423 159 | list_var: 160 | - 'foo' 161 | - 'bar' 162 | - 34 163 | ``` 164 | 165 | You can also configure variables to be initialized from the output of an external command or by referencing an environment variable. 166 | 167 | ```YAML 168 | vars: 169 | perl5_version: 170 | from-command: "perl -MConfig -e 'print $Config{version}'" 171 | default: 'command failed' 172 | perl5_lib: 173 | from-env: PERL5LIB 174 | default: 'NO PERL5LIB SET' 175 | 176 | ``` 177 | 178 | The `from-command` attribute will execute the set command and, assuming the command exits with a value of 0, assign its' STDOUT to the value of the variable. If the command returns multiple lines the variable will become a list containing 179 | each line. If the command exits with a non-zero value then the variable will be assigned the `default` attribute value 180 | or remain undefined if no 'default' attribute is provided. 181 | 182 | `from-env` will check for a matching environment variable and if found will assign that value to the variable. When the environment variable is not defined the 'default' attribute value is used. 183 | 184 | `blocks` is similar to the root list in the Standard Format. It defines a list of named blocks of commands and nestable sub blocks of commands to run. 185 | 186 | ```YAML 187 | blocks: 188 | - name: block-example 189 | desc: An Example block. 190 | vars: 191 | local_var: 'for this block only' 192 | commands: 193 | - diag: '[%local_var%] execute update' 194 | - exec: /bin/uptime 195 | ``` 196 | 197 | Within each block you can define `vars` with the same options the root `vars` attribute, but these variables will only be available for commands in that block. 198 | 199 | The `commands` attribute replaces the `shell` attribute and lets you define three kinds of commands. 200 | 201 | * `diag` - This command is an alias for echo and will print the string template to the terminal. 202 | 203 | * `dir` - Sets the working directory for commands executed after this. 204 | 205 | * `exec` - A command to execute. 206 | 207 | The following configuration attributes are also available for each command. 208 | 209 | * `condition` - Takes a condition in the same format as the *test* command. If the condition returns false the command 210 | will be skipped. 211 | 212 | * `for-vars` - Can be a list or the name of variable that contains a list. The command will be executed for each element of the list. The value and index for each element in the list will be available as the `var` and `index` variables. 213 | 214 | ```YAML 215 | blocks: 216 | - name: for-vars-example 217 | desc: An Example block. 218 | vars: 219 | local_list: 220 | - 1 221 | - 2 222 | - 3 223 | commands: 224 | - diag: 'value [%var%] at index [%index%]' 225 | for-vars: local_list 226 | ``` 227 | 228 | ## License 229 | 230 | This software is copyright 2025 Kate Parkhurst and licensed under the MIT license. 231 | 232 | ## Availability 233 | 234 | The latest version of this software can be found [in the GitHub repository](https://github.com/symkat/dex) 235 | 236 | 237 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module dex 2 | 3 | go 1.21.0 4 | 5 | toolchain go1.23.5 6 | 7 | require github.com/goccy/go-yaml v1.15.16 8 | 9 | require github.com/stretchr/testify v1.10.0 10 | 11 | require ( 12 | github.com/davecgh/go-spew v1.1.1 // indirect 13 | github.com/pmezard/go-difflib v1.0.0 // indirect 14 | gopkg.in/yaml.v3 v3.0.1 // indirect 15 | ) 16 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/goccy/go-yaml v1.15.16 h1:PMTVcGI9uNPIn7KLs0H7KC1rE+51yPl5YNh4i8rGuRA= 4 | github.com/goccy/go-yaml v1.15.16/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= 5 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 6 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 7 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 8 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 9 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 10 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 11 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 12 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 13 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | 9 | v1 "dex/v1" 10 | v2 "dex/v2" 11 | ) 12 | 13 | // Paths to search for dex files. 14 | var configFileLocations = []string{"dex.yaml", "dex.yml", ".dex.yaml", ".dex.yml"} 15 | 16 | /* 17 | 1. Try to locate a dex file, throw an error and exit if there is no config file. 18 | 2. Load the content of the dex file 19 | 3. Attempt to parse the dex file as v1 and then v2 YAML. 20 | */ 21 | func main() { 22 | 23 | /* Find the name of the dex file we're using. */ 24 | if filename, err := findConfigFile(); err != nil { 25 | fmt.Fprintln(os.Stderr, err) 26 | os.Exit(1) 27 | /* Load the raw yaml data */ 28 | } else if dexData, err := loadDexFile(filename); err != nil { 29 | fmt.Fprintln(os.Stderr, err) 30 | os.Exit(1) 31 | /* Attempt parsing as v1 */ 32 | } else if dexFile, err := v1.ParseConfig(dexData); err == nil { 33 | v1.Run(dexFile, os.Args) 34 | /* Attempt parsing as v2 */ 35 | } else if dexFile, err := v2.ParseConfig(dexData); err == nil { 36 | v2.Run(dexFile, os.Args) 37 | /* failure */ 38 | } else { 39 | fmt.Fprintln(os.Stderr, err) 40 | os.Exit(1) 41 | } 42 | } 43 | 44 | func loadDexFile(filename string) ([]byte, error) { 45 | 46 | if fileContent, err := os.Open(filename); err != nil { 47 | return []byte{}, fmt.Errorf("yamlFile.Get err #%s", err) 48 | } else if dexData, err := io.ReadAll(fileContent); err != nil { 49 | return []byte{}, fmt.Errorf("yamlFile.Get err #%v ", err) 50 | } else { 51 | return dexData, err 52 | } 53 | } 54 | 55 | /* 56 | Search through the config_files array and return the first 57 | dex file that exists. 58 | */ 59 | func findConfigFile() (string, error) { 60 | 61 | /* If the first block parameter is "~~", this parameter 62 | is removed and we check for dex files in the users 63 | home directory instead of the current working directory 64 | */ 65 | useHome := false 66 | if len(os.Args) > 1 && os.Args[1] == "~~" { 67 | os.Args = os.Args[1:] 68 | useHome = true 69 | } 70 | 71 | homeDir, err := os.UserHomeDir() 72 | if useHome && err != nil { 73 | fmt.Fprintf(os.Stderr, "error finding home directory: %v", err) 74 | } 75 | 76 | /* DEX_FILE environment variable takes priority. If ~~ was set 77 | then we check for the DEX_FILE path relative to the users 78 | home directory. 79 | */ 80 | if dexFileEnv := os.Getenv("DEX_FILE"); len(dexFileEnv) > 0 { 81 | if useHome { 82 | dexFileEnv = filepath.Join(homeDir, dexFileEnv) 83 | } 84 | 85 | if _, err := os.Stat(dexFileEnv); err == nil { 86 | return dexFileEnv, nil 87 | } 88 | } 89 | 90 | for _, filename := range configFileLocations { 91 | 92 | if useHome { 93 | filename = filepath.Join(homeDir, filename) 94 | } 95 | 96 | if _, err := os.Stat(filename); err == nil { 97 | return filename, nil 98 | } 99 | } 100 | 101 | return "", fmt.Errorf("no dex file was found. Searched %v", configFileLocations) 102 | } 103 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func check(t *testing.T, e error, s string) { 11 | if e != nil { 12 | t.Errorf("%s - %v", s, e) 13 | } 14 | } 15 | 16 | func TestFindConfigFile(t *testing.T) { 17 | _, err := findConfigFile() 18 | 19 | if err == nil { 20 | t.Error("No error on config file not found") 21 | } 22 | 23 | f, err := os.CreateTemp("", "dex-test") 24 | check(t, err, "Error creating cfg file") 25 | 26 | defer os.Remove(f.Name()) 27 | 28 | f2, err := os.CreateTemp("", "dex-test") 29 | check(t, err, "Error creating second cfg file") 30 | 31 | defer os.Remove(f2.Name()) 32 | 33 | configFileLocations = []string{"not-exists.yml", f.Name(), f2.Name()} 34 | 35 | cfg, err := findConfigFile() 36 | check(t, err, "config file not found") 37 | 38 | assert.Equal(t, cfg, f.Name()) 39 | 40 | os.Remove(f.Name()) 41 | 42 | cfg2, err := findConfigFile() 43 | check(t, err, "config file not found") 44 | 45 | assert.Equal(t, cfg2, f2.Name()) 46 | 47 | f3, err := os.CreateTemp("", "dex-test") 48 | check(t, err, "Error creating second cfg file") 49 | 50 | os.Setenv("DEX_FILE", f3.Name()) 51 | 52 | cfg3, err := findConfigFile() 53 | check(t, err, "config file not found") 54 | 55 | assert.Equal(t, cfg3, f3.Name()) 56 | 57 | } 58 | -------------------------------------------------------------------------------- /v1/v1.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "os" 8 | "os/exec" 9 | "strings" 10 | 11 | "github.com/goccy/go-yaml" 12 | ) 13 | 14 | type DexFile []struct { 15 | Name string `yaml:"name"` 16 | Desc string `yaml:"desc"` 17 | Commands []string `yaml:"shell"` 18 | Children DexFile `yaml:"children"` 19 | } 20 | 21 | /* 22 | 1. If there was no commands to run, display the menu of commands the DexFile knows about. 23 | 2. If there was a command to run, find it and run it. If it's invalid, say so and display the menu. 24 | */ 25 | func Run(dexFile DexFile, args []string) { 26 | 27 | /* No commands asked for: show menu and exit */ 28 | if len(args) == 1 { 29 | displayMenu(os.Stdout, dexFile, 0) 30 | os.Exit(0) 31 | } 32 | 33 | /* No commands were found from the arguments the user passed: show error, menu and exit */ 34 | commands, err := resolveCmdToCodeblock(dexFile, args[1:]) 35 | if err != nil { 36 | fmt.Fprintf(os.Stderr, "Error: No commands were found at %v\n\nSee the menu:\n", args[1:]) 37 | displayMenu(os.Stderr, dexFile, 0) 38 | os.Exit(1) 39 | } 40 | 41 | /* Found commands: run them */ 42 | runCommands(commands) 43 | } 44 | 45 | /* 46 | Attempt to parse the YAML content into DexFile format 47 | */ 48 | func ParseConfig(configData []byte) (DexFile, error) { 49 | 50 | var dexFile DexFile 51 | 52 | if err := yaml.Unmarshal([]byte(configData), &dexFile); err != nil { 53 | return nil, err 54 | } 55 | 56 | return dexFile, nil 57 | } 58 | 59 | /* 60 | Display the menu by recursively processing each element of the DexFile and 61 | 62 | showing the name and description for the command. Children are indented with 63 | 4 spaces. 64 | */ 65 | func displayMenu(w io.Writer, dexFile DexFile, indent int) { 66 | for _, elem := range dexFile { 67 | 68 | fmt.Fprintf(w, "%s%-24v: %v\n", strings.Repeat(" ", indent*4), elem.Name, elem.Desc) 69 | 70 | if len(elem.Children) >= 1 { 71 | displayMenu(w, elem.Children, indent+1) 72 | } 73 | } 74 | } 75 | 76 | /* 77 | Find the list of commands to run for a given command path. 78 | 79 | For example, cmd = [ 'foo', 'bar', 'blee' ] would check if 'foo' is a valid command, 80 | then call itself with the child DexFile of foo, and cmd = ['bar', 'blee']. Then bar's 81 | child DexFile would be called with [ 'blee' ] and return the list of commands. 82 | */ 83 | func resolveCmdToCodeblock(dexFile DexFile, cmds []string) ([]string, error) { 84 | for _, elem := range dexFile { 85 | if elem.Name == cmds[0] { 86 | if len(cmds) >= 2 { 87 | return resolveCmdToCodeblock(elem.Children, cmds[1:]) 88 | } else { 89 | return elem.Commands, nil 90 | } 91 | } 92 | } 93 | return []string{}, errors.New("could not find command") 94 | } 95 | 96 | /* 97 | Given a list of commands, run them. 98 | 99 | Uses bash so that quoting, shell expansion, etc works. 100 | Writes the stdout/stderr as one would expect. 101 | */ 102 | func runCommands(commands []string) { 103 | for _, command := range commands { 104 | cmd := exec.Command("/bin/bash", "-c", command) 105 | cmd.Stdout = os.Stdout 106 | cmd.Stderr = os.Stderr 107 | 108 | err := cmd.Run() 109 | if err != nil { 110 | fmt.Fprintln(os.Stderr, "Failed to run command: ", err) 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /v1/v1_test.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "os" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func check(t *testing.T, e error, s string) { 13 | if e != nil { 14 | t.Errorf("%s - %v", s, e) 15 | } 16 | } 17 | 18 | func createTestConfig(t *testing.T, config string) (*os.File, []byte, error) { 19 | 20 | data := []byte(config) 21 | 22 | tcfg, err := os.CreateTemp("", "dex-test") 23 | check(t, err, "Error creating temp cfg file") 24 | 25 | _, err = tcfg.Write(data) 26 | check(t, err, "Error writing to temp cfg file") 27 | 28 | yamlFile, err := os.Open(tcfg.Name()) 29 | check(t, err, "Error opening temp yaml file") 30 | 31 | yamlData, err := io.ReadAll(yamlFile) 32 | check(t, err, "Error reading yaml data") 33 | 34 | return tcfg, yamlData, nil 35 | } 36 | 37 | type DexTest struct { 38 | Name string 39 | Config string 40 | Dexfile DexFile 41 | MenuOut string 42 | Blockpath []string 43 | Commands []string 44 | } 45 | 46 | func TestParseConfigFile(t *testing.T) { 47 | 48 | tests := []DexTest{ 49 | { 50 | Name: "Hello", 51 | Config: `--- 52 | - name: hello 53 | desc: this is a command description`, 54 | Dexfile: DexFile{ 55 | { 56 | Name: "hello", 57 | Desc: "this is a command description", 58 | }, 59 | }, 60 | }, 61 | { 62 | Name: "Hello Children", 63 | Config: `--- 64 | - name: hello 65 | desc: this is a command description 66 | children: 67 | - name: start 68 | desc: start the server 69 | - name: stop 70 | desc: stop the server 71 | - name: restart 72 | desc: restart the server 73 | `, 74 | Dexfile: DexFile{ 75 | { 76 | Name: "hello", 77 | Desc: "this is a command description", 78 | Children: DexFile{ 79 | { 80 | Name: "start", 81 | Desc: "start the server", 82 | }, 83 | { 84 | Name: "stop", 85 | Desc: "stop the server", 86 | }, 87 | { 88 | Name: "restart", 89 | Desc: "restart the server", 90 | }, 91 | }, 92 | }, 93 | }, 94 | }, 95 | } 96 | 97 | for _, test := range tests { 98 | 99 | tcfg, yamlData, _ := createTestConfig(t, test.Config) 100 | 101 | defer os.Remove(tcfg.Name()) 102 | 103 | dex_file, err := ParseConfig(yamlData) 104 | check(t, err, "config file not found") 105 | 106 | assert.Equal(t, dex_file, test.Dexfile) 107 | 108 | } 109 | 110 | } 111 | 112 | func TestDisplayMenu(t *testing.T) { 113 | 114 | tests := []DexTest{ 115 | { 116 | Name: "Hello", 117 | Config: `--- 118 | - name: hello 119 | desc: this is a command description`, 120 | MenuOut: "hello : this is a command description\n", 121 | }, 122 | { 123 | Name: "Hello Children", 124 | Config: `--- 125 | - name: hello 126 | desc: this is a command description 127 | children: 128 | - name: start 129 | desc: start the server 130 | - name: stop 131 | desc: stop the server 132 | - name: restart 133 | desc: restart the server 134 | `, 135 | MenuOut: `hello : this is a command description 136 | start : start the server 137 | stop : stop the server 138 | restart : restart the server 139 | `, 140 | }, 141 | } 142 | 143 | for _, test := range tests { 144 | 145 | tcfg, yamlData, _ := createTestConfig(t, test.Config) 146 | 147 | defer os.Remove(tcfg.Name()) 148 | 149 | dex_file, _ := ParseConfig(yamlData) 150 | 151 | var output bytes.Buffer 152 | displayMenu(&output, dex_file, 0) 153 | 154 | assert.Equal(t, test.MenuOut, output.String()) 155 | 156 | } 157 | 158 | } 159 | 160 | func TestResolveBlock(t *testing.T) { 161 | 162 | tests := []DexTest{ 163 | { 164 | Name: "Nested Command", 165 | Config: `--- 166 | - name: server 167 | desc: control the server 168 | children: 169 | - name: start 170 | desc: start the server 171 | - name: stop 172 | desc: stop the server 173 | - name: restart 174 | desc: restart the server 175 | shell: 176 | - systemctl restart server 177 | - touch /.restarted 178 | 179 | `, 180 | Blockpath: []string{"server", "restart"}, 181 | Commands: []string{"systemctl restart server", "touch /.restarted"}, 182 | }, 183 | } 184 | 185 | for _, test := range tests { 186 | 187 | tcfg, yamlData, _ := createTestConfig(t, test.Config) 188 | 189 | defer os.Remove(tcfg.Name()) 190 | 191 | dex_file, _ := ParseConfig(yamlData) 192 | 193 | block_cmds, _ := resolveCmdToCodeblock(dex_file, test.Blockpath) 194 | 195 | assert.Equal(t, test.Commands, block_cmds) 196 | 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /v2/v2.go: -------------------------------------------------------------------------------- 1 | package v2 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "maps" 9 | "os" 10 | "os/exec" 11 | "regexp" 12 | "strconv" 13 | "strings" 14 | "text/template" 15 | 16 | "github.com/goccy/go-yaml" 17 | ) 18 | 19 | type VarCfg struct { 20 | StringValue string 21 | ListValue []string 22 | FromCommand string 23 | FromEnv string 24 | Default string 25 | } 26 | 27 | func (varCfg VarCfg) Value() (any, error) { 28 | 29 | if len(varCfg.StringValue) > 0 { 30 | return varCfg.Value, nil 31 | } else if len(varCfg.ListValue) > 0 { 32 | return varCfg.ListValue, nil 33 | } 34 | 35 | return nil, errors.New("undefined") 36 | } 37 | 38 | type VarValue interface { 39 | string | []string 40 | } 41 | 42 | func SetVarValue[Value VarValue](varCfg *VarCfg, value Value) error { 43 | switch typeValue := any(value).(type) { 44 | case []string: 45 | varCfg.ListValue = typeValue 46 | 47 | case string: 48 | varCfg.StringValue = typeValue 49 | default: 50 | return errors.New("unknown VarCfg value type") 51 | } 52 | 53 | return nil 54 | } 55 | 56 | type Command struct { 57 | Exec string 58 | Diag string 59 | Dir string 60 | ForVars []string 61 | Shell string 62 | ShellArgs []string 63 | Condition string 64 | } 65 | 66 | type Block struct { 67 | Name string `yaml:"name"` 68 | Desc string `yaml:"desc"` 69 | CommandsRaw []map[string]any `yaml:"commands"` 70 | Commands []Command `yaml:"Commands"` 71 | Vars map[string]any `yaml:"vars"` 72 | Dir string `yaml:"dir"` 73 | Shell string `yaml:"shell"` 74 | ShellArgs []string `yaml:"shell_args"` 75 | Children []Block `yaml:"children"` 76 | } 77 | type DexFile2 struct { 78 | Version int `yaml:"version"` 79 | Vars map[string]any `yaml:"vars"` 80 | Blocks []Block `yaml:"blocks"` 81 | Shell string `yaml:"shell"` 82 | ShellArgs []string `yaml:"shell_args"` 83 | } 84 | 85 | var DefaultShell = "/bin/bash" 86 | var DefaultShellArgs = []string{"-c"} 87 | var VarCfgs = map[string]VarCfg{} 88 | 89 | /* Helper function to set default value if field value is unset */ 90 | func checkSetDefault[D VarValue](field *D, def D) { 91 | 92 | if len(*field) == 0 { 93 | *field = def 94 | } 95 | } 96 | 97 | /* Helper function to set field value if override value is set */ 98 | func checkSetOverride[D VarValue](field *D, override D) { 99 | 100 | if len(override) != 0 { 101 | *field = override 102 | } 103 | } 104 | 105 | /* 106 | Attempt to parse the YAML content into DexFile2 format 107 | and do some sanity checks and set defaults. 108 | */ 109 | func ParseConfig(configData []byte) (DexFile2, error) { 110 | 111 | var dexFile DexFile2 112 | 113 | if err := yaml.Unmarshal([]byte(configData), &dexFile); err != nil { 114 | return DexFile2{}, err 115 | } else if dexFile.Version != 2 { 116 | return DexFile2{}, errors.New("incorrect version number") 117 | } 118 | 119 | checkSetDefault(&dexFile.Shell, DefaultShell) 120 | checkSetDefault(&dexFile.ShellArgs, DefaultShellArgs) 121 | 122 | return dexFile, nil 123 | } 124 | 125 | /* 126 | 1. If there was no commands to run, display the menu of commands the DexFile knows about. 127 | 2. If there was a command to run, find it and run it. If it's invalid, say so and display the menu. 128 | */ 129 | func Run(dexFile DexFile2, args []string) { 130 | 131 | /* No commands asked for: show menu and exit */ 132 | 133 | if len(args) == 1 { 134 | displayMenu(os.Stdout, dexFile.Blocks, 0) 135 | os.Exit(0) 136 | } 137 | 138 | initVars(dexFile.Vars) 139 | 140 | block, err := initBlockFromPath(dexFile, args[1:]) 141 | 142 | /* No commands were found from the arguments the user passed: show error, menu and exit */ 143 | if err != nil { 144 | fmt.Fprint(os.Stderr, err.Error()+"\n") 145 | displayMenu(os.Stderr, dexFile.Blocks, 0) 146 | os.Exit(1) 147 | } 148 | 149 | config := ExecConfig{ 150 | Stdout: os.Stdout, 151 | Stderr: os.Stderr, 152 | } 153 | 154 | processBlock(block, config) 155 | } 156 | 157 | func initBlockFromPath(dexFile DexFile2, blockPath []string) (Block, error) { 158 | 159 | block, err := resolveCmdToCodeblock(dexFile.Blocks, blockPath) 160 | 161 | if err != nil { 162 | return Block{}, fmt.Errorf("error: No commands were found at %v\n\nSee the menu", blockPath) 163 | } 164 | 165 | /* Found block. Init variables, set defaults and process the 166 | block and its commands */ 167 | checkSetDefault(&block.Shell, dexFile.Shell) 168 | checkSetDefault(&block.ShellArgs, dexFile.ShellArgs) 169 | initVars(block.Vars) 170 | initBlockCommands(&block) 171 | 172 | return block, nil 173 | } 174 | 175 | /* 176 | Display the menu by recursively processing each element of the DexFile and 177 | showing the name and description for the command. Children are indented with 178 | 4 spaces. 179 | */ 180 | func displayMenu(w io.Writer, blocks []Block, indent int) { 181 | for _, elem := range blocks { 182 | 183 | fmt.Fprintf(w, "%s%-24v: %v\n", strings.Repeat(" ", indent*4), elem.Name, elem.Desc) 184 | 185 | if len(elem.Children) >= 1 { 186 | displayMenu(w, elem.Children, indent+1) 187 | } 188 | } 189 | } 190 | 191 | func resolveCmdToCodeblock(blocks []Block, cmds []string) (Block, error) { 192 | 193 | for _, elem := range blocks { 194 | if elem.Name == cmds[0] { 195 | if len(cmds) >= 2 { 196 | return resolveCmdToCodeblock(elem.Children, cmds[1:]) 197 | } else { 198 | return elem, nil 199 | } 200 | } 201 | } 202 | return Block{}, errors.New("could not find command") 203 | } 204 | 205 | /* helper function that checks multiple keys for value */ 206 | func checkKeys[T VarValue](cfg map[string]any, keys []string) (T, bool) { 207 | var empty T 208 | 209 | for _, key := range keys { 210 | if cfg[key] != nil { 211 | return cfg[key].(T), true 212 | } 213 | } 214 | 215 | return empty, false 216 | } 217 | 218 | func initVars(varMap map[string]any) { 219 | for varName, value := range varMap { 220 | 221 | switch typeVal := value.(type) { 222 | /* VarCfg */ 223 | case map[string]any: 224 | 225 | varCfg := VarCfg{} 226 | 227 | if fromEnv, ok := checkKeys[string](typeVal, []string{"from-env", "from_env"}); ok { 228 | varCfg.FromEnv = fromEnv 229 | if envVal := os.Getenv(varCfg.FromEnv); len(envVal) > 0 { 230 | varCfg.StringValue = envVal 231 | } 232 | } 233 | 234 | if fromCommand, ok := checkKeys[string](typeVal, []string{"from-command", "from_command"}); ok { 235 | 236 | varCfg.FromCommand = fromCommand 237 | 238 | var output bytes.Buffer 239 | 240 | execConfig := ExecConfig{ 241 | Stdout: &output, 242 | } 243 | 244 | /* TODO? Allow setting custom shell for this. 245 | Would be a just convenience since you already 246 | do something like: 247 | from_command: '/usr/bin/zsh -c "echo hello"' 248 | */ 249 | execConfig.Cmd = "/bin/bash" 250 | execConfig.Args = []string{"-c", varCfg.FromCommand} 251 | 252 | if exit := execCommand(execConfig); exit == 0 { 253 | lines := strings.Split(strings.TrimSuffix(output.String(), "\n"), "\n") 254 | 255 | /* Turn multi-line output into List */ 256 | if len(lines) > 1 { 257 | 258 | SetVarValue(&varCfg, lines) 259 | } else { 260 | SetVarValue(&varCfg, lines[0]) 261 | } 262 | } 263 | } 264 | 265 | if typeVal["default"] != nil { 266 | varCfg.Default = typeVal["default"].(string) 267 | } 268 | 269 | if _, err := varCfg.Value(); err != nil && len(varCfg.Default) > 0 { 270 | 271 | SetVarValue(&varCfg, varCfg.Default) 272 | } 273 | 274 | VarCfgs[varName] = varCfg 275 | 276 | /* List */ 277 | case []any: 278 | 279 | VarCfgs[varName] = VarCfg{ 280 | ListValue: []string{}, 281 | } 282 | 283 | for _, elem := range typeVal { 284 | 285 | entry := VarCfgs[varName] 286 | SetVarValue(&entry, append(entry.ListValue, elem.(string))) 287 | 288 | VarCfgs[varName] = entry 289 | } 290 | 291 | /* Integer */ 292 | case uint64: 293 | 294 | VarCfgs[varName] = VarCfg{ 295 | StringValue: strconv.FormatUint(typeVal, 10), 296 | } 297 | 298 | /* String */ 299 | case string: 300 | 301 | VarCfgs[varName] = VarCfg{ 302 | StringValue: typeVal, 303 | } 304 | default: 305 | fmt.Printf("I don't know about type %T for %s!\n", typeVal, varName) 306 | } 307 | } 308 | } 309 | 310 | /* Capture the variable name inside the perl template delimiters */ 311 | var fixupRe = regexp.MustCompile(`\[%\s*([^\s%]+)\s*%\]`) 312 | var tt = template.New("variable_parser") 313 | 314 | func render(tmpl string, varCfgs map[string]VarCfg) string { 315 | 316 | if len(tmpl) == 0 { 317 | return "" 318 | } 319 | 320 | /* 321 | Converting from the template format established in the perl version 322 | */ 323 | t1, err := tt.Parse(fixupRe.ReplaceAllString(tmpl, "{{ .$1.StringValue }}")) 324 | if err != nil { 325 | panic(err) 326 | } 327 | 328 | var renderBuf bytes.Buffer 329 | 330 | t1.Execute(&renderBuf, varCfgs) 331 | 332 | return renderBuf.String() 333 | } 334 | 335 | func assignIfSet[T string | []string](commandCfg map[string]any, key string, field *T) { 336 | if commandCfg[key] != nil { 337 | *field = commandCfg[key].(T) 338 | } 339 | } 340 | 341 | func initBlockCommands(block *Block) { 342 | for _, command := range block.CommandsRaw { 343 | 344 | /* All this because for-vars can be a string referencing a list or list */ 345 | Command := Command{} 346 | 347 | assignIfSet(command, "exec", &Command.Exec) 348 | assignIfSet(command, "diag", &Command.Diag) 349 | assignIfSet(command, "dir", &Command.Dir) 350 | assignIfSet(command, "condition", &Command.Condition) 351 | assignIfSet(command, "shell", &Command.Shell) 352 | assignIfSet(command, "shell_args", &Command.ShellArgs) 353 | 354 | checkSetDefault(&Command.Shell, block.Shell) 355 | checkSetDefault(&Command.ShellArgs, block.ShellArgs) 356 | 357 | if command["for-vars"] != nil { 358 | switch typeVal := command["for-vars"].(type) { 359 | /* inline list */ 360 | case []any: 361 | 362 | for _, elem := range typeVal { 363 | Command.ForVars = append(Command.ForVars, elem.(string)) 364 | } 365 | /* name of list */ 366 | case string: 367 | 368 | if list := VarCfgs[typeVal]; list.ListValue != nil { 369 | Command.ForVars = list.ListValue 370 | } 371 | default: 372 | fmt.Printf("I don't know about type %T in for-vars!\n", typeVal) 373 | } 374 | } else { 375 | Command.ForVars = []string{"1"} 376 | } 377 | 378 | block.Commands = append(block.Commands, Command) 379 | } 380 | 381 | block.CommandsRaw = nil 382 | } 383 | 384 | type ExecConfig struct { 385 | Cmd string 386 | Args []string 387 | Stdout io.Writer 388 | Stderr io.Writer 389 | Dir string 390 | } 391 | 392 | func processBlock(block Block, config ExecConfig) { 393 | 394 | if len(block.Dir) > 0 { 395 | config.Dir = block.Dir 396 | } else { 397 | dir, err := os.Getwd() 398 | if err != nil { 399 | fmt.Fprintf(os.Stderr, "cannot get current working directory \n") 400 | return 401 | } else { 402 | config.Dir = dir 403 | } 404 | } 405 | 406 | runCommandsWithConfig(block.Commands, config) 407 | } 408 | 409 | func runCommandsWithConfig(commands []Command, config ExecConfig) { 410 | 411 | cwd := config.Dir 412 | 413 | for _, command := range commands { 414 | 415 | if exit := checkCommandCondition(command.Condition, VarCfgs); exit != 0 { 416 | continue 417 | } 418 | 419 | execConfig := config 420 | 421 | /* Update cwd so that the directory update is 422 | preserved until another command changes it */ 423 | checkSetOverride(&cwd, render(command.Dir, VarCfgs)) 424 | 425 | execConfig.Dir = cwd 426 | /* This behaves slightly different from the perl version 427 | 1. Diag wont override Exec and both can run if both are defined 428 | 2. Diag and Exec will both be looped with for-vars 429 | */ 430 | for index, value := range command.ForVars { 431 | 432 | varCfgs := map[string]VarCfg{} 433 | 434 | maps.Copy(varCfgs, VarCfgs) 435 | maps.Copy(varCfgs, map[string]VarCfg{"index": {StringValue: strconv.Itoa(index)}, "var": {StringValue: value}}) 436 | 437 | if len(command.Diag) > 0 { 438 | execConfig.Cmd = "/usr/bin/echo" 439 | execConfig.Args = []string{render(command.Diag, varCfgs)} 440 | 441 | execCommand(execConfig) 442 | } 443 | 444 | if len(command.Exec) > 0 { 445 | execConfig.Cmd = command.Shell 446 | execConfig.Args = command.ShellArgs 447 | execConfig.Args = append(execConfig.Args, render(command.Exec, varCfgs)) 448 | 449 | execCommand(execConfig) 450 | } 451 | } 452 | } 453 | } 454 | 455 | func execCommand(config ExecConfig) int { 456 | 457 | cmd := exec.Command(config.Cmd, config.Args...) 458 | cmd.Stdout = config.Stdout 459 | cmd.Stderr = config.Stderr 460 | cmd.Dir = config.Dir 461 | 462 | err := cmd.Run() 463 | if err != nil { 464 | 465 | if exitError, ok := err.(*exec.ExitError); ok { 466 | return exitError.ExitCode() 467 | } else { 468 | return 1 469 | } 470 | } 471 | 472 | return 0 473 | } 474 | 475 | func checkCommandCondition(condition string, varCfgs map[string]VarCfg) int { 476 | 477 | if len(condition) == 0 { 478 | return 0 479 | } 480 | 481 | config := ExecConfig{ 482 | Stdout: os.NewFile(0, os.DevNull), 483 | Stderr: os.NewFile(0, os.DevNull), 484 | } 485 | 486 | config.Cmd = "/bin/bash" 487 | config.Args = []string{"-c", fmt.Sprintf("test %s", render(condition, varCfgs))} 488 | 489 | return execCommand(config) 490 | } 491 | -------------------------------------------------------------------------------- /v2/v2_test.go: -------------------------------------------------------------------------------- 1 | package v2 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | "reflect" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func check(t *testing.T, e error, s string) error { 15 | if e != nil { 16 | t.Errorf("%s - %v", s, e) 17 | return e 18 | } 19 | return nil 20 | } 21 | 22 | func createTestConfig(t *testing.T, config string) (*os.File, []byte, error) { 23 | 24 | data := []byte(config) 25 | 26 | tDexFile, err := os.CreateTemp("", "dex-test") 27 | check(t, err, "Error creating temp cfg file") 28 | 29 | _, err = tDexFile.Write(data) 30 | check(t, err, "Error writing to temp cfg file") 31 | 32 | yamlFile, err := os.Open(tDexFile.Name()) 33 | check(t, err, "Error opening temp yaml file") 34 | 35 | yamlData, err := io.ReadAll(yamlFile) 36 | check(t, err, "Error reading yaml data") 37 | 38 | return tDexFile, yamlData, nil 39 | } 40 | 41 | func setupTestBlock(t *testing.T, test DexTest) (Block, *os.File, error) { 42 | 43 | tDexFile, yamlData, _ := createTestConfig(t, test.Config) 44 | 45 | dexFile, err := ParseConfig(yamlData) 46 | 47 | if err := check(t, err, "Error parsing config"); err != nil { 48 | return Block{}, nil, err 49 | } 50 | 51 | /* reset VarCfgs */ 52 | VarCfgs = map[string]VarCfg{} 53 | 54 | initVars(dexFile.Vars) 55 | 56 | block, err := initBlockFromPath(dexFile, test.BlockPath) 57 | 58 | if err := check(t, err, "Error resolving command"); err != nil { 59 | return Block{}, nil, err 60 | } 61 | 62 | return block, tDexFile, nil 63 | } 64 | 65 | type DexTest struct { 66 | Name string 67 | Config string 68 | DexFile DexFile2 69 | MenuOut string 70 | BlockPath []string 71 | Commands []Command 72 | CommandsRaw []map[string]any 73 | CommandOut string 74 | ExpectedVars map[string]VarCfg 75 | Custom func(t *testing.T, test DexTest, opts map[string]any) 76 | } 77 | 78 | func TestParseConfigFile(t *testing.T) { 79 | 80 | tests := []DexTest{ 81 | { 82 | Name: "Hello", 83 | Config: `--- 84 | version: 2 85 | blocks: 86 | - name: hello 87 | desc: this is a command description`, 88 | DexFile: DexFile2{ 89 | Version: 2, 90 | Shell: "/bin/bash", 91 | ShellArgs: []string{"-c"}, 92 | Blocks: []Block{ 93 | { 94 | Name: "hello", 95 | Desc: "this is a command description", 96 | Commands: nil, 97 | CommandsRaw: nil, 98 | }, 99 | }, 100 | }, 101 | }, 102 | { 103 | Name: "Hello Children", 104 | Config: `--- 105 | version: 2 106 | shell: /bin/zsh 107 | blocks: 108 | - name: hello 109 | desc: this is a command description 110 | children: 111 | - name: start 112 | desc: start the server 113 | commands: 114 | - exec: systemctl start server 115 | - name: stop 116 | desc: stop the server 117 | commands: 118 | - exec: systemctl stop server 119 | dir: /home/slice 120 | - name: restart 121 | desc: restart the server 122 | commands: 123 | - exec: systemctl stop server 124 | - exec: systemctl start server 125 | `, 126 | DexFile: DexFile2{ 127 | Version: 2, 128 | Shell: "/bin/zsh", 129 | ShellArgs: []string{"-c"}, 130 | Blocks: []Block{ 131 | { 132 | Name: "hello", 133 | Desc: "this is a command description", 134 | Children: []Block{ 135 | { 136 | Name: "start", 137 | Desc: "start the server", 138 | Commands: nil, 139 | CommandsRaw: []map[string]any{{"exec": "systemctl start server"}}, 140 | }, 141 | { 142 | Name: "stop", 143 | Desc: "stop the server", 144 | Commands: nil, 145 | CommandsRaw: []map[string]any{{"exec": "systemctl stop server", "dir": "/home/slice"}}, 146 | }, 147 | { 148 | Name: "restart", 149 | Desc: "restart the server", 150 | Commands: nil, 151 | CommandsRaw: []map[string]any{ 152 | {"exec": "systemctl stop server"}, 153 | {"exec": "systemctl start server"}}, 154 | }, 155 | }, 156 | }, 157 | }, 158 | }, 159 | }, 160 | } 161 | 162 | for _, test := range tests { 163 | 164 | tcfg, yamlData, _ := createTestConfig(t, test.Config) 165 | 166 | defer os.Remove(tcfg.Name()) 167 | 168 | dex_file, err := ParseConfig(yamlData) 169 | check(t, err, "config file not found") 170 | 171 | assert.Equal(t, test.DexFile, dex_file) 172 | 173 | } 174 | 175 | } 176 | 177 | func TestDisplayMenu(t *testing.T) { 178 | 179 | tests := []DexTest{ 180 | { 181 | Name: "Hello", 182 | Config: `--- 183 | version: 2 184 | blocks: 185 | - name: hello 186 | desc: this is a command description`, 187 | MenuOut: "hello : this is a command description\n", 188 | }, 189 | { 190 | Name: "Hello Children", 191 | Config: `--- 192 | version: 2 193 | blocks: 194 | - name: hello 195 | desc: this is a command description 196 | children: 197 | - name: start 198 | desc: start the server 199 | - name: stop 200 | desc: stop the server 201 | - name: restart 202 | desc: restart the server 203 | `, 204 | MenuOut: `hello : this is a command description 205 | start : start the server 206 | stop : stop the server 207 | restart : restart the server 208 | `, 209 | }, 210 | } 211 | 212 | for _, test := range tests { 213 | 214 | tcfg, yamlData, _ := createTestConfig(t, test.Config) 215 | 216 | defer os.Remove(tcfg.Name()) 217 | 218 | dex_file, _ := ParseConfig(yamlData) 219 | 220 | var output bytes.Buffer 221 | displayMenu(&output, dex_file.Blocks, 0) 222 | 223 | assert.Equal(t, test.MenuOut, output.String()) 224 | 225 | } 226 | 227 | } 228 | 229 | func TestResolveBlock(t *testing.T) { 230 | 231 | tests := []DexTest{ 232 | { 233 | Name: "Nested Command", 234 | Config: `--- 235 | version: 2 236 | blocks: 237 | - name: server 238 | desc: this is a command description 239 | children: 240 | - name: start 241 | desc: start the server 242 | - name: stop 243 | desc: stop the server 244 | - name: restart 245 | desc: restart the server 246 | commands: 247 | - exec: restart server 248 | - exec: touch .restarted 249 | `, 250 | BlockPath: []string{"server", "restart"}, 251 | CommandsRaw: []map[string]any{ 252 | {"exec": "restart server"}, 253 | {"exec": "touch .restarted"}}, 254 | }, 255 | } 256 | 257 | for _, test := range tests { 258 | 259 | tcfg, yamlData, _ := createTestConfig(t, test.Config) 260 | 261 | defer os.Remove(tcfg.Name()) 262 | 263 | dex_file, err := ParseConfig(yamlData) 264 | 265 | check(t, err, "Error parsing config") 266 | 267 | block, err := resolveCmdToCodeblock(dex_file.Blocks, test.BlockPath) 268 | 269 | check(t, err, "Error resolving command") 270 | 271 | assert.Equal(t, test.CommandsRaw, block.CommandsRaw) 272 | 273 | } 274 | } 275 | 276 | func TestCommands(t *testing.T) { 277 | 278 | tests := []DexTest{ 279 | { 280 | Name: "Nested Command", 281 | Config: `--- 282 | version: 2 283 | blocks: 284 | - name: hello_world 285 | desc: this is a command description 286 | commands: 287 | - exec: echo "hello world" 288 | `, 289 | BlockPath: []string{"hello_world"}, 290 | CommandOut: "hello world\n", 291 | }, 292 | } 293 | 294 | for _, test := range tests { 295 | 296 | block, tDexFile, err := setupTestBlock(t, test) 297 | 298 | defer os.Remove(tDexFile.Name()) 299 | 300 | if err := check(t, err, "error setting up test"); err != nil { 301 | continue 302 | } 303 | 304 | var output bytes.Buffer 305 | 306 | config := ExecConfig{ 307 | Stdout: &output, 308 | Stderr: &output, 309 | } 310 | 311 | processBlock(block, config) 312 | 313 | assert.Equal(t, test.CommandOut, output.String()) 314 | 315 | } 316 | } 317 | 318 | func TestVars(t *testing.T) { 319 | 320 | tests := []DexTest{ 321 | { 322 | Name: "Global Vars", 323 | Config: `--- 324 | version: 2 325 | vars: 326 | string_var: "hi there" 327 | int_var: 2 328 | list_var: 329 | - these 330 | - those 331 | blocks: 332 | - name: hello_world 333 | desc: this is a command description 334 | commands: 335 | - exec: echo "hello world" 336 | `, 337 | BlockPath: []string{"hello_world"}, 338 | CommandOut: "hello world\n", 339 | ExpectedVars: map[string]VarCfg{ 340 | "string_var": { 341 | StringValue: "hi there", 342 | }, 343 | "int_var": { 344 | StringValue: "2", 345 | }, 346 | "list_var": { 347 | ListValue: []string{ 348 | "these", 349 | "those", 350 | }, 351 | }, 352 | }, 353 | }, 354 | { 355 | Name: "Block Vars", 356 | Config: `--- 357 | version: 2 358 | vars: 359 | global_string: "foobar" 360 | 361 | blocks: 362 | - name: block_vars 363 | desc: this is a command description 364 | vars: 365 | string_var: "from block" 366 | int_var: 3 367 | list_var: 368 | - one 369 | - two 370 | commands: 371 | - exec: echo "hello world" 372 | - name: other_block 373 | desc: this is a command description 374 | vars: 375 | string_var: "other local block var" 376 | 377 | `, 378 | BlockPath: []string{"block_vars"}, 379 | CommandOut: "hello world\n", 380 | ExpectedVars: map[string]VarCfg{ 381 | "global_string": { 382 | StringValue: "foobar", 383 | }, 384 | "string_var": { 385 | StringValue: "from block", 386 | }, 387 | "int_var": { 388 | StringValue: "3", 389 | }, 390 | "list_var": { 391 | ListValue: []string{ 392 | "one", 393 | "two", 394 | }, 395 | }, 396 | }, 397 | }, 398 | { 399 | Name: "Vars From Env", 400 | Config: `--- 401 | version: 2 402 | vars: 403 | global_string: 404 | from-env: TESTENV 405 | not_set: 406 | from_env: TESTENV_UNSET 407 | default: fizzbizz 408 | 409 | blocks: 410 | - name: block_vars 411 | desc: this is a command description 412 | `, 413 | BlockPath: []string{"block_vars"}, 414 | ExpectedVars: map[string]VarCfg{ 415 | "global_string": { 416 | FromEnv: "TESTENV", 417 | StringValue: "from env!", 418 | }, 419 | "not_set": { 420 | FromEnv: "TESTENV_UNSET", 421 | Default: "fizzbizz", 422 | StringValue: "fizzbizz", 423 | }, 424 | }, 425 | }, 426 | { 427 | Name: "Vars From command", 428 | Config: `--- 429 | version: 2 430 | vars: 431 | command_string: 432 | from-command: echo "c var" 433 | command_list: 434 | from_command: echo -en "foo\nbar\nbazz" 435 | 436 | blocks: 437 | - name: block_vars 438 | desc: this is a command description 439 | `, 440 | BlockPath: []string{"block_vars"}, 441 | ExpectedVars: map[string]VarCfg{ 442 | "command_string": { 443 | FromCommand: "echo \"c var\"", 444 | StringValue: "c var", 445 | }, 446 | "command_list": { 447 | FromCommand: "echo -en \"foo\\nbar\\nbazz\"", 448 | ListValue: []string{"foo", "bar", "bazz"}, 449 | }, 450 | }, 451 | }, 452 | } 453 | 454 | for _, test := range tests { 455 | 456 | os.Setenv("TESTENV", "from env!") 457 | 458 | _, tDexFile, err := setupTestBlock(t, test) 459 | 460 | defer os.Remove(tDexFile.Name()) 461 | 462 | if err := check(t, err, "error setting up test"); err != nil { 463 | continue 464 | } 465 | 466 | assert.True(t, reflect.DeepEqual(test.ExpectedVars, VarCfgs)) 467 | } 468 | } 469 | 470 | func TestRenderedCommand(t *testing.T) { 471 | 472 | tests := []DexTest{ 473 | { 474 | Name: "Global Vars", 475 | Config: `--- 476 | version: 2 477 | vars: 478 | string_var: "hi there" 479 | blocks: 480 | - name: hello_world 481 | desc: this is a command description 482 | commands: 483 | - exec: echo "[%string_var%]" 484 | `, 485 | BlockPath: []string{"hello_world"}, 486 | CommandOut: "hi there\n", 487 | }, 488 | { 489 | Name: "Block Vars", 490 | Config: `--- 491 | version: 2 492 | vars: 493 | global_string: "foobar" 494 | 495 | blocks: 496 | - name: block_vars 497 | desc: this is a command description 498 | vars: 499 | string_var: "from block" 500 | int_var: 3 501 | commands: 502 | - exec: echo "[% global_string %] [%string_var%]-[%int_var%]" 503 | `, 504 | BlockPath: []string{"block_vars"}, 505 | CommandOut: "foobar from block-3\n", 506 | }, 507 | { 508 | Name: "Diag", 509 | Config: `--- 510 | version: 2 511 | vars: 512 | global_string: "foobar" 513 | 514 | blocks: 515 | - name: diag_command 516 | desc: this is a command description 517 | vars: 518 | string_var: "from block" 519 | int_var: 4 520 | commands: 521 | - diag: "[% global_string %] [% string_var %] [% int_var %]" 522 | `, 523 | BlockPath: []string{"diag_command"}, 524 | CommandOut: "foobar from block 4\n", 525 | }, 526 | } 527 | 528 | for _, test := range tests { 529 | 530 | block, tDexFile, err := setupTestBlock(t, test) 531 | 532 | defer os.Remove(tDexFile.Name()) 533 | 534 | if err := check(t, err, "error setting up test"); err != nil { 535 | continue 536 | } 537 | 538 | var output bytes.Buffer 539 | 540 | config := ExecConfig{ 541 | Stdout: &output, 542 | Stderr: &output, 543 | } 544 | 545 | processBlock(block, config) 546 | 547 | assert.Equal(t, test.CommandOut, output.String()) 548 | } 549 | } 550 | 551 | func TestCommandDir(t *testing.T) { 552 | 553 | tests := []DexTest{ 554 | { 555 | Name: "Block dir", 556 | Config: `--- 557 | version: 2 558 | blocks: 559 | - name: change_dir 560 | dir: ".." 561 | desc: this is a command description 562 | commands: 563 | - exec: echo $(pwd) 564 | `, 565 | BlockPath: []string{"change_dir"}, 566 | Custom: func(t *testing.T, test DexTest, opts map[string]any) { 567 | 568 | output := opts["ouput"].(bytes.Buffer) 569 | 570 | path, _ := os.Getwd() 571 | 572 | parentDir := filepath.Dir(path) + "\n" 573 | 574 | assert.Equal(t, parentDir, output.String()) 575 | }, 576 | }, 577 | { 578 | Name: "Command Dir", 579 | Config: `--- 580 | version: 2 581 | blocks: 582 | - name: change_dir 583 | desc: this is a command description 584 | vars: 585 | start_dir: 586 | from-command: pwd 587 | commands: 588 | - exec: echo $(pwd) 589 | dir: ".." 590 | - exec: echo $(pwd) 591 | - exec: echo $(pwd) 592 | dir: "[% start_dir %]" 593 | 594 | `, 595 | BlockPath: []string{"change_dir"}, 596 | Custom: func(t *testing.T, test DexTest, opts map[string]any) { 597 | 598 | output := opts["ouput"].(bytes.Buffer) 599 | 600 | path, _ := os.Getwd() 601 | newDir := filepath.Dir(path) 602 | 603 | parentDir := newDir + "\n" + newDir + "\n" + path + "\n" 604 | 605 | assert.Equal(t, parentDir, output.String()) 606 | }, 607 | }, 608 | } 609 | 610 | for _, test := range tests { 611 | 612 | block, tDexFile, err := setupTestBlock(t, test) 613 | 614 | defer os.Remove(tDexFile.Name()) 615 | 616 | if err := check(t, err, "error setting up test"); err != nil { 617 | continue 618 | } 619 | 620 | var output bytes.Buffer 621 | 622 | config := ExecConfig{ 623 | Stdout: &output, 624 | Stderr: &output, 625 | } 626 | 627 | processBlock(block, config) 628 | 629 | test.Custom(t, test, map[string]any{"ouput": output}) 630 | } 631 | } 632 | 633 | func TestForVars(t *testing.T) { 634 | 635 | tests := []DexTest{ 636 | { 637 | Name: "for-vars", 638 | Config: `--- 639 | version: 2 640 | blocks: 641 | - name: loop_vars 642 | dir: ".." 643 | desc: this is a command description 644 | commands: 645 | - exec: echo [% index %] [% var %] 646 | for-vars: 647 | - one 648 | - two 649 | - three 650 | `, 651 | BlockPath: []string{"loop_vars"}, 652 | CommandOut: "0 one\n1 two\n2 three\n", 653 | }, 654 | { 655 | Name: "for-vars list ref", 656 | Config: `--- 657 | version: 2 658 | vars: 659 | some_string: foobar 660 | some_list: 661 | - four 662 | - five 663 | - six 664 | blocks: 665 | - name: loop_vars 666 | dir: ".." 667 | desc: this is a command description 668 | commands: 669 | - exec: echo [% some_string %] [% index %] [% var %] 670 | for-vars: some_list 671 | `, 672 | BlockPath: []string{"loop_vars"}, 673 | CommandOut: "foobar 0 four\nfoobar 1 five\nfoobar 2 six\n", 674 | }, 675 | } 676 | 677 | for _, test := range tests { 678 | 679 | block, tDexFile, err := setupTestBlock(t, test) 680 | 681 | defer os.Remove(tDexFile.Name()) 682 | 683 | if err := check(t, err, "error setting up test"); err != nil { 684 | continue 685 | } 686 | 687 | var output bytes.Buffer 688 | 689 | config := ExecConfig{ 690 | Stdout: &output, 691 | Stderr: &output, 692 | Dir: block.Dir, 693 | } 694 | 695 | processBlock(block, config) 696 | 697 | assert.Equal(t, test.CommandOut, output.String()) 698 | } 699 | } 700 | 701 | func TestCommandCondition(t *testing.T) { 702 | 703 | tests := []DexTest{ 704 | { 705 | Name: "conditions", 706 | Config: `--- 707 | version: 2 708 | blocks: 709 | - name: condition commands 710 | dir: ".." 711 | desc: this is a command description 712 | commands: 713 | - exec: echo condition true 714 | condition: 1 -eq 1 715 | - exec: echo condition false 716 | condition: 1 -eq 0 717 | 718 | `, 719 | BlockPath: []string{"condition commands"}, 720 | CommandOut: "condition true\n", 721 | }, 722 | { 723 | Name: "conditions", 724 | Config: `--- 725 | version: 2 726 | vars: 727 | conditionVal: 1 728 | blocks: 729 | - name: condition commands 730 | dir: ".." 731 | desc: this is a command description 732 | commands: 733 | - exec: echo condition true 734 | condition: 1 -eq [% conditionVal %] 735 | `, 736 | BlockPath: []string{"condition commands"}, 737 | CommandOut: "condition true\n", 738 | }, 739 | } 740 | 741 | for _, test := range tests { 742 | 743 | block, tDexFile, err := setupTestBlock(t, test) 744 | 745 | defer os.Remove(tDexFile.Name()) 746 | 747 | if err := check(t, err, "error setting up test"); err != nil { 748 | continue 749 | } 750 | 751 | var output bytes.Buffer 752 | 753 | config := ExecConfig{ 754 | Stdout: &output, 755 | Stderr: &output, 756 | } 757 | 758 | processBlock(block, config) 759 | 760 | assert.Equal(t, test.CommandOut, output.String()) 761 | } 762 | } 763 | -------------------------------------------------------------------------------- /v2/v2_zsh_test.go: -------------------------------------------------------------------------------- 1 | //go:build zsh 2 | 3 | package v2 4 | 5 | import ( 6 | "bytes" 7 | "os" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestZshShellCommand(t *testing.T) { 14 | 15 | tests := []DexTest{ 16 | { 17 | Name: "zshell", 18 | Config: `--- 19 | version: 2 20 | shell: /usr/bin/zsh 21 | blocks: 22 | - name: zsh command 23 | dir: ".." 24 | desc: this is a command description 25 | commands: 26 | - exec: echo from $0! 27 | `, 28 | BlockPath: []string{"zsh command"}, 29 | CommandOut: "from /usr/bin/zsh!\n", 30 | }, 31 | { 32 | Name: "zshell", 33 | Config: `--- 34 | version: 2 35 | blocks: 36 | - name: block zsh command 37 | shell: /usr/bin/zsh 38 | dir: ".." 39 | desc: this is a command description 40 | commands: 41 | - exec: echo from $0! 42 | `, 43 | BlockPath: []string{"block zsh command"}, 44 | CommandOut: "from /usr/bin/zsh!\n", 45 | }, 46 | { 47 | Name: "zshell", 48 | Config: `--- 49 | version: 2 50 | blocks: 51 | - name: zsh for one command 52 | dir: ".." 53 | desc: this is a command description 54 | commands: 55 | - exec: echo from $0! 56 | shell: /usr/bin/zsh 57 | - exec: echo from $0! 58 | `, 59 | BlockPath: []string{"zsh for one command"}, 60 | CommandOut: "from /usr/bin/zsh!\nfrom /bin/bash!\n", 61 | }, 62 | } 63 | 64 | for _, test := range tests { 65 | 66 | block, tDexFile, err := setupTestBlock(t, test) 67 | 68 | defer os.Remove(tDexFile.Name()) 69 | 70 | if err := check(t, err, "error setting up test"); err != nil { 71 | continue 72 | } 73 | 74 | var output bytes.Buffer 75 | 76 | config := ExecConfig{ 77 | Stdout: &output, 78 | Stderr: &output, 79 | } 80 | 81 | processBlock(block, config) 82 | 83 | assert.Equal(t, test.CommandOut, output.String()) 84 | } 85 | } 86 | --------------------------------------------------------------------------------