status messages, run first flag

This commit is contained in:
Bryson Steck 2025-06-29 12:58:37 -06:00
parent 1d3f8e48ae
commit a4cdfc257d
Signed by: bryson
SSH key fingerprint: SHA256:XpKABw/nP4z8UVaH+weLaBnEOD86+cVwif+QjuYLGT4
4 changed files with 200 additions and 65 deletions

11
go.mod
View file

@ -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
)

13
go.sum
View file

@ -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=

135
listen.go
View file

@ -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...")
}
}

106
main.go
View file

@ -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)
}
}
}