github.com/JarrahG/buffalocli@v0.0.0-20230801092127-b85bfd5d395a/internal/plugins/plugins.go (about)

     1  package plugins
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"encoding/json"
     7  	"errors"
     8  	"io/fs"
     9  	"os"
    10  	"os/exec"
    11  	"path/filepath"
    12  	"strings"
    13  	"sync"
    14  	"time"
    15  
    16  	"github.com/JarrahG/buffalocli/internal/plugins/plugdeps"
    17  	"github.com/gobuffalo/envy"
    18  	"github.com/gobuffalo/meta"
    19  	"github.com/sirupsen/logrus"
    20  )
    21  
    22  const timeoutEnv = "BUFFALO_PLUGIN_TIMEOUT"
    23  
    24  var t = time.Second * 2
    25  var initTimeoutOnce sync.Once
    26  
    27  func timeout() time.Duration {
    28  	initTimeoutOnce.Do(func() {
    29  		rawTimeout, err := envy.MustGet(timeoutEnv)
    30  		if err == nil {
    31  			if parsed, err := time.ParseDuration(rawTimeout); err == nil {
    32  				t = parsed
    33  			} else {
    34  				logrus.Errorf("%q value is malformed assuming default %q: %v", timeoutEnv, t, err)
    35  			}
    36  		} else {
    37  			logrus.Debugf("%q not set, assuming default of %v", timeoutEnv, t)
    38  		}
    39  	})
    40  	return t
    41  }
    42  
    43  // List maps a Buffalo command to a slice of Command
    44  type List map[string]Commands
    45  
    46  var _list List
    47  var scanPlugsOnce sync.Once
    48  
    49  // Available plugins for the `buffalo` command.
    50  // It will look in $GOPATH/bin and the `./plugins` directory.
    51  // This can be changed by setting the $BUFFALO_PLUGIN_PATH
    52  // environment variable.
    53  //
    54  // Requirements:
    55  // * file/command must be executable
    56  // * file/command must start with `buffalo-`
    57  // * file/command must respond to `available` and return JSON of
    58  //	 plugins.Commands{}
    59  //
    60  // Limit full path scan with direct plugin path
    61  //
    62  // If a file/command doesn't respond to being invoked with `available`
    63  // within one second, buffalo will assume that it is unable to load. This
    64  // can be changed by setting the $BUFFALO_PLUGIN_TIMEOUT environment
    65  // variable. It must be set to a duration that `time.ParseDuration` can
    66  // process.
    67  func Available() (List, error) {
    68  	var err error
    69  	scanPlugsOnce.Do(func() {
    70  		defer func() {
    71  			if err := saveCache(); err != nil {
    72  				logrus.Error(err)
    73  			}
    74  		}()
    75  
    76  		app := meta.New(".")
    77  
    78  		if plugdeps.On(app) {
    79  			_list, err = listPlugDeps(app)
    80  			return
    81  		}
    82  
    83  		paths := []string{"plugins"}
    84  
    85  		from, err := envy.MustGet("BUFFALO_PLUGIN_PATH")
    86  		if err != nil {
    87  			from, err = envy.MustGet("GOPATH")
    88  			if err != nil {
    89  				return
    90  			}
    91  			from = filepath.Join(from, "bin")
    92  		}
    93  
    94  		paths = append(paths, strings.Split(from, string(os.PathListSeparator))...)
    95  
    96  		list := List{}
    97  		for _, p := range paths {
    98  			if ignorePath(p) {
    99  				continue
   100  			}
   101  			if _, err := os.Stat(p); err != nil {
   102  				continue
   103  			}
   104  
   105  			err := fs.WalkDir(os.DirFS(p), ".", func(path string, d fs.DirEntry, err error) error {
   106  				if err != nil {
   107  					return nil
   108  				}
   109  				if d.IsDir() {
   110  					return nil
   111  				}
   112  
   113  				base := filepath.Base(path)
   114  				if strings.HasPrefix(base, "buffalo-") && !strings.HasPrefix(base, "buffalo-plugins") {
   115  					ctx, cancel := context.WithTimeout(context.Background(), timeout())
   116  					commands := askBin(ctx, path)
   117  					cancel()
   118  					for _, c := range commands {
   119  						bc := c.BuffaloCommand
   120  						if _, ok := list[bc]; !ok {
   121  							list[bc] = Commands{}
   122  						}
   123  						c.Binary = path
   124  						list[bc] = append(list[bc], c)
   125  					}
   126  				}
   127  				return nil
   128  			})
   129  			if err != nil {
   130  				return
   131  			}
   132  		}
   133  		_list = list
   134  	})
   135  	return _list, err
   136  }
   137  
   138  func askBin(ctx context.Context, path string) Commands {
   139  	start := time.Now()
   140  	defer func() {
   141  		logrus.Debugf("askBin %s=%.4f s", path, time.Since(start).Seconds())
   142  	}()
   143  
   144  	commands := Commands{}
   145  	if cp, ok := findInCache(path); ok {
   146  		s := sum(path)
   147  		if s == cp.CheckSum {
   148  			logrus.Debugf("cache hit: %s", path)
   149  			commands = cp.Commands
   150  			return commands
   151  		}
   152  	}
   153  	logrus.Debugf("cache miss: %s", path)
   154  	if strings.HasPrefix(filepath.Base(path), "buffalo-no-sqlite") {
   155  		return commands
   156  	}
   157  
   158  	cmd := exec.CommandContext(ctx, path, "available")
   159  	bb := &bytes.Buffer{}
   160  	cmd.Stdout = bb
   161  	err := cmd.Run()
   162  	if err != nil {
   163  		logrus.Errorf("[PLUGIN] error loading plugin %s: %s\n", path, err)
   164  		return commands
   165  	}
   166  
   167  	msg := bb.String()
   168  	for len(msg) > 0 {
   169  		err = json.NewDecoder(strings.NewReader(msg)).Decode(&commands)
   170  		if err == nil {
   171  			addToCache(path, cachedPlugin{
   172  				Commands: commands,
   173  			})
   174  			return commands
   175  		}
   176  		msg = msg[1:]
   177  	}
   178  	logrus.Errorf("[PLUGIN] error decoding plugin %s: %s\n%s\n", path, err, msg)
   179  	return commands
   180  }
   181  
   182  func ignorePath(p string) bool {
   183  	p = strings.ToLower(p)
   184  	for _, x := range []string{`c:\windows`, `c:\program`} {
   185  		if strings.HasPrefix(p, x) {
   186  			return true
   187  		}
   188  	}
   189  	return false
   190  }
   191  
   192  func listPlugDeps(app meta.App) (List, error) {
   193  	list := List{}
   194  	plugs, err := plugdeps.List(app)
   195  	if err != nil {
   196  		return list, err
   197  	}
   198  	for _, p := range plugs.List() {
   199  		ctx, cancel := context.WithTimeout(context.Background(), timeout())
   200  		defer cancel()
   201  		bin := p.Binary
   202  		if len(p.Local) != 0 {
   203  			bin = p.Local
   204  		}
   205  		bin, err := LookPath(bin)
   206  		if err != nil {
   207  			if !errors.Is(err, ErrPlugMissing) {
   208  				return list, err
   209  			}
   210  			continue
   211  		}
   212  		commands := askBin(ctx, bin)
   213  		cancel()
   214  		for _, c := range commands {
   215  			bc := c.BuffaloCommand
   216  			if _, ok := list[bc]; !ok {
   217  				list[bc] = Commands{}
   218  			}
   219  			c.Binary = p.Binary
   220  			for _, pc := range p.Commands {
   221  				if c.Name == pc.Name {
   222  					c.Flags = pc.Flags
   223  					break
   224  				}
   225  			}
   226  			list[bc] = append(list[bc], c)
   227  		}
   228  	}
   229  	return list, nil
   230  }