github.com/nuvolaris/nuv@v0.0.0-20240511174247-a74e3a52bfd8/nuv.go (about) 1 // Licensed to the Apache Software Foundation (ASF) under one 2 // or more contributor license agreements. See the NOTICE file 3 // distributed with this work for additional information 4 // regarding copyright ownership. The ASF licenses this file 5 // to you under the Apache License, Version 2.0 (the 6 // "License"); you may not use this file except in compliance 7 // with the License. You may obtain a copy of the License at 8 // 9 // http://www.apache.org/licenses/LICENSE-2.0 10 // 11 // Unless required by applicable law or agreed to in writing, 12 // software distributed under the License is distributed on an 13 // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 // KIND, either express or implied. See the License for the 15 // specific language governing permissions and limitations 16 // under the License. 17 package main 18 19 import ( 20 "bufio" 21 "fmt" 22 "os" 23 "regexp" 24 "sort" 25 "strings" 26 27 docopt "github.com/docopt/docopt-go" 28 "github.com/mitchellh/go-homedir" 29 "golang.org/x/exp/slices" 30 "gopkg.in/yaml.v3" 31 32 envsubst "github.com/nuvolaris/envsubst/cmd/envsubstmain" 33 ) 34 35 type TaskNotFoundErr struct { 36 input string 37 } 38 39 func (e *TaskNotFoundErr) Error() string { 40 return fmt.Sprintf("no command named %s found", e.input) 41 } 42 43 func help() error { 44 if os.Getenv("NUV_NO_NUVOPTS") == "" && exists(".", NUVOPTS) { 45 os.Args = []string{"envsubst", "-no-unset", "-i", NUVOPTS} 46 return envsubst.EnvsubstMain() 47 } 48 // In case of syntax error, Task will return an error 49 list := "-l" 50 if os.Getenv("NUV_NO_NUVOPTS") != "" { 51 list = "--list-all" 52 } 53 _, err := Task("-t", NUVFILE, list) 54 55 return err 56 } 57 58 // parseArgs parse the arguments acording the docopt 59 // it returns a sequence suitable to be feed as arguments for task. 60 // note that it will change hyphens for flags ('-c', '--count') to '_' ('_c' '__count') 61 // and '<' and '>' for parameters '_' (<hosts> => _hosts_) 62 // boolean are "true" or "false" and arrays in the form ('first' 'second') 63 // suitable to be used as arrays 64 // Examples: 65 // if "Usage: nettool ping [--count=<max>] <hosts>..." 66 // with "ping --count=3 google apple" returns 67 // ping=true _count=3 _hosts_=('google' 'apple') 68 func parseArgs(usage string, args []string) []string { 69 res := []string{} 70 // parse args 71 parser := docopt.Parser{} 72 opts, err := parser.ParseArgs(usage, args, NuvVersion) 73 if err != nil { 74 warn(err) 75 return res 76 } 77 for k, v := range opts { 78 kk := strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(k, "-", "_"), "<", "_"), ">", "_") 79 vv := "" 80 //fmt.Println(v, reflect.TypeOf(v)) 81 switch o := v.(type) { 82 case bool: 83 vv = "false" 84 if o { 85 vv = "true" 86 } 87 case string: 88 vv = o 89 case []string: 90 a := []string{} 91 for _, i := range o { 92 a = append(a, fmt.Sprintf("'%v'", i)) 93 } 94 vv = "(" + strings.Join(a, " ") + ")" 95 case nil: 96 vv = "" 97 } 98 res = append(res, fmt.Sprintf("%s=%s", kk, vv)) 99 } 100 sort.Strings(res) 101 return res 102 } 103 104 // setupTmp sets up a tmp folder 105 func setupTmp() { 106 // setup NUV_TMP 107 var err error 108 tmp := os.Getenv("NUV_TMP") 109 if tmp == "" { 110 tmp, err = homedir.Expand("~/.nuv/tmp") 111 if err == nil { 112 //nolint:errcheck 113 os.Setenv("NUV_TMP", tmp) 114 } 115 } 116 if err == nil { 117 err = os.MkdirAll(tmp, 0755) 118 } 119 if err != nil { 120 warn("cannot create tmp dir", err) 121 os.Exit(1) 122 } 123 } 124 125 // load saved args in files names _*_ in current directory 126 func loadSavedArgs() []string { 127 res := []string{} 128 files, err := os.ReadDir(".") 129 if err != nil { 130 return res 131 } 132 r := regexp.MustCompile(`^_.+_$`) // regex to match file names that start and end with '_' 133 for _, f := range files { 134 if !f.IsDir() && r.MatchString(f.Name()) { 135 debug("reading vars from " + f.Name()) 136 file, err := os.Open(f.Name()) 137 if err != nil { 138 warn("cannot read " + f.Name()) 139 continue 140 } 141 scanner := bufio.NewScanner(file) 142 r := regexp.MustCompile(`^[a-zA-Z0-9]+=`) // regex to match lines that start with an alphanumeric sequence followed by '=' 143 for scanner.Scan() { 144 line := scanner.Text() 145 if r.MatchString(line) { 146 debug("found var " + line) 147 res = append(res, line) 148 } 149 } 150 err = scanner.Err() 151 //nolint:errcheck 152 file.Close() 153 if err != nil { 154 warn(err) 155 continue 156 } 157 } 158 } 159 return res 160 } 161 162 // Nuv parses args moving into the folder corresponding to args 163 // then parses them with docopts and invokes the task 164 func Nuv(base string, args []string) error { 165 trace("Nuv run in", base, "with", args) 166 // go down using args as subcommands 167 err := os.Chdir(base) 168 debug("Nuv chdir", base) 169 170 if err != nil { 171 return err 172 } 173 rest := args 174 175 isSubCmd := false 176 for _, task := range args { 177 trace("task name", task) 178 179 // skip flags 180 if strings.HasPrefix(task, "-") { 181 continue 182 } 183 184 // try to correct name if it's not a flag 185 pwd, _ := os.Getwd() 186 taskName, err := validateTaskName(pwd, task) 187 if err != nil { 188 return err 189 } 190 // if valid, check if it's a folder and move to it 191 if isDir(taskName) && exists(taskName, NUVFILE) { 192 if err := os.Chdir(taskName); err != nil { 193 return err 194 } 195 //remove it from the args 196 rest = rest[1:] 197 isSubCmd = true 198 } else { 199 // stop when non folder reached 200 //substitute it with the validated task name 201 if len(rest) > 0 { 202 rest[0] = taskName 203 } 204 break 205 } 206 } 207 208 if len(rest) == 0 || rest[0] == "help" { 209 trace("print help") 210 err := help() 211 if !isSubCmd { 212 fmt.Println() 213 return printPluginsHelp() 214 } 215 return err 216 } 217 218 // load saved args 219 savedArgs := loadSavedArgs() 220 221 // parsed args 222 if os.Getenv("NUV_NO_NUVOPTS") == "" && exists(".", NUVOPTS) { 223 trace("PREPARSE:", rest) 224 parsedArgs := parseArgs(readfile(NUVOPTS), rest) 225 prefix := []string{"-t", NUVFILE} 226 if len(rest) > 0 && rest[0][0] != '-' { 227 prefix = append(prefix, rest[0]) 228 } 229 230 parsedArgs = append(savedArgs, parsedArgs...) 231 parsedArgs = append(prefix, parsedArgs...) 232 extra := os.Getenv("EXTRA") 233 if extra != "" { 234 trace("EXTRA:", extra) 235 parsedArgs = append(parsedArgs, strings.Split(extra, " ")...) 236 } 237 trace("POSTPARSE:", parsedArgs) 238 _, err := Task(parsedArgs...) 239 return err 240 } 241 242 mainTask := rest[0] 243 244 // unparsed args - separate variable assignments from extra args 245 pre := []string{"-t", NUVFILE, mainTask} 246 pre = append(pre, savedArgs...) 247 post := []string{"--"} 248 args1 := rest[1:] 249 extra := os.Getenv("EXTRA") 250 if extra != "" { 251 trace("EXTRA:", extra) 252 args1 = append(args1, strings.Split(extra, " ")...) 253 } 254 for _, s := range args1 { 255 if strings.Contains(s, "=") { 256 pre = append(pre, s) 257 } else { 258 post = append(post, s) 259 } 260 } 261 taskArgs := append(pre, post...) 262 263 debug("task args: ", taskArgs) 264 _, err = Task(taskArgs...) 265 return err 266 } 267 268 // validateTaskName does the following: 269 // 1. Check that the given task name is found in the nuvfile.yaml and return it 270 // 2. If not found, check if the input is a prefix of any task name, if it is for only one return the proper task name 271 // 3. If the prefix is valid for more than one task, return an error 272 // 4. If the prefix is not valid for any task, return an error 273 func validateTaskName(dir string, name string) (string, error) { 274 if name == "" { 275 return "", fmt.Errorf("command name is empty") 276 } 277 278 candidates := []string{} 279 tasks := getTaskNamesList(dir) 280 if !slices.Contains(tasks, "help") { 281 tasks = append(tasks, "help") 282 } 283 for _, t := range tasks { 284 if t == name { 285 return name, nil 286 } 287 if strings.HasPrefix(t, name) { 288 candidates = append(candidates, t) 289 } 290 } 291 292 if len(candidates) == 0 { 293 return "", &TaskNotFoundErr{input: name} 294 } 295 296 if len(candidates) == 1 { 297 return candidates[0], nil 298 } 299 300 return "", fmt.Errorf("ambiguous command: %s. Possible matches: %v", name, candidates) 301 } 302 303 // obtains the task names from the nuvfile.yaml inside the given directory 304 func getTaskNamesList(dir string) []string { 305 m := make(map[interface{}]interface{}) 306 var taskNames []string 307 if exists(dir, NUVFILE) { 308 dat, err := os.ReadFile(joinpath(dir, NUVFILE)) 309 if err != nil { 310 return make([]string, 0) 311 } 312 313 err = yaml.Unmarshal(dat, &m) 314 if err != nil { 315 warn("error reading nuvfile.yml") 316 return make([]string, 0) 317 } 318 tasksMap, ok := m["tasks"].(map[string]interface{}) 319 if !ok { 320 // warn("error checking task list, perhaps no tasks defined?") 321 return make([]string, 0) 322 } 323 324 for k := range tasksMap { 325 taskNames = append(taskNames, k) 326 } 327 328 } 329 330 // for each subfolder, check if it has a nuvfile.yaml 331 // if it does, add it to the list of tasks 332 333 // get subfolders 334 subfolders, err := os.ReadDir(dir) 335 if err != nil { 336 warn("error reading subfolders of", dir) 337 return taskNames 338 } 339 340 for _, f := range subfolders { 341 if f.IsDir() { 342 subfolder := joinpath(dir, f.Name()) 343 if exists(subfolder, NUVFILE) { 344 // check if not contained 345 name := f.Name() 346 if !slices.Contains(taskNames, name) { 347 taskNames = append(taskNames, name) 348 } 349 } 350 } 351 } 352 353 return taskNames 354 }