diff --git a/go.mod b/go.mod index 8ab7850..02feea2 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,13 @@ go 1.24 require github.com/jessevdk/go-flags v1.6.1 -require github.com/fsnotify/fsnotify v1.9.0 +require ( + github.com/fatih/color v1.18.0 + github.com/fsnotify/fsnotify v1.9.0 +) -require golang.org/x/sys v0.21.0 // indirect +require ( + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + golang.org/x/sys v0.25.0 // indirect +) diff --git a/go.sum b/go.sum index 15bb6c9..1d481ca 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,15 @@ +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4= github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc= -golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= -golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/listen.go b/listen.go index 2c434a5..facee04 100644 --- a/listen.go +++ b/listen.go @@ -12,6 +12,7 @@ import ( "slices" "time" + color "github.com/fatih/color" "github.com/fsnotify/fsnotify" ) @@ -24,6 +25,7 @@ type Listen struct { Interval time.Duration Command []string Quiet bool + RunFirst bool } func cksumCheck(l Listen, k string, t *bool) bool { @@ -61,6 +63,50 @@ func allCheck(l Listen) bool { return true } +func runTrigger(l Listen, f string, r bool, quit chan bool) bool { + if !l.Quiet && !r { + if l.Condition == "any" { + color.New(color.FgWhite).Fprintf(os.Stderr, "& File %s modified. ", f) + } else { + color.New(color.FgWhite).Fprint(os.Stderr, "& All files have been modified. ") + } + } + + if len(l.Command) >= 1 { + if !l.Quiet { + color.New(color.FgWhite).Fprintln(os.Stderr, "Running command...") + } + + cmd := exec.Command(l.Command[0], l.Command[1:]...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + log.Fatalln(err) + } + + if err := cmd.Wait(); err != nil { + if rc, ok := err.(*exec.ExitError); ok { + if !l.Quiet { + color.New(color.FgYellow).Fprintf(os.Stderr, "& WARNING: Command exited with code %d.\n", rc.ExitCode()) + } + } + } + + if !l.Quiet { + color.New(color.FgWhite).Fprintln(os.Stderr, "& Returned to listening...") + } + } else { + if !l.Quiet { + fmt.Println() + } + + quit <- true + return true + } + + return false +} + func loopFsnotify(l Listen, quit chan bool) { watcher, err := fsnotify.NewWatcher() if err != nil { @@ -74,6 +120,7 @@ func loopFsnotify(l Listen, quit chan bool) { for { var renameAdd []string = []string{} + fileMod := "" trigger := false select { case event, ok := <-watcher.Events: @@ -86,11 +133,13 @@ func loopFsnotify(l Listen, quit chan bool) { if l.Cksum { // return value indicates a break is needed if cksumCheck(l, key, &trigger) { + fileMod = key break } } else { if event.Has(fsnotify.Write) { if l.Condition == "any" { + fileMod = key trigger = true break } @@ -103,6 +152,7 @@ func loopFsnotify(l Listen, quit chan bool) { // ... then adding to a list to allow additional time renameAdd = append(renameAdd, key) if l.Condition == "any" { + fileMod = key trigger = true break } @@ -125,13 +175,8 @@ func loopFsnotify(l Listen, quit chan bool) { // end case errors if trigger { - if len(l.Command) >= 1 { - cmd := exec.Command(l.Command[0], l.Command[1:]...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.Run() - } else { - quit <- true + if runTrigger(l, fileMod, false, quit) { + return } } // end if trigger @@ -147,12 +192,14 @@ func loopFsnotify(l Listen, quit chan bool) { func loopInterval(l Listen, quit chan bool) { for { time.Sleep(l.Interval) + fileMod := "" trigger := false for key := range l.FileMap { if l.Cksum { // return value indicates a break is needed if cksumCheck(l, key, &trigger) { + fileMod = key break } } else { @@ -164,6 +211,7 @@ func loopInterval(l Listen, quit chan bool) { if l.IntervalMap[key] != s.ModTime() { l.IntervalMap[key] = s.ModTime() if l.Condition == "any" { + fileMod = key trigger = true break } @@ -179,25 +227,79 @@ func loopInterval(l Listen, quit chan bool) { } if trigger { - if len(l.Command) >= 1 { - cmd := exec.Command(l.Command[0], l.Command[1:]...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.Run() - } else { - quit <- true + if runTrigger(l, fileMod, false, quit) { + return } } // end if trigger } } +func startMessage(l Listen) { + c := color.New(color.FgWhite) + c.Fprintf(os.Stderr, "& listen %s\n", VERSION) + c.Fprintln(os.Stderr, "& This program is free software, and comes with ABSOLUTELY NO WARRANTY.") + c.Fprintln(os.Stderr, "& Run 'listen --license' for details.") + c.Fprintln(os.Stderr, "&") + if len(l.Command) >= 1 { + c.Fprintln(os.Stderr, "& This command will run:") + c.Fprint(os.Stderr, "& ") + for _, value := range l.Command { + c.Fprint(os.Stderr, value, " ") + } + + c.Fprintln(os.Stderr) + c.Fprint(os.Stderr, "& When ") + } else { + c.Fprint(os.Stderr, "& listen will exit when ") + } + + if l.Cksum { + c.Fprint(os.Stderr, "the checksum of ") + } + + if len(l.FileMap) >= 2 { + if l.Condition == "any" { + c.Fprint(os.Stderr, "any of ") + } else { + c.Fprint(os.Stderr, "all ") + } + + c.Fprint(os.Stderr, "these files have ") + } else { + c.Fprint(os.Stderr, "this file has ") + } + + if l.Cksum { + c.Fprintln(os.Stderr, "changed:") + } else { + c.Fprintln(os.Stderr, "been modified:") + } + + for key := range l.FileMap { + c.Fprintf(os.Stderr, "& %s\n", key) + } +} + func (l Listen) Run() { // catch ^C quit := make(chan bool) c := make(chan os.Signal, 1) signal.Notify(c, os.Interrupt) + if !l.Quiet { + startMessage(l) + } + + if l.RunFirst { + if !l.Quiet { + color.New(color.FgWhite).Fprintf(os.Stderr, "& -r option specified. ") + } + runTrigger(l, "", true, quit) + } else { + color.New(color.FgWhite).Fprintln(os.Stderr, "& Starting now...") + } + // start main loop if l.Interval == time.Duration(0*time.Second) { go loopFsnotify(l, quit) @@ -207,7 +309,12 @@ func (l Listen) Run() { select { case <-c: + fmt.Println() case <-quit: } + if !l.Quiet { + color.New(color.FgWhite).Fprintln(os.Stderr, "& Exiting...") + } + } diff --git a/main.go b/main.go index a2dfd3c..6587fc7 100644 --- a/main.go +++ b/main.go @@ -13,25 +13,26 @@ import ( flags "github.com/jessevdk/go-flags" ) -const VERSION string = "v0.1.0" +const VERSION string = "v0.2.0" -var opts struct { +type Options struct { Version bool `short:"v" long:"version" description:"Displays version info and exits"` - Quiet bool `short:"q" long:"quiet" description:"Suppresses all non-error output"` + Quiet bool `short:"q" long:"quiet" description:"Suppresses status messages (stderr lines beginning with '&')"` Files []string `short:"f" long:"file" description:"File(s) to listen to (watch)" value-name:"FILE"` Condition string `short:"w" long:"when" description:"If multiple files are specified, choose if any file or all files specified are needed to trigger COMMAND" default:"any" choice:"any" choice:"all"` Checksum bool `short:"c" long:"checksum" description:"Use checksum to determine when file(s) are changed instead of writes/modification time"` Interval string `short:"i" long:"interval" description:"Use this time interval (ex. 5m30s, 1s) between filesystem checks instead of watching kernel events. If the interval is effectively 0 (the default), kernel events are used" default:"0s"` + RunFirst bool `short:"r" long:"run" description:"Runs COMMAND (if specified) before starting listen"` } -func validateArgs(files []string, interval string) error { +func validateArgs(opts Options, commandLen int) error { // file checks - if len(files) <= 0 { + if len(opts.Files) <= 0 { fmt.Println("listen: at least one file (-f) is required") return flags.ErrCommandRequired } - for _, file := range files { + for _, file := range opts.Files { info, err := os.Stat(file) if err != nil { fmt.Printf("listen: %s\n", err) @@ -51,15 +52,64 @@ func validateArgs(files []string, interval string) error { } // interval checks - if _, err := time.ParseDuration(interval); err != nil { + if _, err := time.ParseDuration(opts.Interval); err != nil { fmt.Printf("listen: %s\n", err) return err } + // run first check + if commandLen < 1 && opts.RunFirst { + fmt.Println("listen: -r cannot be specified without a command") + return flags.ErrInvalidChoice + } + return nil } +func setup(opts Options, command []string) { + var filesMap map[string]bool = make(map[string]bool) + var cksumMap map[string][]byte = make(map[string][]byte) + var intervalMap map[string]time.Time = make(map[string]time.Time) + intervalDuration, _ := time.ParseDuration(opts.Interval) + success := true + for _, file := range opts.Files { + hasher := sha256.New() + f, err := os.Open(file) + if err != nil { + success = false + break + } + defer f.Close() + + s, err := os.Stat(file) + if err != nil { + success = false + break + } + + if _, err := io.Copy(hasher, f); err != nil { + log.Fatal(err) + } + + abs, err := filepath.Abs(file) + if err == nil { + filesMap[abs] = false + cksumMap[abs] = hasher.Sum(nil) + intervalMap[abs] = s.ModTime() + } else { + success = false + break + } + } + + if success { + l := Listen{filesMap, cksumMap, intervalMap, opts.Condition, opts.Checksum, intervalDuration, command, opts.Quiet, opts.RunFirst} + l.Run() + } +} + func main() { + opts := Options{} parser := flags.NewParser(&opts, flags.Default) parser.Usage = "[OPTIONS] -- [COMMAND]" remaining, err := parser.Parse() @@ -67,46 +117,8 @@ func main() { if err == nil { if opts.Version { fmt.Printf("listen %s\n", VERSION) - } else if err := validateArgs(opts.Files, opts.Interval); err == nil { - var filesMap map[string]bool = make(map[string]bool) - var cksumMap map[string][]byte = make(map[string][]byte) - var intervalMap map[string]time.Time = make(map[string]time.Time) - intervalDuration, _ := time.ParseDuration(opts.Interval) - success := true - for _, file := range opts.Files { - hasher := sha256.New() - f, err := os.Open(file) - if err != nil { - success = false - break - } - defer f.Close() - - s, err := os.Stat(file) - if err != nil { - success = false - break - } - - if _, err := io.Copy(hasher, f); err != nil { - log.Fatal(err) - } - - abs, err := filepath.Abs(file) - if err == nil { - filesMap[abs] = false - cksumMap[abs] = hasher.Sum(nil) - intervalMap[abs] = s.ModTime() - } else { - success = false - break - } - } - - if success { - l := Listen{filesMap, cksumMap, intervalMap, opts.Condition, opts.Checksum, intervalDuration, remaining, opts.Quiet} - l.Run() - } + } else if err := validateArgs(opts, len(remaining)); err == nil { + setup(opts, remaining) } } }