ZQDGR v0.0.4: Qualirt of Life Update

This update brings several quality of life enhancements:
- You can now use excluded_files to exclude files from the watcher
- ZQDGR reloads itself when its config changes
- ZQDGR will not start if there are unrecognized keys in the config

It also fixes a bug where if a program was sent a kill signal, but didnt
die, ZQDGR would carry on as if it did, which lead to many issues.
This commit is contained in:
Zoe
2025-10-03 01:36:18 -05:00
parent adac21ce29
commit 50a727863b
4 changed files with 203 additions and 41 deletions

View File

@@ -14,11 +14,13 @@ go install github.com/juls0730/zqdgr@latest
## Usage ## Usage
Full usage Full usage
```Bash ```Bash
zqdgr [options] <command> zqdgr [options] <command>
``` ```
The list of commands is ### Commands
- `init` - `init`
generates a zqdgr.config.json file in the current directory generates a zqdgr.config.json file in the current directory
@@ -30,25 +32,43 @@ The list of commands is
- `watch <script>` - `watch <script>`
runs the script in "watch mode", when files that follow the pattern in zqdgr.config.json change, the script restarts runs the script in "watch mode", when files that follow the pattern in zqdgr.config.json change, the script restarts
- `<script>` - `<script>`
runs the script runs the script
### Options
ZQDGR has the following list of options
- `-no-ws` - `-no-ws`
disables the web socket server running at 2067 disables the web socket server running at 2067
- `-config`
specifies the path to the config file
- `-disable-reload-config`
disables reloading the config file when it changes
- `-C`
specifies the path to use as the working directry
Example usage: Example usage:
```bash ```bash
zqdgr init zqdgr init
zqdgr watch dev zqdgr watch dev
zqdgr -config ./my-config.json watch dev
zqdgr -C ./my-nested-project watch dev
``` ```
### ZQDGR websocket ### ZQDGR websocket
ZQDGR comes with a websocket to notify listeners that the application has updates, the websocket is accessible at ZQDGR comes with a websocket to notify listeners that the application has updates, the websocket is accessible at
`127.0.0.1:2067/ws`. An example dev script to listen for rebuilds might look like this `127.0.0.1:2067/ws`. An example dev script to listen for rebuilds might look like this
```Javascript ```Javascript
let host = window.location.hostname; let host = window.location.hostname;
const socket = new WebSocket('ws://' + host + ':2067/ws'); const socket = new WebSocket('ws://' + host + ':2067/ws');
@@ -75,21 +95,22 @@ socket.addEventListener('message', (event) => {
ZQDGR is solely configured by a `zqdgr.config.json` file in the root of your project. The file has the following keys: ZQDGR is solely configured by a `zqdgr.config.json` file in the root of your project. The file has the following keys:
| Key | Type | Description | | Key | Type | Required | default | Description |
| --- | --- | --- | | ---------------- | ------ | -------- | ------- | -------------------------------------------------------------------------------------------------------- |
| name | string | The name of the project | | name | string | - | - | The name of the project |
| version | string | The version of the project | | version | string | - | - | The version of the project |
| description | string | The description of the project | | description | string | - | - | The description of the project |
| author | string | The author of the project (probably you) | | author | string | - | - | The author of the project (probably you) |
| license | string | The license of the project | | license | string | - | - | The license of the project |
| homepage | string | The URL to the homepage of the project | | homepage | string | - | - | The URL to the homepage of the project |
| repository | object | The repository of the project | | repository | object | - | - | The repository of the project |
| repository.type | string | The type of VCS that you use, most likely git | | repository.type | string | - | - | The type of VCS that you use, most likely git |
| repository.url | string | The place where you code is hosted. This should be a URI that can be used as is in a program such as git | | repository.url | string | - | - | The place where you code is hosted. This should be a URI that can be used as is in a program such as git |
| scripts | object | An object that maps a script name to a script command, which is just a shell command | | scripts | object | true | - | An object that maps a script name to a script command, which is just a shell command |
| pattern | string | The GLOB pattern that ZQDGR will watch for changes | | pattern | string | true | - | The GLOB pattern that ZQDGR will watch for changes |
| excluded_dirs | array | The directories that ZQDGR will ignore when in the `watch` mode | | excluded_globs | array | - | - | Globs that ZQDGR will ignore when in the `watch` mode |
| shutdown_signal | string | The signal that ZQDGR will use to shutdown the script. Currently the only supported signals are `SIGINT`, `SIGTERM`, and `SIGQUIT`, if no shutdown signal is specified, ZQDGR will default to `SIGKILL` | | shutdown_signal | string | - | SIGINT | The signal that ZQDGR will use to shutdown the script. One of SIGKILL, SIGINT, SIGQUIT, SIGTERM |
| shutdown_timeout | int | - | 1 | The amount of time in seconds that ZQDGR will wait for the script to exit before force killing it. |
The only required key is `scripts`, the rest are optional, but we recommend you set the most important ones. The only required key is `scripts`, the rest are optional, but we recommend you set the most important ones.

123
main.go
View File

@@ -2,6 +2,7 @@ package main
import ( import (
"bufio" "bufio"
"bytes"
_ "embed" _ "embed"
"encoding/json" "encoding/json"
"flag" "flag"
@@ -37,10 +38,13 @@ type Config struct {
Type string `json:"type"` Type string `json:"type"`
URL string `json:"url"` URL string `json:"url"`
} `json:"repository"` } `json:"repository"`
Scripts map[string]string `json:"scripts"` Scripts map[string]string `json:"scripts"`
Pattern string `json:"pattern"` Pattern string `json:"pattern"`
ExcludedDirs []string `json:"excluded_dirs"` // Deprecated: use excludedGlobs instead
ShutdownSignal string `json:"shutdown_signal"` ExcludedDirs []string `json:"excluded_dirs"`
ExcludedGlobs []string `json:"excluded_files"`
ShutdownSignal string `json:"shutdown_signal"`
ShutdownTimeout int `json:"shutdown_timeout"`
} }
type Script struct { type Script struct {
@@ -72,7 +76,11 @@ func NewZQDGR(enableWebSocket bool, configDir string) *ZQDGR {
WorkingDirectory: configDir, WorkingDirectory: configDir,
} }
zqdgr.loadConfig() err := zqdgr.loadConfig()
if err != nil {
log.Fatal(err)
return nil
}
zqdgr.EnableWebSocket = enableWebSocket zqdgr.EnableWebSocket = enableWebSocket
zqdgr.WSServer = &WSServer{ zqdgr.WSServer = &WSServer{
@@ -150,8 +158,10 @@ func (s *Script) Start() error {
s.exitCode = exitError.ExitCode() s.exitCode = exitError.ExitCode()
} else { } else {
// Other errors (e.g., process not found, permission denied) // Other errors (e.g., process not found, permission denied)
log.Printf("Error waiting for script %s: %v", s.scriptName, err) if !s.isRestarting {
s.exitCode = 1 log.Printf("Error waiting for script %s: %v", s.scriptName, err)
s.exitCode = 1
}
} }
} }
@@ -163,8 +173,11 @@ func (s *Script) Start() error {
return err return err
} }
func (s *Script) Restart() error { func (s *Script) Stop(lock bool) error {
s.mutex.Lock() if lock {
s.mutex.Lock()
defer s.mutex.Unlock()
}
s.isRestarting = true s.isRestarting = true
@@ -183,9 +196,45 @@ func (s *Script) Restart() error {
if err := syscall.Kill(-s.command.Process.Pid, signal); err != nil { if err := syscall.Kill(-s.command.Process.Pid, signal); err != nil {
log.Printf("error killing previous process: %v", err) log.Printf("error killing previous process: %v", err)
return err
} }
} }
dead := make(chan bool)
go func() {
s.command.Wait()
dead <- true
}()
shutdownTimeout := s.zqdgr.Config.ShutdownTimeout
if shutdownTimeout == 0 {
shutdownTimeout = 1
}
select {
case <-dead:
case <-time.After(time.Duration(shutdownTimeout) * time.Second):
log.Println("Script failed to stop after kill signal, force killing")
if err := syscall.Kill(-s.command.Process.Pid, syscall.SIGKILL); err != nil {
log.Printf("error killing previous process: %v", err)
return err
}
}
return nil
}
func (s *Script) Restart() error {
s.mutex.Lock()
defer s.mutex.Unlock()
err := s.Stop(false)
if err != nil {
s.mutex.Unlock()
return err
}
s.command = s.zqdgr.NewCommand(s.scriptName) s.command = s.zqdgr.NewCommand(s.scriptName)
if s.command == nil { if s.command == nil {
@@ -198,7 +247,7 @@ func (s *Script) Restart() error {
s.mutex.Unlock() s.mutex.Unlock()
err := s.Start() err = s.Start()
// tell the websocket clients to refresh // tell the websocket clients to refresh
if s.zqdgr.EnableWebSocket { if s.zqdgr.EnableWebSocket {
@@ -246,7 +295,10 @@ func (wsServer *WSServer) handleWs(w http.ResponseWriter, r *http.Request) {
func (zqdgr *ZQDGR) loadConfig() error { func (zqdgr *ZQDGR) loadConfig() error {
data, err := os.ReadFile(path.Join(zqdgr.WorkingDirectory, "zqdgr.config.json")) data, err := os.ReadFile(path.Join(zqdgr.WorkingDirectory, "zqdgr.config.json"))
if err == nil { if err == nil {
if err := json.Unmarshal(data, &zqdgr.Config); err != nil { decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
err := decoder.Decode(&zqdgr.Config)
if err != nil {
return fmt.Errorf("error parsing config file: %v", err) return fmt.Errorf("error parsing config file: %v", err)
} }
} else { } else {
@@ -259,19 +311,30 @@ func (zqdgr *ZQDGR) loadConfig() error {
} }
} }
if zqdgr.Config.ExcludedDirs != nil {
fmt.Printf("WARNING: the 'excluded_dirs' key is deprecated, please use 'excluded_globs' instead\n")
zqdgr.Config.ExcludedGlobs = append(zqdgr.Config.ExcludedGlobs, zqdgr.Config.ExcludedDirs...)
}
return nil return nil
} }
func main() { func main() {
noWs := flag.Bool("no-ws", false, "Disable WebSocket server") noWs := flag.Bool("no-ws", false, "Disable WebSocket server")
configDir := flag.String("config", ".", "Path to the config directory") configDir := flag.String("config", ".", "Path to the config directory")
disableReloadConfig := flag.Bool("no-reload-config", false, "Do not restart ZQDGR on config file change")
flag.StringVar(configDir, "C", *configDir, "Path to the config directory") flag.StringVar(configDir, "C", *configDir, "Path to the config directory")
flag.Parse() flag.Parse()
originalArgs := os.Args
os.Args = flag.Args() os.Args = flag.Args()
zqdgr := NewZQDGR(*noWs, *configDir) zqdgr := NewZQDGR(*noWs, *configDir)
if zqdgr == nil {
return
}
var command string var command string
var commandArgs []string var commandArgs []string
@@ -493,6 +556,7 @@ func main() {
log.Fatal("watch pattern not specified in config") log.Fatal("watch pattern not specified in config")
} }
// make sure the pattern is valid
var paternArray []string var paternArray []string
var currentPattern string var currentPattern string
inMatch := false inMatch := false
@@ -533,8 +597,8 @@ func main() {
} }
watcherConfig := WatcherConfig{ watcherConfig := WatcherConfig{
excludedDirs: globList(zqdgr.Config.ExcludedDirs), excludedGlobs: globList(zqdgr.Config.ExcludedGlobs),
pattern: paternArray, pattern: paternArray,
} }
watcher, err := NewWatcher(&watcherConfig) watcher, err := NewWatcher(&watcherConfig)
@@ -549,6 +613,11 @@ func main() {
log.Fatal(err) log.Fatal(err)
} }
err = watcher.AddFile(path.Join(zqdgr.WorkingDirectory, "zqdgr.config.json"))
if err != nil {
log.Fatal(err)
}
// We use this timer to deduplicate events. // We use this timer to deduplicate events.
var ( var (
// Wait 100ms for new events; each new event resets the timer. // Wait 100ms for new events; each new event resets the timer.
@@ -573,7 +642,33 @@ func main() {
if !ok { if !ok {
timer = time.AfterFunc(waitFor, func() { timer = time.AfterFunc(waitFor, func() {
if event.Op&fsnotify.Remove == fsnotify.Remove || event.Op&fsnotify.Write == fsnotify.Write || event.Op&fsnotify.Create == fsnotify.Create { if event.Op&fsnotify.Remove == fsnotify.Remove || event.Op&fsnotify.Write == fsnotify.Write || event.Op&fsnotify.Create == fsnotify.Create {
fmt.Println("File changed:", event.Name) if os.Getenv("ZQDGR_DEBUG") != "" {
fmt.Println("File changed:", event.Name)
}
if strings.HasSuffix(event.Name, "zqdgr.config.json") {
// re-exec the exact same command
if !*disableReloadConfig {
log.Println("zqdgr.config.json has changed, restarting...")
executable, err := os.Executable()
if err != nil {
log.Fatal(err)
}
err = script.Stop(true)
if err != nil {
log.Fatal(err)
}
err = syscall.Exec(executable, originalArgs, os.Environ())
if err != nil {
log.Fatal(err)
}
panic("unreachable")
}
}
if directoryShouldBeTracked(&watcherConfig, event.Name) { if directoryShouldBeTracked(&watcherConfig, event.Name) {
watcher.(NotifyWatcher).watcher.Add(event.Name) watcher.(NotifyWatcher).watcher.Add(event.Name)
} }

View File

@@ -4,7 +4,9 @@ import (
"errors" "errors"
"fmt" "fmt"
"log" "log"
"os"
"path/filepath" "path/filepath"
"strings"
"github.com/bmatcuk/doublestar" "github.com/bmatcuk/doublestar"
"github.com/fsnotify/fsnotify" "github.com/fsnotify/fsnotify"
@@ -14,12 +16,19 @@ type globList []string
func (g *globList) Matches(value string) bool { func (g *globList) Matches(value string) bool {
for _, v := range *g { for _, v := range *g {
// if the pattern matches a filepath pattern
if match, err := filepath.Match(v, value); err != nil { if match, err := filepath.Match(v, value); err != nil {
log.Fatalf("Bad pattern \"%s\": %s", v, err.Error()) log.Fatalf("Bad pattern \"%s\": %s", v, err.Error())
} else if match { } else if match {
return true return true
} }
// or if the path starts with the pattern
if strings.HasSuffix(value, v) {
return true
}
} }
return false return false
} }
@@ -29,12 +38,25 @@ func matchesPattern(pattern []string, path string) bool {
return true return true
} }
} }
return false return false
} }
func directoryShouldBeTracked(cfg *WatcherConfig, path string) bool { func directoryShouldBeTracked(cfg *WatcherConfig, path string) bool {
base := filepath.Dir(path) base := filepath.Dir(path)
return matchesPattern(cfg.pattern, path) && !cfg.excludedDirs.Matches(base)
if os.Getenv("ZQDGR_DEBUG") != "" {
log.Printf("checking %s against %s %v\n", path, base, *cfg)
}
if cfg.excludedGlobs.Matches(base) {
if os.Getenv("ZQDGR_DEBUG") != "" {
log.Printf("%s is excluded\n", base)
}
return false
}
return matchesPattern(cfg.pattern, path)
} }
func pathMatches(cfg *WatcherConfig, path string) bool { func pathMatches(cfg *WatcherConfig, path string) bool {
@@ -42,13 +64,14 @@ func pathMatches(cfg *WatcherConfig, path string) bool {
} }
type WatcherConfig struct { type WatcherConfig struct {
excludedDirs globList excludedGlobs globList
pattern globList pattern globList
} }
type FileWatcher interface { type FileWatcher interface {
Close() error Close() error
AddFiles() error AddFiles() error
AddFile(path string) error
add(path string) error add(path string) error
getConfig() *WatcherConfig getConfig() *WatcherConfig
} }
@@ -62,6 +85,14 @@ func (n NotifyWatcher) Close() error {
return n.watcher.Close() return n.watcher.Close()
} }
func (n NotifyWatcher) AddFile(path string) error {
if os.Getenv("ZQDGR_DEBUG") != "" {
log.Printf("manually adding file\n")
}
return n.add(path)
}
func (n NotifyWatcher) AddFiles() error { func (n NotifyWatcher) AddFiles() error {
return addFiles(n) return addFiles(n)
} }
@@ -79,10 +110,12 @@ func NewWatcher(cfg *WatcherConfig) (FileWatcher, error) {
err := errors.New("no config specified") err := errors.New("no config specified")
return nil, err return nil, err
} }
w, err := fsnotify.NewWatcher() w, err := fsnotify.NewWatcher()
if err != nil { if err != nil {
return nil, err return nil, err
} }
return NotifyWatcher{ return NotifyWatcher{
watcher: w, watcher: w,
cfg: cfg, cfg: cfg,
@@ -92,18 +125,31 @@ func NewWatcher(cfg *WatcherConfig) (FileWatcher, error) {
func addFiles(fw FileWatcher) error { func addFiles(fw FileWatcher) error {
cfg := fw.getConfig() cfg := fw.getConfig()
for _, pattern := range cfg.pattern { for _, pattern := range cfg.pattern {
if os.Getenv("ZQDGR_DEBUG") != "" {
fmt.Printf("processing glob %s\n", pattern)
}
matches, err := doublestar.Glob(pattern) matches, err := doublestar.Glob(pattern)
if err != nil { if err != nil {
log.Fatalf("Bad pattern \"%s\": %s", pattern, err.Error()) log.Fatalf("Bad pattern \"%s\": %s", pattern, err.Error())
} }
for _, match := range matches { for _, match := range matches {
if os.Getenv("ZQDGR_DEBUG") != "" {
log.Printf("checking %s\n", match)
}
if directoryShouldBeTracked(cfg, match) { if directoryShouldBeTracked(cfg, match) {
if os.Getenv("ZQDGR_DEBUG") != "" {
log.Printf("%s is not excluded\n", match)
}
if err := fw.add(match); err != nil { if err := fw.add(match); err != nil {
return fmt.Errorf("FileWatcher.Add(): %v", err) return fmt.Errorf("FileWatcher.Add(): %v", err)
} }
} }
} }
} }
return nil return nil
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "zqdgr", "name": "zqdgr",
"version": "0.0.3", "version": "0.0.4",
"description": "zqdgr is a quick and dirty Golang runner", "description": "zqdgr is a quick and dirty Golang runner",
"author": "juls0730", "author": "juls0730",
"license": "BSL-1.0", "license": "BSL-1.0",