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  }