Compare commits

...

17 commits

10 changed files with 233 additions and 118 deletions

View file

@ -11,7 +11,7 @@ A simple watcher program written in Go that runs shell command(s) when the speci
- Great for students who work on a small set of source files for assignments.
- Also great for validating configuration files.
## Other Features
### Other Features
- Choose between listening to kernel events, or wait between checks on an interval.
- Run the command when any or all files specified are modified.
@ -22,6 +22,65 @@ A simple watcher program written in Go that runs shell command(s) when the speci
- Helpful if linters are involved, such as `clang-format`, to avoid multiple runs of a command.
- Free and Open Source software!
## Installation
You can install this package easily using the Go CLI:
```bash
# main repo
go install forge.steck.dev/bryson/listen@latest
# codeberg mirror
go install codeberg.org/brysonsteck/listen@latest
```
## Building
### Using Go
When possible, you should [install Go](https://go.dev/doc/install) on your system and build from source:
```bash
# you can alternatively clone the codeberg mirror: codeberg.org/brysonsteck/listen
git clone forge.steck.dev/bryson/listen && cd listen
go build . -o out/listen
```
### Using Docker
On **Windows and Linux**, you can alternatively build Listen in a Docker container and copy it out:
```bash
# you can alternatively clone the codeberg mirror: codeberg.org/brysonsteck/listen
git clone forge.steck.dev/bryson/listen && cd listen
# replace "linux" with "windows" appropriately
docker build -t listen-build -f docker/build.linux.Dockerfile .
mkdir -p out
docker create --name listen-build-tmp listen-build
# the exe is located at:
# /usr/src/listen/listen - on Linux
# C:\build\listen\listen.exe - on Windows
docker cp listen-build-tmp:/usr/src/listen/listen out/
docker rm listen-build-tmp
```
Listen is not intended for use in a standalone Docker environment (currently) due to it's function. However, you could build Listen in a stage and copy the executable to another stage to run a program inside a container:
```dockerfile
# Example Dockerfile
FROM golang:1-alpine AS build
WORKDIR /usr/src/listen
COPY . .
RUN go build .
FROM python:3 AS main
COPY --from=build /usr/src/listen /usr/local/bin
# expect a file mounted to /usr/src/main.py to listen to
CMD ["listen", "-f", "/usr/src/main.py", "--", "python", "main.py"]
```
## History
Listen was originally a Perl script that came about when I wanted something for my college assignments and also needed to learn Perl for an internship. I wrote it once and haven't touched it or improved upon it since I created it.
@ -42,9 +101,9 @@ the Free Software Foundation, either version 3 of the License, or
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
along with this program. If not, see <https://www.gnu.org/licenses/>.
```

56
Taskfile.yml Normal file
View file

@ -0,0 +1,56 @@
version: '3'
vars:
UNAME:
sh: uname
DOCKER_EXE: /usr/src/listen/out/{{OS}}/listen
tasks:
default:
deps:
- build:go
build:go:
aliases:
- build
cmds:
- "go build -o out/{{OS}}/ ."
preconditions:
- sh: "which go"
msg: Go is not installed. Install Go or build with Docker using the "docker" task
build:docker:
aliases:
- docker
cmds:
- task: build:docker:dockercli
- "docker build -t listen-build -f docker/build.Dockerfile --build-arg GOOS='{{OS}}' --build-arg GOARCH='{{ARCH}}' ."
- "mkdir -p out/{{OS}}"
- "docker create --name listen-build-tmp listen-build"
- cmd: "docker cp listen-build-tmp:{{.DOCKER_EXE}}.exe out/{{OS}}/"
platforms:
- windows
- cmd: "docker cp listen-build-tmp:{{.DOCKER_EXE}} out/{{OS}}/"
platforms:
- darwin
- linux
- "docker rm listen-build-tmp"
preconditions:
- sh: "uname | grep -qe Linux -e MINGW -e Darwin"
msg: "This task cannot be ran on OS: {{OS}}"
- sh: "which docker"
msg: Docker is not installed. Install Docker or build with Go using the "build" task
build:docker:dockercli:
cmds:
- cmd: '"C:\Program Files\Docker\Docker\DockerCli.exe" -SwitchLinuxEngine'
platforms:
- windows
clean:
cmds:
- rm -rf out/
- cmd: docker rm listen-build-tmp
ignore_error: true
- cmd: docker image rm listen-build
ignore_error: true

49
build
View file

@ -1,49 +0,0 @@
#!/bin/sh
# Create all the different builds for listen
# verify we are at root of repository
if ! [ -d .git ]; then
echo build: this script must be run at the root of the repo
exit 1
fi
if uname | grep -qe Linux; then
os=linux
elif uname | grep -qe MINGW; then
os=windows
else
os=other
fi
build_go() {
go build -o out/$(go env GOOS)/listen .
}
build_docker() {
if [ $os = "windows" ]; then
/c/Program\ Files/Docker/Docker/DockerCli.exe -SwitchWindowsEngine
exe=C:/build/listen/listen.exe
else
exe=/usr/src/listen/listen
fi
if docker build -t listen-build -f docker/build.$os.Dockerfile .; then
mkdir -p out/$os
docker create --name listen-build-tmp listen-build
docker cp listen-build-tmp:$exe out/$os
docker rm listen-build-tmp
fi
}
# if an arg is specified, force building with the specified method
[ $1 ] && (build_$1; exit)
# prefer building with local go install if it exists on path
if which go &> /dev/null; then
build_go; exit
# if windows or linux, try building with docker
elif echo $os | grep -qe linux -e windows; then
which docker &> /dev/null && (build_docker; exit)
fi
echo could not find valid build method for OS && exit 2

16
docker/build.Dockerfile Normal file
View file

@ -0,0 +1,16 @@
# This Dockerfile is meant for building listen ONLY
# listen is currently not intended to run in a Docker container
FROM golang:1-alpine AS build
WORKDIR /usr/src/listen
COPY . .
RUN apk upgrade --no-cache
ARG GOOS="linux"
RUN go env -w GOOS=${GOOS}
ARG GOARCH="amd64"
RUN go env -w GOARCH=${GOARCH}
RUN go build -o out/$(go env GOOS)/ .
CMD ["tail", "-f", "/dev/null"]

View file

@ -1,14 +0,0 @@
# This Dockerfile is meant for building listen for Linux ONLY
# listen is currently not intended to run in a Docker container
ARG IMAGE="1-alpine"
FROM golang:${IMAGE} AS build
WORKDIR /usr/src/listen
COPY . .
RUN apk upgrade --no-cache
RUN go build .
CMD ["tail", "-f", "/dev/null"]

View file

@ -1,13 +0,0 @@
# This Dockerfile is meant for building listen for Windows ONLY
# listen is currently not intended to run in a Docker container
ARG IMAGE="1-nanoserver"
FROM golang:${IMAGE} AS build
WORKDIR C:/build/listen
COPY . .
RUN go build .
CMD ["ping.exe", "-t", "localhost"]

2
go.mod
View file

@ -1,6 +1,6 @@
module forge.steck.dev/bryson/listen
go 1.24
go 1.23
require github.com/jessevdk/go-flags v1.6.1

View file

@ -17,12 +17,14 @@ import (
"github.com/fsnotify/fsnotify"
)
var triggered bool = false
type Listen struct {
FileMap map[string]bool
CksumMap map[string][]byte
IntervalMap map[string]time.Time
Condition string
Cksum bool
NoCksum bool
Interval time.Duration
Command []string
Quiet bool
@ -74,6 +76,7 @@ func allCheck(l Listen) bool {
}
func runTrigger(l Listen, f string, r bool, quit chan bool) bool {
triggered = true
if !l.Quiet && !r {
if l.Condition == "any" {
printStatus(fmt.Sprintf("& File %s modified. ", f))
@ -94,12 +97,32 @@ func runTrigger(l Listen, f string, r bool, quit chan bool) bool {
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())
exit := make(chan bool)
s := make(chan os.Signal, 1)
signal.Notify(s, os.Interrupt)
go func() {
if err := cmd.Wait(); err != nil {
if rc, ok := err.(*exec.ExitError); ok {
if !l.Quiet {
switch rc.ExitCode() {
case -1:
color.New(color.FgYellow).Fprintf(os.Stderr, "\n& WARNING: Command interrupted with ^C.\n")
default:
color.New(color.FgYellow).Fprintf(os.Stderr, "& WARNING: Command exited with code %d.\n", rc.ExitCode())
}
}
}
}
exit <- true
}()
select {
case <-s:
cmd.Process.Signal(os.Interrupt)
<-exit
case <-exit:
}
if !l.Quiet {
@ -111,9 +134,11 @@ func runTrigger(l Listen, f string, r bool, quit chan bool) bool {
}
quit <- true
triggered = false
return true
}
triggered = false
return false
}
@ -140,13 +165,7 @@ func loopFsnotify(l Listen, quit chan bool) {
for key := range l.FileMap {
if event.Name == key {
if l.Cksum {
// return value indicates a break is needed
if cksumCheck(l, key, &trigger) {
fileMod = key
break
}
} else {
if l.NoCksum {
if event.Has(fsnotify.Write) {
if l.Condition == "any" {
fileMod = key
@ -169,6 +188,12 @@ func loopFsnotify(l Listen, quit chan bool) {
l.FileMap[key] = true
}
} else {
// return value indicates a break is needed
if cksumCheck(l, key, &trigger) {
fileMod = key
break
}
}
}
} // end for
@ -206,13 +231,7 @@ func loopInterval(l Listen, quit chan bool) {
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 {
if l.NoCksum {
s, err := os.Stat(key)
if err != nil {
log.Fatal()
@ -228,7 +247,12 @@ func loopInterval(l Listen, quit chan bool) {
l.FileMap[key] = true
}
} else {
// return value indicates a break is needed
if cksumCheck(l, key, &trigger) {
fileMod = key
break
}
}
} // end for key
@ -262,7 +286,7 @@ func startMessage(l Listen) {
printStatus("& listen will exit when ")
}
if l.Cksum {
if !l.NoCksum {
printStatus("the checksum of ")
}
@ -278,10 +302,10 @@ func startMessage(l Listen) {
printStatus("this file has ")
}
if l.Cksum {
printStatus("changed:\n")
} else {
if l.NoCksum {
printStatus("been modified:\n")
} else {
printStatus("changed:\n")
}
for key := range l.FileMap {
@ -289,8 +313,9 @@ func startMessage(l Listen) {
}
}
func (l Listen) Run() {
func (l Listen) Run() int {
// catch ^C
ret := 0
quit := make(chan bool)
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt)
@ -317,7 +342,14 @@ func (l Listen) Run() {
select {
case <-c:
fmt.Println()
for {
if !triggered {
fmt.Println()
ret = 130
break
}
<-c
}
case <-quit:
}
@ -325,4 +357,5 @@ func (l Listen) Run() {
printStatus("& Exiting...\n")
}
return ret
}

42
main.go
View file

@ -13,16 +13,16 @@ import (
flags "github.com/jessevdk/go-flags"
)
const VERSION string = "v0.3.0"
const VERSION string = "v0.4.1"
type Options struct {
Version bool `short:"v" long:"version" description:"Displays version info and exits"`
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"`
Version bool `short:"v" long:"version" description:"Displays version info and exits"`
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"`
NoChecksum bool `short:"x" long:"no-checksum" description:"Do not calculate checksum as an additional check for file changes (see MODES section in man page for more info)"`
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(opts Options, commandLen int) error {
@ -66,7 +66,7 @@ func validateArgs(opts Options, commandLen int) error {
return nil
}
func setup(opts Options, command []string) {
func setup(opts Options, command []string) int {
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)
@ -103,22 +103,40 @@ func setup(opts Options, command []string) {
}
if success {
l := Listen{filesMap, cksumMap, intervalMap, opts.Condition, opts.Checksum, intervalDuration, command, opts.Quiet, opts.RunFirst}
l.Run()
l := Listen{filesMap, cksumMap, intervalMap, opts.Condition, opts.NoChecksum, intervalDuration, command, opts.Quiet, opts.RunFirst}
return l.Run()
}
return -1
}
func main() {
ret := 0
opts := Options{}
parser := flags.NewParser(&opts, flags.Default)
parser.Usage = "[OPTIONS] -- [COMMAND]"
remaining, err := parser.Parse()
defer func() {
if err := recover(); err != nil {
switch ret {
case -1:
os.Exit(5)
default:
os.Exit(ret)
}
}
}()
if err == nil {
if opts.Version {
fmt.Printf("listen %s\n", VERSION)
} else if err := validateArgs(opts, len(remaining)); err == nil {
setup(opts, remaining)
ret = setup(opts, remaining)
if ret != 0 {
panic(ret)
}
}
}
}

9
man/listen.1.md Normal file
View file

@ -0,0 +1,9 @@
% listen | General Commands Manual
# NAME
listen - A simple watcher program that runs commands when specified files are modified
# SYNOPSIS
listen [OPTIONS] -- [COMMAND]