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 }