From 290eafb3d0c8b22a058c1ac2b132a51f5dfb3d10 Mon Sep 17 00:00:00 2001 From: juls0730 <62722391+juls0730@users.noreply.github.com> Date: Sat, 16 Nov 2024 04:17:27 -0600 Subject: [PATCH] initial commit --- .gitignore | 1 + LICENSE | 23 +++ README.md | 48 +++++ embed/zqdgr.config.json | 13 ++ folder/test.go | 1 + go.mod | 12 ++ go.sum | 8 + main.go | 397 ++++++++++++++++++++++++++++++++++++++++ watcher.go | 140 ++++++++++++++ zqdgr.config.json | 20 ++ 10 files changed, 663 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 embed/zqdgr.config.json create mode 100644 folder/test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 watcher.go create mode 100644 zqdgr.config.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1f4b980 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +zqdgr \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..36b7cd9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,23 @@ +Boost Software License - Version 1.0 - August 17th, 2003 + +Permission is hereby granted, free of charge, to any person or organization +obtaining a copy of the software and accompanying documentation covered by +this license (the "Software") to use, reproduce, display, distribute, +execute, and transmit the Software, and to prepare derivative works of the +Software, and to permit third-parties to whom the Software is furnished to +do so, all subject to the following: + +The copyright notices in the Software and this entire statement, including +the above license grant, this restriction and the following disclaimer, +must be included in all copies of the Software, in whole or in part, and +all derivative works of the Software, unless such copies or derivative +works are solely in the form of machine-executable object code generated by +a source language processor. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT +SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE +FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9e247a0 --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# ZQDGR + +ZQDGR is the Zero need 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 include an optional websocket server that will notify listeners that a rebuild has occured, this is very useful for live reloading when doing web development with Go. + +## Install + +```bash +go install github.com/juls0730/zqdgr +``` + +## Usage + +```bash +zqdgr init +zqdgr watch dev +``` + +## Attribution + +This project uses work from the following projects: + +- [CompileDaemon](https://github.com/githubnemo/CompileDaemon) + + ``` + Copyright (c) 2013, Marian Tietz + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ``` diff --git a/embed/zqdgr.config.json b/embed/zqdgr.config.json new file mode 100644 index 0000000..9d8b103 --- /dev/null +++ b/embed/zqdgr.config.json @@ -0,0 +1,13 @@ +{ + "name": "Go Project", + "version": "0.0.1", + "description": "Example description", + "author": "you", + "license": "BSL-1.0", + "scripts": { + "build": "go build", + "dev": "go run main.go" + }, + "pattern": "**/*.go", + "excluded_dirs": [] +} \ No newline at end of file diff --git a/folder/test.go b/folder/test.go new file mode 100644 index 0000000..3b7dc11 --- /dev/null +++ b/folder/test.go @@ -0,0 +1 @@ +package folder diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1afa758 --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module github.com/juls0730/zqdgr + +go 1.23.2 + +require github.com/fsnotify/fsnotify v1.8.0 + +require github.com/gorilla/websocket v1.5.3 + +require ( + github.com/bmatcuk/doublestar v1.3.4 + golang.org/x/sys v0.13.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..dec0f64 --- /dev/null +++ b/go.sum @@ -0,0 +1,8 @@ +github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQrS0= +github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/main.go b/main.go new file mode 100644 index 0000000..8c57250 --- /dev/null +++ b/main.go @@ -0,0 +1,397 @@ +package main + +import ( + _ "embed" + "encoding/json" + "flag" + "fmt" + "log" + "net/http" + "os" + "os/exec" + "os/signal" + "runtime" + "sync" + "syscall" + "time" + + "github.com/fsnotify/fsnotify" + "github.com/gorilla/websocket" +) + +//go:embed embed/zqdgr.config.json +var zqdgrConfig []byte + +type Config struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + Author string `json:"author"` + License string `json:"license"` + Homepage string `json:"homepage"` + Repository 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"` +} + +type Script struct { + command *exec.Cmd + mutex sync.Mutex + scriptName string + isRestarting bool + wg sync.WaitGroup +} + +func NewCommand(scriptName string) *exec.Cmd { + if script, ok := config.Scripts[scriptName]; ok { + var cmd *exec.Cmd + if runtime.GOOS == "windows" { + cmd = exec.Command("cmd", "/C", script) + } else { + cmd = exec.Command("sh", "-c", script) + } + + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, + } + + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + + return cmd + } else { + return nil + } +} + +func NewScript(scriptName string) *Script { + command := NewCommand(scriptName) + + if command == nil { + log.Fatal("script not found") + return nil + } + + return &Script{ + command: command, + scriptName: scriptName, + isRestarting: false, + } +} + +func (s *Script) Start() error { + s.mutex.Lock() + defer s.mutex.Unlock() + + s.wg.Add(1) + + err := s.command.Start() + + go func() { + s.command.Wait() + if !s.isRestarting { + s.wg.Done() + } + }() + + 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 { + println("Restarting script") + + s.mutex.Lock() + + s.isRestarting = true + + if s.command.Process != nil { + if err := syscall.Kill(-s.command.Process.Pid, syscall.SIGKILL); err != nil { + log.Printf("error killing previous process: %v", err) + } + } + + s.command = NewCommand(s.scriptName) + + if s.command == nil { + // this should never happen + log.Fatal("script not found") + return nil + } + + s.isRestarting = false + + s.mutex.Unlock() + + err := s.Start() + + // tell the websocket clients to refresh + if enableWebSocket { + clientsMux.Lock() + for client := range clients { + err := client.WriteMessage(websocket.TextMessage, []byte("refresh")) + if err != nil { + log.Printf("error broadcasting refresh: %v", err) + client.Close() + delete(clients, client) + } + } + clientsMux.Unlock() + } + + return err +} + +func (s *Script) Wait() { + s.wg.Wait() +} + +func handleWs(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("error upgrading connection: %v", err) + return + } + + clientsMux.Lock() + clients[conn] = true + clientsMux.Unlock() + + for { + _, _, err := conn.ReadMessage() + if err != nil { + clientsMux.Lock() + delete(clients, conn) + clientsMux.Unlock() + break + } + } +} + +var ( + enableWebSocket = false + config Config + script *Script + upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true + }, + } + clients = make(map[*websocket.Conn]bool) + clientsMux sync.Mutex +) + +func loadConfig() error { + data, err := os.ReadFile("zqdgr.config.json") + if err == nil { + if err := json.Unmarshal(data, &config); err != nil { + return fmt.Errorf("error parsing config file: %v", err) + } + } else { + config = Config{ + Scripts: map[string]string{ + "build": "go build", + "run": "go run main.go", + }, + Pattern: "**/*.go", + } + } + + return nil +} + +func main() { + noWs := flag.Bool("no-ws", false, "Disable WebSocket server") + flag.Parse() + + if err := loadConfig(); err != nil { + log.Fatal(err) + } + + command := os.Args[1] + watchMode := false + var scriptName string + switch command { + case "init": + config, err := os.Create("zqdgr.config.json") + if err != nil { + log.Fatal(err) + } + + _, err = config.Write(zqdgrConfig) + if err != nil { + log.Fatal(err) + } + + fmt.Println("zqdgr.config.json created successfully") + return + case "watch": + if len(os.Args) < 3 { + log.Fatal("please specify a script to run") + } + watchMode = true + scriptName = os.Args[2] + default: + scriptName = command + } + + script = NewScript(scriptName) + + if err := script.Start(); err != nil { + log.Fatal(err) + } + + go func() { + processSignalChannel := make(chan os.Signal, 1) + signal.Notify(processSignalChannel, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT) + <-processSignalChannel + + log.Println("Received signal, exiting...") + if script.command != nil { + syscall.Kill(-script.command.Process.Pid, syscall.SIGKILL) + } + + os.Exit(0) + }() + + if watchMode { + if !*noWs { + enableWebSocket = true + + go func() { + http.HandleFunc("/ws", handleWs) + log.Printf("WebSocket server running on :2067") + if err := http.ListenAndServe(":2067", nil); err != nil { + log.Printf("WebSocket server error: %v", err) + } + }() + } + + if config.Pattern == "" { + log.Fatal("watch pattern not specified in config") + } + + var paternArray []string + var currentPattern string + inMatch := false + // iterate over every letter in the pattern + for _, p := range config.Pattern { + if string(p) == "{" { + inMatch = true + } + + if string(p) == "}" { + inMatch = false + } + + if string(p) == "," && !inMatch { + paternArray = append(paternArray, currentPattern) + currentPattern = "" + inMatch = false + continue + } + + currentPattern += string(p) + } + + if currentPattern != "" { + paternArray = append(paternArray, currentPattern) + } + + watcherConfig := WatcherConfig{ + excludedDirs: globList(config.ExcludedDirs), + pattern: paternArray, + } + + watcher, err := NewWatcher(&watcherConfig) + if err != nil { + log.Fatal(err) + } + + defer watcher.Close() + + err = watcher.AddFiles() + if err != nil { + log.Fatal(err) + } + + // We use this timer to deduplicate events. + var ( + // Wait 100ms for new events; each new event resets the timer. + waitFor = 100 * time.Millisecond + + // Keep track of the timers, as path → timer. + mu sync.Mutex + timers = make(map[string]*time.Timer) + ) + go func() { + for { + select { + case event, ok := <-watcher.(NotifyWatcher).watcher.Events: + if !ok { + return + } + + mu.Lock() + timer, ok := timers[event.Name] + mu.Unlock() + + if !ok { + timer = time.AfterFunc(waitFor, func() { + 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 directoryShouldBeTracked(&watcherConfig, event.Name) { + watcher.(NotifyWatcher).watcher.Add(event.Name) + } + + if pathMatches(&watcherConfig, event.Name) { + script.Restart() + } + + } + }) + timer.Stop() + + mu.Lock() + timers[event.Name] = timer + mu.Unlock() + } + + timer.Reset(waitFor) + case err := <-watcher.(NotifyWatcher).watcher.Errors: + if err == nil { + continue + } + + if v, ok := err.(*os.SyscallError); ok { + if v.Err == syscall.EINTR { + continue + } + log.Fatal("watcher.Error: SyscallError:", v) + } + log.Fatal("watcher.Error:", err) + + } + } + }() + } + + script.Wait() + + log.Println("Script finished") +} diff --git a/watcher.go b/watcher.go new file mode 100644 index 0000000..6f85c62 --- /dev/null +++ b/watcher.go @@ -0,0 +1,140 @@ +// There is some dead code in here because I pulled out the parts I needed to get it working, deal with this later lol. + +package main + +import ( + "errors" + "fmt" + "log" + "os" + "path/filepath" + "syscall" + + "github.com/bmatcuk/doublestar" + "github.com/fsnotify/fsnotify" +) + +type globList []string + +func (g *globList) Matches(value string) bool { + for _, v := range *g { + if match, err := filepath.Match(v, value); err != nil { + log.Fatalf("Bad pattern \"%s\": %s", v, err.Error()) + } else if match { + return true + } + } + return false +} + +func matchesPattern(pattern []string, path string) bool { + for _, p := range pattern { + if matched, _ := doublestar.Match(p, path); matched { + return true + } + } + return false +} + +func directoryShouldBeTracked(cfg *WatcherConfig, path string) bool { + base := filepath.Dir(path) + return matchesPattern(cfg.pattern, path) && !cfg.excludedDirs.Matches(base) +} + +func pathMatches(cfg *WatcherConfig, path string) bool { + return matchesPattern(cfg.pattern, path) +} + +type WatcherConfig struct { + excludedDirs globList + pattern globList +} + +type FileWatcher interface { + Close() error + AddFiles() error + add(path string) error + Watch(jobs chan<- string) + getConfig() *WatcherConfig +} + +type NotifyWatcher struct { + watcher *fsnotify.Watcher + cfg *WatcherConfig +} + +func (n NotifyWatcher) Close() error { + return n.watcher.Close() +} + +func (n NotifyWatcher) AddFiles() error { + return addFiles(n) +} + +func (n NotifyWatcher) Watch(jobs chan<- string) { + for { + select { + case ev := <-n.watcher.Events: + if ev.Op&fsnotify.Remove == fsnotify.Remove || ev.Op&fsnotify.Write == fsnotify.Write || ev.Op&fsnotify.Create == fsnotify.Create { + // Assume it is a directory and track it. + if directoryShouldBeTracked(n.cfg, ev.Name) { + n.watcher.Add(ev.Name) + } + if pathMatches(n.cfg, ev.Name) { + jobs <- ev.Name + } + } + + case err := <-n.watcher.Errors: + if v, ok := err.(*os.SyscallError); ok { + if v.Err == syscall.EINTR { + continue + } + log.Fatal("watcher.Error: SyscallError:", v) + } + log.Fatal("watcher.Error:", err) + } + } +} + +func (n NotifyWatcher) add(path string) error { + return n.watcher.Add(path) +} + +func (n NotifyWatcher) getConfig() *WatcherConfig { + return n.cfg +} + +func NewWatcher(cfg *WatcherConfig) (FileWatcher, error) { + if cfg == nil { + err := errors.New("no config specified") + return nil, err + } + w, err := fsnotify.NewWatcher() + if err != nil { + return nil, err + } + return NotifyWatcher{ + watcher: w, + cfg: cfg, + }, nil +} + +func addFiles(fw FileWatcher) error { + cfg := fw.getConfig() + for _, pattern := range cfg.pattern { + matches, err := doublestar.Glob(pattern) + if err != nil { + log.Fatalf("Bad pattern \"%s\": %s", pattern, err.Error()) + } + for _, match := range matches { + if directoryShouldBeTracked(cfg, match) { + if err := fw.add(match); err != nil { + return fmt.Errorf("FileWatcher.Add(): %v", err) + } + } + } + + } + return nil +} diff --git a/zqdgr.config.json b/zqdgr.config.json new file mode 100644 index 0000000..76217c9 --- /dev/null +++ b/zqdgr.config.json @@ -0,0 +1,20 @@ +{ + "name": "zqdgr", + "version": "0.0.1", + "description": "zqdgr is a quick and dirty Golang runner", + "author": "juls0730", + "license": "BSL-1.0", + "homepage": "https://github.com/juls0730/zqdgr", + "repository": { + "type": "git", + "url": "https://github.com/juls0730/zqdgr.git" + }, + "scripts": { + "build": "go build -o zqdgr", + "dev": "sleep 5; echo 'test' && sleep 2 && echo 'test2'" + }, + "pattern": "**/*.go", + "excluded_dirs": [ + "folder" + ] +} \ No newline at end of file