ZQDGR V0.0.3

This release adds many crucial features to improve development
worksflows. It adds shutdown_signal, an improved script running pipeline
and correct error propogation.
This commit is contained in:
Zoe
2025-05-22 15:40:05 +00:00
parent 89b2e25dbe
commit 80a1c240a5
3 changed files with 149 additions and 23 deletions

View File

@@ -1,6 +1,9 @@
# ZQDGR # ZQDGR
ZQDGR is Zoe's Quick and Dirty Golang Runner. This is a simple tool that lets you run a go project in a similar way to how you would use npm. ZQDGR lets you watch files and rebuild your project as you make changes. ZQDGR also includes an optional websocket server that will notify listeners that a rebuild has occurred, this is very useful for live reloading when doing web development with Go. ZQDGR is Zoe's Quick and Dirty Golang Runner. This is a simple tool that lets you run a go project in a similar way to
how you would use npm. ZQDGR lets you watch files and rebuild your project as you make changes. ZQDGR also includes an
optional websocket server that will notify listeners that a rebuild has occurred, this is very useful for live reloading
when doing web development with Go.
## Install ## Install
@@ -44,7 +47,8 @@ zqdgr watch dev
``` ```
### ZQDGR websocket ### ZQDGR websocket
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 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
```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');
@@ -67,16 +71,42 @@ socket.addEventListener('message', (event) => {
}); });
``` ```
## Configuration
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 |
| --- | --- | --- |
| name | string | The name of the project |
| version | string | The version of the project |
| description | string | The description of the project |
| author | string | The author of the project (probably you) |
| license | string | The license of the project |
| homepage | string | The URL to the homepage of the project |
| repository | object | The repository of the project |
| 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 |
| scripts | object | 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 |
| excluded_dirs | array | The directories 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` |
The only required key is `scripts`, the rest are optional, but we recommend you set the most important ones.
## ZQDGR `new` scripts ## ZQDGR `new` scripts
With ZQDGR, you can easily initialize a new project with `zqdgr new` and optionally, you can specify a git repo to use as the initializer script. An example script is available at [github.com/juls0730/zqdgr-script-example](https://github.com/juls0730/zqdgr-script-example). Since ZQDGR v0.0.2, you can easily initialize a new project with `zqdgr new` and optionally, you can specify a git repo
to use as the initializer script. An example script is available at
[github.com/juls0730/zqdgr-script-example](https://github.com/juls0730/zqdgr-script-example).
Every initialize script is expected to follow a few rules: Every initialize script is expected to follow a few rules:
- The project must be a zqdgr project - The project must be a zqdgr project
- The `build` script must exist and must export a binary named `main` - The `build` script must exist and must export a binary named `main`
ZQDGR passes your init script the directory that is being initialized as the first and only argument and runs the script in the target directory. When your binary is executed, there is a git repository and the project is in the following state: ZQDGR passes your init script the directory that is being initialized as the first and only argument and runs the script
in the target directory. When your binary is executed, there is a git repository and the project is in the following
state:
- go.mod - go.mod
- main.go - main.go

124
main.go
View File

@@ -11,8 +11,11 @@ import (
"os" "os"
"os/exec" "os/exec"
"os/signal" "os/signal"
"path"
"path/filepath" "path/filepath"
"regexp"
"runtime" "runtime"
"sort"
"strings" "strings"
"sync" "sync"
"syscall" "syscall"
@@ -22,6 +25,8 @@ import (
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )
var executableName string
//go:embed embed/zqdgr.config.json //go:embed embed/zqdgr.config.json
var zqdgrConfig []byte var zqdgrConfig []byte
@@ -36,9 +41,10 @@ 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"` ExcludedDirs []string `json:"excluded_dirs"`
ShutdownSignal string `json:"shutdown_signal"`
} }
type Script struct { type Script struct {
@@ -47,12 +53,64 @@ type Script struct {
scriptName string scriptName string
isRestarting bool isRestarting bool
wg sync.WaitGroup wg sync.WaitGroup
// the exit code of the script, only set after the script has exited
exitCode int
}
func flattenZQDGRScript(commandString string) string {
keys := make([]string, 0, len(config.Scripts))
for k := range config.Scripts {
keys = append(keys, k)
}
// Sort the keys in descending order in order to prevent scripts that might be substrings of other scripts to
// evaluate first.
sort.Slice(keys, func(i, j int) bool {
return len(keys[i]) > len(keys[j])
})
// escape scripts to be evaluated via regex
escapedKeys := make([]string, len(keys))
for i, key := range keys {
escapedKeys[i] = regexp.QuoteMeta(key)
}
pattern := `\b(` + executableName + `)\b` + `\s+` + `\b(` + strings.Join(escapedKeys, "|") + `)\b`
re := regexp.MustCompile(pattern)
currentCommand := commandString
for {
previousCommand := currentCommand
currentCommand = re.ReplaceAllStringFunc(currentCommand, func(match string) string {
// match the script name, not the whole `zqdgr script` command
match = strings.Split(match, " ")[1]
if val, ok := config.Scripts[match]; ok {
return val
}
return match
})
// If the current command has not changed, we have completely evaluated the command.
if currentCommand == previousCommand {
break
}
}
if re.MatchString(currentCommand) {
fmt.Println("Error: circular dependency detected in scripts")
os.Exit(1)
}
return currentCommand
} }
func NewCommand(scriptName string, args ...string) *exec.Cmd { func NewCommand(scriptName string, args ...string) *exec.Cmd {
if script, ok := config.Scripts[scriptName]; ok { if script, ok := config.Scripts[scriptName]; ok {
fullCmd := strings.Join(append([]string{script}, args...), " ") fullCmd := strings.Join(append([]string{script}, args...), " ")
fullCmd = flattenZQDGRScript(fullCmd)
var cmd *exec.Cmd var cmd *exec.Cmd
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
cmd = exec.Command("cmd", "/C", fullCmd) cmd = exec.Command("cmd", "/C", fullCmd)
@@ -96,9 +154,23 @@ func (s *Script) Start() error {
s.wg.Add(1) s.wg.Add(1)
err := s.command.Start() err := s.command.Start()
if err != nil {
s.wg.Done()
return err
}
go func() { go func() {
s.command.Wait() err := s.command.Wait()
if err != nil {
if exitError, ok := err.(*exec.ExitError); ok {
s.exitCode = exitError.ExitCode()
} else {
// Other errors (e.g., process not found, permission denied)
log.Printf("Error waiting for script %s: %v", s.scriptName, err)
s.exitCode = 1
}
}
if !s.isRestarting { if !s.isRestarting {
s.wg.Done() s.wg.Done()
} }
@@ -107,24 +179,25 @@ func (s *Script) Start() error {
return err return err
} }
func (s *Script) Stop() error {
s.mutex.Lock()
defer s.mutex.Unlock()
err := syscall.Kill(-s.command.Process.Pid, syscall.SIGKILL)
s.wg.Done()
return err
}
func (s *Script) Restart() error { func (s *Script) Restart() error {
s.mutex.Lock() s.mutex.Lock()
s.isRestarting = true s.isRestarting = true
if s.command.Process != nil { if s.command.Process != nil {
if err := syscall.Kill(-s.command.Process.Pid, syscall.SIGKILL); err != nil { var signal syscall.Signal
switch config.ShutdownSignal {
case "SIGINT":
signal = syscall.SIGINT
case "SIGTERM":
signal = syscall.SIGTERM
case "SIGQUIT":
signal = syscall.SIGQUIT
default:
signal = syscall.SIGKILL
}
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)
} }
} }
@@ -229,6 +302,10 @@ func main() {
var command string var command string
var commandArgs []string var commandArgs []string
// get the name of the executable, and if it's a path then get the base name
// this is mainly for testing
executableName = path.Base(os.Args[0])
for i, arg := range os.Args[1:] { for i, arg := range os.Args[1:] {
if arg == "--" { if arg == "--" {
commandArgs = os.Args[i+2:] commandArgs = os.Args[i+2:]
@@ -409,7 +486,19 @@ func main() {
log.Println("Received signal, exiting...") log.Println("Received signal, exiting...")
if script.command != nil { if script.command != nil {
syscall.Kill(-script.command.Process.Pid, syscall.SIGKILL) var signal syscall.Signal
switch config.ShutdownSignal {
case "SIGINT":
signal = syscall.SIGINT
case "SIGTERM":
signal = syscall.SIGTERM
case "SIGQUIT":
signal = syscall.SIGQUIT
default:
signal = syscall.SIGKILL
}
syscall.Kill(-script.command.Process.Pid, signal)
} }
os.Exit(0) os.Exit(0)
@@ -550,4 +639,5 @@ func main() {
} }
script.Wait() script.Wait()
os.Exit(script.exitCode)
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "zqdgr", "name": "zqdgr",
"version": "0.0.2", "version": "0.0.3",
"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",
@@ -11,7 +11,13 @@
}, },
"scripts": { "scripts": {
"build": "go build -o zqdgr", "build": "go build -o zqdgr",
"dev": "sleep 5; echo 'test' && sleep 2 && echo 'test2'" "dev": "sleep 5; echo 'test' && sleep 2 && echo 'test2'",
"test": "zqdgr test:1 && zqdgr test:2 && zqdgr test:3 && zqdgr test:4",
"test:1": "echo 'a'",
"test:2": "false",
"test:3": "echo 'b'",
"test:4": "zqdgr test:3",
"recursive": "zqdgr recursive"
}, },
"pattern": "**/*.go" "pattern": "**/*.go"
} }