From 80a1c240a544b30b5948d8d5b2e3260f299e61be Mon Sep 17 00:00:00 2001 From: Zoe <62722391+juls0730@users.noreply.github.com> Date: Thu, 22 May 2025 15:40:05 +0000 Subject: [PATCH] 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. --- README.md | 38 ++++++++++++-- main.go | 124 +++++++++++++++++++++++++++++++++++++++------- zqdgr.config.json | 10 +++- 3 files changed, 149 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 4b04a97..2a1e4cc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,9 @@ # 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 @@ -44,7 +47,8 @@ zqdgr watch dev ``` ### 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 let host = window.location.hostname; 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 -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: - The project must be a zqdgr project - 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 - main.go diff --git a/main.go b/main.go index de9132d..a0b1cd5 100644 --- a/main.go +++ b/main.go @@ -11,8 +11,11 @@ import ( "os" "os/exec" "os/signal" + "path" "path/filepath" + "regexp" "runtime" + "sort" "strings" "sync" "syscall" @@ -22,6 +25,8 @@ import ( "github.com/gorilla/websocket" ) +var executableName string + //go:embed embed/zqdgr.config.json var zqdgrConfig []byte @@ -36,9 +41,10 @@ type Config struct { Type string `json:"type"` URL string `json:"url"` } `json:"repository"` - Scripts map[string]string `json:"scripts"` - Pattern string `json:"pattern"` - ExcludedDirs []string `json:"excluded_dirs"` + Scripts map[string]string `json:"scripts"` + Pattern string `json:"pattern"` + ExcludedDirs []string `json:"excluded_dirs"` + ShutdownSignal string `json:"shutdown_signal"` } type Script struct { @@ -47,12 +53,64 @@ type Script struct { scriptName string isRestarting bool 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 { if script, ok := config.Scripts[scriptName]; ok { fullCmd := strings.Join(append([]string{script}, args...), " ") + fullCmd = flattenZQDGRScript(fullCmd) + var cmd *exec.Cmd if runtime.GOOS == "windows" { cmd = exec.Command("cmd", "/C", fullCmd) @@ -96,9 +154,23 @@ func (s *Script) Start() error { s.wg.Add(1) err := s.command.Start() + if err != nil { + s.wg.Done() + return err + } 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 { s.wg.Done() } @@ -107,24 +179,25 @@ func (s *Script) Start() error { 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 { s.mutex.Lock() s.isRestarting = true 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) } } @@ -229,6 +302,10 @@ func main() { var command 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:] { if arg == "--" { commandArgs = os.Args[i+2:] @@ -409,7 +486,19 @@ func main() { log.Println("Received signal, exiting...") 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) @@ -550,4 +639,5 @@ func main() { } script.Wait() + os.Exit(script.exitCode) } diff --git a/zqdgr.config.json b/zqdgr.config.json index 857242e..98f113e 100644 --- a/zqdgr.config.json +++ b/zqdgr.config.json @@ -1,6 +1,6 @@ { "name": "zqdgr", - "version": "0.0.2", + "version": "0.0.3", "description": "zqdgr is a quick and dirty Golang runner", "author": "juls0730", "license": "BSL-1.0", @@ -11,7 +11,13 @@ }, "scripts": { "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" } \ No newline at end of file