initial commit

This commit is contained in:
Zoe
2024-11-16 04:17:27 -06:00
commit 290eafb3d0
10 changed files with 663 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
zqdgr

23
LICENSE Normal file
View File

@@ -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.

48
README.md Normal file
View File

@@ -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.
```

13
embed/zqdgr.config.json Normal file
View File

@@ -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": []
}

1
folder/test.go Normal file
View File

@@ -0,0 +1 @@
package folder

12
go.mod Normal file
View File

@@ -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
)

8
go.sum Normal file
View File

@@ -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=

397
main.go Normal file
View File

@@ -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")
}

140
watcher.go Normal file
View File

@@ -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
}

20
zqdgr.config.json Normal file
View File

@@ -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"
]
}