github.com/sudo-bmitch/version-bump@v0.0.0-20240503123857-70b0e3f646dd/root.go (about)

     1  package main
     2  
     3  import (
     4  	"bytes"
     5  	"errors"
     6  	"fmt"
     7  	"io"
     8  	"io/fs"
     9  	"os"
    10  	"path/filepath"
    11  	"strings"
    12  
    13  	"github.com/spf13/cobra"
    14  	"github.com/sudo-bmitch/version-bump/internal/action"
    15  	"github.com/sudo-bmitch/version-bump/internal/config"
    16  	"github.com/sudo-bmitch/version-bump/internal/filesearch"
    17  	"github.com/sudo-bmitch/version-bump/internal/lockfile"
    18  	"github.com/sudo-bmitch/version-bump/internal/scan"
    19  	"github.com/sudo-bmitch/version-bump/internal/template"
    20  	"github.com/sudo-bmitch/version-bump/internal/version"
    21  )
    22  
    23  const (
    24  	defaultConf = ".version-bump.yml"
    25  	defaultLock = ".version-bump.lock"
    26  	envConf     = "VERSION_BUMP_CONF"
    27  	envLock     = "VERSION_BUMP_LOCK"
    28  )
    29  
    30  var rootOpts struct {
    31  	chdir     string
    32  	confFile  string
    33  	lockFile  string
    34  	dryrun    bool
    35  	verbosity string
    36  	logopts   []string
    37  	format    string
    38  	scans     []string
    39  }
    40  
    41  var rootCmd = &cobra.Command{
    42  	Use:           "version-bump <cmd>",
    43  	Short:         "Version and pinning management tool",
    44  	Long:          `version-bump updates versions embedded in various files of your project`,
    45  	SilenceUsage:  true,
    46  	SilenceErrors: true,
    47  }
    48  
    49  // check
    50  var checkCmd = &cobra.Command{
    51  	Use:   "check <file list>",
    52  	Short: "Check versions in files compared to sources",
    53  	Long: `Check each file identified in the configuration for versions.
    54  Compare the version to the upstream source. Report any version mismatches.
    55  Files or directories to scan should be passed as arguments, with the current dir as the default.
    56  By default, the current directory is changed to the location of the config file.`,
    57  	RunE: runAction,
    58  }
    59  
    60  // update
    61  var updateCmd = &cobra.Command{
    62  	Use:   "update <file list>",
    63  	Short: "Update versions in files using upstream sources",
    64  	Long: `Scan each file identified in the configuration for versions.
    65  Compare the version to the upstream source.
    66  Update old versions, update the lock file, and report changes.
    67  Files or directories to scan should be passed as arguments, with the current dir as the default.
    68  By default, the current directory is changed to the location of the config file.`,
    69  	RunE: runAction,
    70  }
    71  
    72  // TODO:
    73  // set
    74  // reset
    75  
    76  // scan
    77  var scanCmd = &cobra.Command{
    78  	Use:   "scan <file list>",
    79  	Short: "Scan versions from files into lock file",
    80  	Long: `Scan each file identified in the configuration for versions.
    81  Store those versions in lock file.
    82  Files or directories to scan should be passed as arguments, with the current dir as the default.
    83  By default, the current directory is changed to the location of the config file.`,
    84  	RunE: runAction,
    85  }
    86  
    87  var versionCmd = &cobra.Command{
    88  	Use:   "version",
    89  	Short: "Show the version",
    90  	Long:  `Show the version`,
    91  	Args:  cobra.ExactArgs(0),
    92  	RunE:  runVersion,
    93  }
    94  
    95  func init() {
    96  	for _, cmd := range []*cobra.Command{checkCmd, scanCmd, updateCmd} {
    97  		cmd.Flags().StringVar(&rootOpts.chdir, "chdir", "", "Changes to requested directory, defaults to config file location")
    98  		cmd.Flags().StringVarP(&rootOpts.confFile, "conf", "c", "", "Config file to load")
    99  		cmd.Flags().BoolVar(&rootOpts.dryrun, "dry-run", false, "Dry run")
   100  		cmd.Flags().StringArrayVar(&rootOpts.scans, "scan", []string{}, "Only run specific scans")
   101  		rootCmd.AddCommand(cmd)
   102  	}
   103  
   104  	versionCmd.Flags().StringVar(&rootOpts.format, "format", "{{printPretty .}}", "Format output with go template syntax")
   105  	rootCmd.AddCommand(versionCmd)
   106  }
   107  
   108  func runAction(cmd *cobra.Command, args []string) error {
   109  	origDir := "."
   110  	// parse config
   111  	conf, err := getConf()
   112  	if err != nil {
   113  		return fmt.Errorf("failed to load config: %w", err)
   114  	}
   115  	locks, err := getLocks()
   116  	if err != nil {
   117  		return fmt.Errorf("failed to load lockfile: %w", err)
   118  	}
   119  
   120  	// cd to appropriate location
   121  	if !flagChanged(cmd, "chdir") {
   122  		rootOpts.chdir = filepath.Dir(rootOpts.confFile)
   123  	}
   124  	if rootOpts.chdir != "." {
   125  		origDir, err = os.Getwd()
   126  		if err != nil {
   127  			return fmt.Errorf("unable to get current directory: %w", err)
   128  		}
   129  		err = os.Chdir(rootOpts.chdir)
   130  		if err != nil {
   131  			return fmt.Errorf("unable to change directory to %s: %w", rootOpts.chdir, err)
   132  		}
   133  	}
   134  
   135  	confRun := &action.Opts{
   136  		DryRun: rootOpts.dryrun,
   137  		Locks:  locks,
   138  	}
   139  	switch cmd.Name() {
   140  	case "check":
   141  		confRun.Action = action.ActionCheck
   142  	case "scan":
   143  		confRun.Action = action.ActionScan
   144  	case "update":
   145  		confRun.Action = action.ActionUpdate
   146  	default:
   147  		return fmt.Errorf("unhandled command %s", cmd.Name())
   148  	}
   149  	act := action.New(confRun, *conf)
   150  
   151  	// loop over files
   152  	walk, err := filesearch.New(args, conf.Files)
   153  	if err != nil {
   154  		return err
   155  	}
   156  	for {
   157  		filename, key, err := walk.Next()
   158  		if err != nil {
   159  			if errors.Is(err, io.EOF) {
   160  				break
   161  			}
   162  			return err
   163  		}
   164  		fmt.Printf("processing file: %s for config %s\n", filename, key)
   165  		err = procFile(filename, key, conf, act)
   166  		if err != nil {
   167  			return err
   168  		}
   169  	}
   170  	err = act.Done()
   171  	if err != nil {
   172  		return err
   173  	}
   174  	// display changes
   175  	for _, change := range confRun.Changes {
   176  		fmt.Printf("Version changed: filename=%s, source=%s, scan=%s, old=%s, new=%s\n",
   177  			change.Filename, change.Source, change.Scan, change.Orig, change.New)
   178  	}
   179  
   180  	if origDir != "." {
   181  		err = os.Chdir(origDir)
   182  		if err != nil {
   183  			return fmt.Errorf("unable to change directory to %s: %w", origDir, err)
   184  		}
   185  	}
   186  	if !rootOpts.dryrun {
   187  		switch confRun.Action {
   188  		case action.ActionScan, action.ActionUpdate:
   189  			err = saveLocks(locks)
   190  			if err != nil {
   191  				return err
   192  			}
   193  		case action.ActionCheck:
   194  			if len(confRun.Changes) > 0 {
   195  				return fmt.Errorf("changes detected")
   196  			}
   197  		}
   198  	}
   199  	return nil
   200  }
   201  
   202  func runVersion(cmd *cobra.Command, args []string) error {
   203  	info := version.GetInfo()
   204  	return template.Writer(os.Stdout, rootOpts.format, info)
   205  }
   206  
   207  func flagChanged(cmd *cobra.Command, name string) bool {
   208  	flag := cmd.Flags().Lookup(name)
   209  	if flag == nil {
   210  		return false
   211  	}
   212  	return flag.Changed
   213  }
   214  
   215  func getConf() (*config.Config, error) {
   216  	// if conf not provided, attempt to use env
   217  	if rootOpts.confFile == "" {
   218  		if file, ok := os.LookupEnv(envConf); ok {
   219  			rootOpts.confFile = file
   220  		}
   221  	}
   222  	// fall back to fixed name
   223  	if rootOpts.confFile == "" {
   224  		rootOpts.confFile = defaultConf
   225  	}
   226  	return config.LoadFile(rootOpts.confFile)
   227  }
   228  
   229  func getLocks() (*lockfile.Locks, error) {
   230  	if rootOpts.lockFile == "" {
   231  		if file, ok := os.LookupEnv(envLock); ok {
   232  			rootOpts.lockFile = file
   233  		}
   234  	}
   235  	// fall back to changing conf filename
   236  	if rootOpts.lockFile == "" && rootOpts.confFile != "" {
   237  		rootOpts.lockFile = strings.TrimSuffix(rootOpts.confFile, filepath.Ext(rootOpts.confFile)) + ".lock"
   238  	}
   239  	// fall back to fixed name
   240  	if rootOpts.lockFile == "" {
   241  		rootOpts.lockFile = defaultLock
   242  	}
   243  	l, err := lockfile.LoadFile(rootOpts.lockFile)
   244  	if err != nil {
   245  		if !errors.Is(err, fs.ErrNotExist) {
   246  			return nil, err
   247  		}
   248  		l = lockfile.New()
   249  	}
   250  	return l, nil
   251  }
   252  
   253  func saveLocks(l *lockfile.Locks) error {
   254  	if rootOpts.lockFile == "" {
   255  		return fmt.Errorf("lockfile not defined")
   256  	}
   257  	return lockfile.SaveFile(rootOpts.lockFile, l)
   258  }
   259  
   260  func procFile(filename string, fileConf string, conf *config.Config, act *action.Action) (err error) {
   261  	// TODO: for large files, write to a tmp file instead of using an in-memory buffer
   262  	origBytes, err := os.ReadFile(filename)
   263  	if err != nil {
   264  		return err
   265  	}
   266  	origRdr := bytes.NewReader(origBytes)
   267  	var curFH io.ReadCloser
   268  	curFH = io.NopCloser(origRdr)
   269  	defer func() {
   270  		if curFH != nil {
   271  			newErr := curFH.Close()
   272  			if newErr != nil && err == nil {
   273  				err = newErr
   274  			}
   275  		}
   276  	}()
   277  	scanFound := false
   278  	for _, s := range conf.Files[fileConf].Scans {
   279  		// skip scans when CLI arg requests specific scans
   280  		if len(rootOpts.scans) > 0 && !containsStr(rootOpts.scans, s) {
   281  			continue
   282  		}
   283  		if _, ok := conf.Scans[s]; !ok {
   284  			return fmt.Errorf("missing scan config: %s, file config: %s, reading file: %s", s, fileConf, filename)
   285  		}
   286  		curScan, err := scan.New(*conf.Scans[s], curFH, act, filename)
   287  		if err != nil {
   288  			return fmt.Errorf("failed scanning file \"%s\", scan \"%s\": %w", filename, s, err)
   289  		}
   290  		curFH = curScan
   291  		scanFound = true
   292  	}
   293  	if !scanFound {
   294  		return nil
   295  	}
   296  	finalBytes, err := io.ReadAll(curFH)
   297  	if err != nil {
   298  		return fmt.Errorf("failed scanning file \"%s\": %w", filename, err)
   299  	}
   300  	// if the file was changed, output to a tmpfile and then copy/replace orig file
   301  	if !bytes.Equal(origBytes, finalBytes) {
   302  		dir := filepath.Dir(filename)
   303  		tmp, err := os.CreateTemp(dir, filepath.Base(filename))
   304  		if err != nil {
   305  			return fmt.Errorf("unable to create temp file in %s: %w", dir, err)
   306  		}
   307  		tmpName := tmp.Name()
   308  		_, err = tmp.Write(finalBytes)
   309  		tmp.Close()
   310  		defer func() {
   311  			if err != nil {
   312  				os.Remove(tmpName)
   313  			}
   314  		}()
   315  		if err != nil {
   316  			return fmt.Errorf("failed to write temp file %s: %w", tmpName, err)
   317  		}
   318  		// update permissions to match existing file or 0644
   319  		mode := os.FileMode(0644)
   320  		stat, err := os.Stat(filename)
   321  		if err == nil && stat.Mode().IsRegular() {
   322  			mode = stat.Mode()
   323  		}
   324  		if err := os.Chmod(tmpName, mode); err != nil {
   325  			return fmt.Errorf("failed to adjust permissions on file %s: %w", filename, err)
   326  		}
   327  		// move temp file to target filename
   328  		if err := os.Rename(tmpName, filename); err != nil {
   329  			return fmt.Errorf("failed to rename file %s to %s: %w", tmpName, filename, err)
   330  		}
   331  	}
   332  	return nil
   333  }
   334  
   335  func containsStr(strList []string, str string) bool {
   336  	for _, cur := range strList {
   337  		if cur == str {
   338  			return true
   339  		}
   340  	}
   341  	return false
   342  }