initial commit

This commit is contained in:
abs3nt 2023-03-04 14:22:03 -08:00
commit afb536e439
30 changed files with 3202 additions and 0 deletions

38
.gitignore vendored Normal file
View File

@ -0,0 +1,38 @@
# Compiled Object files, Static and Dynamic libs (Shared Objects)
*.o
*.a
*.so
# Folders
_obj
_test
# Architecture specific extensions/prefixes
*.[568vq]
[568vq].out
*.cgo1.go
*.cgo2.c
_cgo_defun.c
_cgo_gotypes.go
_cgo_export.*
.haunt.yaml
_testmain.go
*.exe
*.test
*.prof
.glide
.idea
.DS_Store
.realize
realize/assets/*
docker
vendor
bin
glide.*
Dockerfile
docker-compose.yml

6
.woodpecker.yml Normal file
View File

@ -0,0 +1,6 @@
pipeline:
build:
image: golang
commands:
- go mod tidy
- go build -o bin/realize

16
LICENSE Normal file
View File

@ -0,0 +1,16 @@
The GPLv3 License (GPLv3)
Copyright (c) 2023 abs3nt
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.

34
Makefile Normal file
View File

@ -0,0 +1,34 @@
pkgname := haunt
build: ${pkgname}
${pkgname}: $(shell find . -name '*.go')
mkdir -p bin
go build -o bin/${pkgname} .
completions:
mkdir -p completions
./bin/${pkgname} completion zsh > completions/_${pkgname}
./bin/${pkgname} completion bash > completions/${pkgname}
./bin/${pkgname} completion fish > completions/${pkgname}.fish
run:
go run main.go
tidy:
go mod tidy
clean:
rm -f bin
rm -rf completions
uninstall:
rm -f /usr/bin/${pkgname}
rm -f /usr/share/zsh/site-functions/_${pkgname}
rm -f /usr/share/bash-completion/completions/${pkgname}
rm -f /usr/share/fish/vendor_completions.d/${pkgname}.fish
install:
cp bin/${pkgname} /usr/bin
bin/${pkgname} completion zsh > /usr/share/zsh/site-functions/_${pkgname}
bin/${pkgname} completion bash > /usr/share/bash-completion/completions/${pkgname}
bin/${pkgname} completion fish > /usr/share/fish/vendor_completions.d/${pkgname}.fish

141
README.md Normal file
View File

@ -0,0 +1,141 @@
## Quickstart
```
go install github.com/abs3ntdev/haunt@latest
```
or
```
git clone https://github.com/abs3ntdev/haunt
cd haunt
make build && sudo make install
```
## Commands List
### Run Command
From **project/projects** root execute:
```
haunt init
```
then
```
haunt start
```
haunt init will add your root directory if it contains a main.go file and will add any directory inside of cmd as projects. If you wish to add additional projects run haunt add [name] or edit the config file manually. By default projects are set to run go install and go run [project]
***start*** command supports the following custom parameters:
--name="name" -> Run by name on existing configuration
--path="haunt/server" -> Custom Path (if not specified takes the working directory name)
--generate -> Enable go generate
--fmt -> Enable go fmt
--test -> Enable go test
--vet -> Enable go vet
--install -> Enable go install
--build -> Enable go build
--run -> Enable go run
--server -> Enable the web server
--open -> Open web ui in default browser
--no-config -> Ignore an existing config / skip the creation of a new one
Some examples:
haunt start
haunt start --path="mypath"
haunt start --name="haunt" --build
haunt start --path="haunt" --run --no-config
haunt start --install --test --fmt --no-config
haunt start --path="/Users/username/go/src/github.com/oxequa/haunt-examples/coin/"
### Init Command
This command will generate a .haunt.yaml with sane default for your current project/projects.\
If there is a main.go in the root directory it will be added along with any directories inside the relative path `cmd`
haunt init
### Add Command
Add a project to an existing config file or create a new one.
haunt add [name]
### Remove Command
Remove a project by its name
haunt remove [name]
## Config sample
settings:
legacy:
force: true // force polling watcher instead fsnotifiy
interval: 100ms // polling interval
resources: // files names
outputs: outputs.log
logs: logs.log
errors: errors.log
server:
status: false // server status
open: false // open browser at start
host: localhost // server host
port: 5001 // server port
schema:
- name: coin
path: cmd/coin // project path
env: // env variables available at startup
test: test
myvar: value
commands: // go commands supported
vet:
status: true
fmt:
status: true
args:
- -s
- -w
test:
status: true
method: gb test // support different build tools
generate:
status: true
install:
status: true
build:
status: false
method: gb build // support differents build tool
args: // additional params for the command
- -race
run:
status: true
args: // arguments to pass at the project
- --myarg
watcher:
paths: // watched paths
- /
ignore_paths: // ignored paths
- vendor
extensions: // watched extensions
- go
- html
scripts:
- type: before
command: echo before global
global: true
output: true
- type: before
command: echo before change
output: true
- type: after
command: echo after change
output: true
- type: after
command: echo after global
global: true
output: true
errorOutputPattern: mypattern //custom error pattern

99
cmd/add.go Normal file
View File

@ -0,0 +1,99 @@
package cmd
import (
"log"
"os"
"strings"
"github.com/abs3ntdev/haunt/src/config"
"github.com/abs3ntdev/haunt/src/haunt"
"github.com/spf13/cobra"
)
var addConfig config.Flags
var addCmd = &cobra.Command{
Use: "add",
Short: "Adds a project by name",
Long: "Adds a project by name, if path is provided it will use 'cmd/name', all flags provided will be saved in the config file. By default go install and go run will be ran",
Args: cobra.MatchAll(cobra.ExactArgs(1)),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) >= 1 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
return getPotentialProjets(toComplete), cobra.ShellCompDirectiveNoFileComp
},
RunE: add,
}
func getPotentialProjets(in string) []string {
r := haunt.NewHaunt()
err := r.Settings.Read(&r)
if err != nil {
return []string{}
}
out := []string{}
cmdDir, err := os.ReadDir("cmd")
if err != nil {
return []string{}
}
for _, dir := range cmdDir {
exists := false
for _, proj := range r.Projects {
if dir.Name() == proj.Name {
exists = true
continue
}
}
if !exists {
if in == "" {
out = append(out, dir.Name())
} else {
if strings.HasPrefix(dir.Name(), in) {
out = append(out, dir.Name())
}
}
}
}
return out
}
func init() {
rootCmd.AddCommand(addCmd)
addCmd.Flags().StringVarP(&addConfig.Path, "path", "p", "", "Project base path")
addCmd.Flags().BoolVarP(&addConfig.Format, "fmt", "f", false, "Enable go fmt")
addCmd.Flags().BoolVarP(&addConfig.Vet, "vet", "v", false, "Enable go vet")
addCmd.Flags().BoolVarP(&addConfig.Test, "test", "t", false, "Enable go test")
addCmd.Flags().BoolVarP(&addConfig.Generate, "generate", "g", false, "Enable go generate")
addCmd.Flags().BoolVarP(&addConfig.Install, "install", "i", true, "Enable go install")
addCmd.Flags().BoolVarP(&addConfig.Build, "build", "b", false, "Enable go build")
addCmd.Flags().BoolVarP(&addConfig.Run, "run", "r", true, "Enable go run")
}
// Add a project to an existing config or create a new one
func add(cmd *cobra.Command, args []string) (err error) {
addConfig.Name = args[0]
r := haunt.NewHaunt()
// read a config if exist
err = r.Settings.Read(&r)
if err != nil {
return err
}
if addConfig.Path == "" {
addConfig.Path = "cmd/" + addConfig.Name
}
projects := len(r.Schema.Projects)
// create and add a new project
r.Schema.Add(r.Schema.New(addConfig))
if len(r.Schema.Projects) > projects {
// update config
err = r.Settings.Write(r)
if err != nil {
return err
}
log.Println(r.Prefix(haunt.Green.Bold("project successfully added")))
} else {
log.Println(r.Prefix(haunt.Green.Bold("project can't be added")))
}
return nil
}

29
cmd/clean.go Normal file
View File

@ -0,0 +1,29 @@
package cmd
import (
"log"
"github.com/abs3ntdev/haunt/src/haunt"
"github.com/spf13/cobra"
)
// cleanCmd represents the clean command
var cleanCmd = &cobra.Command{
Use: "clean",
Short: "Deletes the haunt config file",
RunE: clean,
}
func init() {
rootCmd.AddCommand(cleanCmd)
}
// Clean remove haunt file
func clean(cmd *cobra.Command, args []string) (err error) {
r := haunt.NewHaunt()
if err := r.Settings.Remove(haunt.RFile); err != nil {
return err
}
log.Println(r.Prefix(haunt.Green.Bold("folder successfully removed")))
return nil
}

53
cmd/init.go Normal file
View File

@ -0,0 +1,53 @@
package cmd
import (
"fmt"
"log"
"os"
"strings"
"github.com/abs3ntdev/haunt/src/haunt"
"github.com/spf13/cobra"
)
// initCmd represents the init command
var initCmd = &cobra.Command{
Use: "init",
Short: "Generates a haunt config file using sane defaults",
Long: "Generates a haunt config file using sane defaults, haunt will look for a main.go file and any directories inside the relative path 'cmd' and add them all as projects",
RunE: defaultConfig,
}
func init() {
rootCmd.AddCommand(initCmd)
}
func defaultConfig(cmd *cobra.Command, args []string) error {
r := haunt.NewHaunt()
write := true
if _, err := os.Stat(haunt.RFile); err == nil {
fmt.Print(r.Prefix("Config file exists. Overwire? " + haunt.Magenta.Bold("[y/n] ") + haunt.Green.Bold("(n) ")))
var overwrite string
fmt.Scanf("%s", &overwrite)
write = false
switch strings.ToLower(overwrite) {
case "y", "ye", "yes":
write = true
}
}
if write {
r.SetDefaults()
err := r.Settings.Write(r)
if err != nil {
return err
}
log.Println(r.Prefix(
"Config file has successfully been saved at .haunt.yaml",
))
log.Println(r.Prefix(
"Run haunt add --help to see how to add more projects",
))
return nil
}
return nil
}

64
cmd/remove.go Normal file
View File

@ -0,0 +1,64 @@
package cmd
import (
"log"
"strings"
"github.com/abs3ntdev/haunt/src/haunt"
"github.com/spf13/cobra"
)
var removeCmd = &cobra.Command{
Use: "remove [names]",
Short: "Removes all projects by name from config file",
Args: cobra.MatchAll(cobra.MinimumNArgs(1), cobra.OnlyValidArgs),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return getProjectNames(toComplete), cobra.ShellCompDirectiveNoFileComp
},
RunE: remove,
}
func init() {
rootCmd.AddCommand(removeCmd)
}
func getProjectNames(input string) []string {
r := haunt.NewHaunt()
// read a config if exist
err := r.Settings.Read(&r)
if err != nil {
return []string{}
}
names := []string{}
for _, project := range r.Schema.Projects {
if strings.HasPrefix(project.Name, input) {
names = append(names, project.Name)
}
}
return names
}
// Remove a project from an existing config
func remove(cmd *cobra.Command, args []string) (err error) {
r := haunt.NewHaunt()
// read a config if exist
err = r.Settings.Read(&r)
if err != nil {
return err
}
for _, arg := range args {
err := r.Schema.Remove(arg)
if err != nil {
log.Println(r.Prefix(haunt.Red.Bold(arg + " project not found")))
continue
}
log.Println(r.Prefix(haunt.Green.Bold(arg + " successfully removed")))
}
// update config
err = r.Settings.Write(r)
if err != nil {
return err
}
return nil
}

31
cmd/root.go Normal file
View File

@ -0,0 +1,31 @@
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "haunt",
Short: `Haunt: Go task automater and live-reloader.
.-.
(o o)
| O \
\ \
'~~'
`,
}
func Execute() {
err := rootCmd.Execute()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func init() {
rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
}

93
cmd/start.go Normal file
View File

@ -0,0 +1,93 @@
package cmd
import (
"github.com/abs3ntdev/haunt/src/config"
"github.com/abs3ntdev/haunt/src/haunt"
"github.com/spf13/cobra"
)
var startConfig config.Flags
var startCmd = &cobra.Command{
Use: "start",
Short: "Start haunt on a given path, generates a config file if one does not already exist",
RunE: start,
}
func init() {
rootCmd.AddCommand(startCmd)
startCmd.Flags().StringVarP(&startConfig.Path, "path", "p", "", "Project base path")
startCmd.Flags().StringVarP(&startConfig.Name, "name", "n", "", "Run a project by its name")
startCmd.Flags().BoolVarP(&startConfig.Format, "fmt", "f", false, "Enable go fmt")
startCmd.Flags().BoolVarP(&startConfig.Vet, "vet", "v", false, "Enable go vet")
startCmd.Flags().BoolVarP(&startConfig.Test, "test", "t", false, "Enable go test")
startCmd.Flags().BoolVarP(&startConfig.Generate, "generate", "g", false, "Enable go generate")
startCmd.Flags().BoolVarP(&startConfig.Server, "server", "s", false, "Start server")
startCmd.Flags().BoolVarP(&startConfig.Open, "open", "o", false, "Open into the default browser")
startCmd.Flags().BoolVarP(&startConfig.Install, "install", "i", false, "Enable go install")
startCmd.Flags().BoolVarP(&startConfig.Build, "build", "b", false, "Enable go build")
startCmd.Flags().BoolVarP(&startConfig.Run, "run", "r", false, "Enable go run")
startCmd.Flags().BoolVarP(&startConfig.Legacy, "legacy", "l", false, "Legacy watch by polling instead fsnotify")
startCmd.Flags().BoolVarP(&startConfig.NoConfig, "no-config", "c", false, "Ignore existing config and doesn't create a new one")
}
// Start haunt workflow
func start(cmd *cobra.Command, args []string) (err error) {
r := haunt.NewHaunt()
// set legacy watcher
if startConfig.Legacy {
r.Settings.Legacy.Set(startConfig.Legacy, 1)
}
// set server
if startConfig.Server {
r.Server.Set(startConfig.Server, startConfig.Open, haunt.Port, haunt.Host)
}
// check no-config and read
if !startConfig.NoConfig {
// read a config if exist
err := r.Settings.Read(&r)
if err != nil {
return err
}
if startConfig.Name != "" {
// filter by name flag if exist
r.Schema.Projects = r.Schema.Filter("Name", startConfig.Name)
}
// increase file limit
if r.Settings.FileLimit != 0 {
if err = r.Settings.Flimit(); err != nil {
return err
}
}
}
// check project list length
if len(r.Schema.Projects) == 0 {
// create a new project based on given params
project := r.Schema.New(startConfig)
// Add to projects list
r.Schema.Add(project)
// save config
if !startConfig.NoConfig {
err = r.Settings.Write(r)
if err != nil {
return err
}
}
}
// Start web server
if r.Server.Status {
r.Server.Parent = r
err = r.Server.Start()
if err != nil {
return err
}
err = r.Server.OpenURL()
if err != nil {
return err
}
}
// start workflow
return r.Start()
}

41
cmd/version.go Normal file
View File

@ -0,0 +1,41 @@
package cmd
import (
"log"
"github.com/abs3ntdev/haunt/src/haunt"
"github.com/spf13/cobra"
)
// versionCmd represents the version command
var versionCmd = &cobra.Command{
Use: "version",
Short: "A brief description of your command",
Long: `A longer description that spans multiple lines and likely contains examples
and usage of using your command. For example:
Cobra is a CLI library for Go that empowers applications.
This application is a tool to generate the needed files
to quickly create a Cobra application.`,
Run: version,
}
func init() {
rootCmd.AddCommand(versionCmd)
// Here you will define your flags and configuration settings.
// Cobra supports Persistent Flags which will work for this command
// and all subcommands, e.g.:
// versionCmd.PersistentFlags().String("foo", "", "A help for foo")
// Cobra supports local flags which will only run when this command
// is called directly, e.g.:
// versionCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
}
// Version print current version
func version(cmd *cobra.Command, args []string) {
r := haunt.NewHaunt()
log.Println(r.Prefix(haunt.Green.Bold(haunt.RVersion)))
}

28
go.mod Normal file
View File

@ -0,0 +1,28 @@
module github.com/abs3ntdev/haunt
go 1.19
require (
github.com/fatih/color v1.14.1
github.com/fsnotify/fsnotify v1.6.0
github.com/labstack/echo v3.3.10+incompatible
github.com/sirupsen/logrus v1.9.0
github.com/spf13/cobra v1.6.1
golang.org/x/net v0.7.0
gopkg.in/yaml.v2 v2.4.0
)
require (
github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/labstack/gommon v0.4.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/stretchr/testify v1.8.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
golang.org/x/crypto v0.6.0 // indirect
golang.org/x/sys v0.5.0 // indirect
golang.org/x/text v0.7.0 // indirect
)

68
go.sum Normal file
View File

@ -0,0 +1,68 @@
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w=
github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg=
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/labstack/echo v3.3.10+incompatible h1:pGRcYk231ExFAyoAjAfD85kQzRJCRI8bbnE7CX5OEgg=
github.com/labstack/echo v3.3.10+incompatible/go.mod h1:0INS7j/VjnFxD4E2wkz67b8cVwCLbBmJyDaka6Cmk1s=
github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8=
github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA=
github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

7
main.go Normal file
View File

@ -0,0 +1,7 @@
package main
import "github.com/abs3ntdev/haunt/cmd"
func main() {
cmd.Execute()
}

17
src/config/config.go Normal file
View File

@ -0,0 +1,17 @@
package config
type Flags struct {
Path string
Name string
Format bool
Vet bool
Test bool
Generate bool
Server bool
Open bool
Install bool
Build bool
Run bool
Legacy bool
NoConfig bool
}

538
src/haunt/bindata.go Normal file

File diff suppressed because one or more lines are too long

182
src/haunt/cli.go Normal file
View File

@ -0,0 +1,182 @@
package haunt
import (
"errors"
"fmt"
"go/build"
"log"
"os"
"os/signal"
"path/filepath"
"strings"
"sync"
"time"
"github.com/fsnotify/fsnotify"
)
var (
// RPrefix tool name
RPrefix = "haunt"
// RVersion current version
RVersion = "3.0.0"
// RExt file extension
RExt = ".yaml"
// RFile config file name
RFile = "." + RPrefix + RExt
// RExtWin windows extension
RExtWin = ".exe"
)
type (
// LogWriter used for all log
LogWriter struct{}
// Haunt main struct
Haunt struct {
Settings Settings `yaml:"settings" json:"settings"`
Server Server `yaml:"server,omitempty" json:"server,omitempty"`
Schema `yaml:",inline" json:",inline"`
Sync chan string `yaml:"-" json:"-"`
Err Func `yaml:"-" json:"-"`
After Func `yaml:"-" json:"-"`
Before Func `yaml:"-" json:"-"`
Change Func `yaml:"-" json:"-"`
Reload Func `yaml:"-" json:"-"`
}
// Context is used as argument for func
Context struct {
Path string
Project *Project
Stop <-chan bool
Watcher FileWatcher
Event fsnotify.Event
}
// Func is used instead haunt func
Func func(Context)
)
func NewHaunt() *Haunt {
return &Haunt{
Sync: make(chan string),
}
}
// init check
func init() {
// custom log
log.SetFlags(0)
log.SetOutput(LogWriter{})
if build.Default.GOPATH == "" {
log.Fatal("$GOPATH isn't set properly")
}
path := filepath.SplitList(build.Default.GOPATH)
if err := os.Setenv("GOBIN", filepath.Join(path[len(path)-1], "bin")); err != nil {
log.Fatal(err)
}
}
func (r *Haunt) SetDefaults() {
r.Server = Server{Parent: r, Status: true, Open: false, Port: Port}
r.Settings.FileLimit = 0
r.Settings.Legacy.Interval = 100 * time.Millisecond
r.Settings.Legacy.Force = false
r.Settings.Files.Errors = Resource{Name: FileErr, Status: false}
r.Settings.Files.Errors = Resource{Name: FileOut, Status: false}
r.Settings.Files.Errors = Resource{Name: FileLog, Status: false}
if _, err := os.Stat("main.go"); err == nil {
log.Println(r.Prefix(Green.Bold("Adding: " + filepath.Base(Wdir()))))
r.Schema.Projects = append(r.Schema.Projects, Project{
Name: filepath.Base(Wdir()),
Path: Wdir(),
Tools: Tools{
Install: Tool{
Status: true,
},
Run: Tool{
Status: true,
},
},
Watcher: Watch{
Exts: []string{"go"},
Paths: []string{"/"},
},
})
} else {
log.Println(r.Prefix(Magenta.Bold("Skipping: " + filepath.Base(Wdir()) + " no main.go file in root")))
}
subDirs, err := os.ReadDir("cmd")
if err != nil {
log.Println(r.Prefix("cmd directory not found, skipping"))
return
}
for _, dir := range subDirs {
if dir.IsDir() {
log.Println(r.Prefix(Green.Bold("Adding: " + dir.Name())))
r.Schema.Projects = append(r.Schema.Projects, Project{
Name: dir.Name(),
Path: "cmd/" + dir.Name(),
Tools: Tools{
Install: Tool{
Status: true,
},
Run: Tool{
Status: true,
},
},
Watcher: Watch{
Exts: []string{"go"},
Paths: []string{"/"},
},
})
} else {
log.Println(r.Prefix(Magenta.Bold("Skipping: " + dir.Name() + " not a directory")))
}
}
}
// Stop haunt workflow
func (r *Haunt) Stop() error {
for k := range r.Schema.Projects {
if r.Schema.Projects[k].exit != nil {
close(r.Schema.Projects[k].exit)
}
}
return nil
}
// Start haunt workflow
func (r *Haunt) Start() error {
if len(r.Schema.Projects) > 0 {
var wg sync.WaitGroup
wg.Add(len(r.Schema.Projects))
for k := range r.Schema.Projects {
r.Schema.Projects[k].exit = make(chan os.Signal, 1)
signal.Notify(r.Schema.Projects[k].exit, os.Interrupt)
r.Schema.Projects[k].parent = r
go r.Schema.Projects[k].Watch(&wg)
}
wg.Wait()
} else {
return errors.New("there are no projects")
}
return nil
}
// Prefix a given string with tool name
func (r *Haunt) Prefix(input string) string {
if len(input) > 0 {
return fmt.Sprint(Yellow.Bold("["), strings.ToUpper(RPrefix), Yellow.Bold("]"), ": ", input)
}
return input
}
// Rewrite the layout of the log timestamp
func (w LogWriter) Write(bytes []byte) (int, error) {
if len(bytes) > 0 {
return fmt.Fprint(Output, Yellow.Regular("["), time.Now().Format("15:04:05"), Yellow.Regular("]"), string(bytes))
}
return 0, nil
}

269
src/haunt/notify.go Normal file
View File

@ -0,0 +1,269 @@
package haunt
// this code is imported from moby, unfortunately i can't import it directly as dependencies from its repo,
// cause there was a problem between moby vendor and fsnotify
// i have just added only the walk methods and some little changes to polling interval, originally set as static.
import (
"errors"
"fmt"
"os"
"sync"
"time"
"github.com/fsnotify/fsnotify"
"github.com/sirupsen/logrus"
)
var (
// errPollerClosed is returned when the poller is closed
errPollerClosed = errors.New("poller is closed")
// errNoSuchWatch is returned when trying to remove a watch that doesn't exist
errNoSuchWatch = errors.New("watch does not exist")
)
type (
// FileWatcher is an interface for implementing file notification watchers
FileWatcher interface {
Close() error
Add(string) error
Walk(string, bool) string
Remove(string) error
Errors() <-chan error
Events() <-chan fsnotify.Event
}
// fsNotifyWatcher wraps the fsnotify package to satisfy the FileNotifier interface
fsNotifyWatcher struct {
*fsnotify.Watcher
}
// filePoller is used to poll files for changes, especially in cases where fsnotify
// can't be run (e.g. when inotify handles are exhausted)
// filePoller satisfies the FileWatcher interface
filePoller struct {
// watches is the list of files currently being polled, close the associated channel to stop the watch
watches map[string]chan struct{}
// events is the channel to listen to for watch events
events chan fsnotify.Event
// errors is the channel to listen to for watch errors
errors chan error
// mu locks the poller for modification
mu sync.Mutex
// closed is used to specify when the poller has already closed
closed bool
// polling interval
interval time.Duration
}
)
// PollingWatcher returns a poll-based file watcher
func PollingWatcher(interval time.Duration) FileWatcher {
if interval == 0 {
interval = time.Duration(1) * time.Second
}
return &filePoller{
interval: interval,
events: make(chan fsnotify.Event),
errors: make(chan error),
}
}
// NewFileWatcher tries to use an fs-event watcher, and falls back to the poller if there is an error
func NewFileWatcher(l Legacy) (FileWatcher, error) {
if !l.Force {
if w, err := EventWatcher(); err == nil {
return w, nil
}
}
return PollingWatcher(l.Interval), nil
}
// EventWatcher returns an fs-event based file watcher
func EventWatcher() (FileWatcher, error) {
w, err := fsnotify.NewWatcher()
if err != nil {
return nil, err
}
return &fsNotifyWatcher{Watcher: w}, nil
}
// Errors returns the fsnotify error channel receiver
func (w *fsNotifyWatcher) Errors() <-chan error {
return w.Watcher.Errors
}
// Events returns the fsnotify event channel receiver
func (w *fsNotifyWatcher) Events() <-chan fsnotify.Event {
return w.Watcher.Events
}
// Walk fsnotify
func (w *fsNotifyWatcher) Walk(path string, init bool) string {
if err := w.Add(path); err != nil {
return ""
}
return path
}
// Close closes the poller
// All watches are stopped, removed, and the poller cannot be added to
func (w *filePoller) Close() error {
w.mu.Lock()
if w.closed {
w.mu.Unlock()
return nil
}
w.closed = true
for name := range w.watches {
w.remove(name)
delete(w.watches, name)
}
w.mu.Unlock()
return nil
}
// Errors returns the errors channel
// This is used for notifications about errors on watched files
func (w *filePoller) Errors() <-chan error {
return w.errors
}
// Add adds a filename to the list of watches
// once added the file is polled for changes in a separate goroutine
func (w *filePoller) Add(name string) error {
w.mu.Lock()
defer w.mu.Unlock()
if w.closed {
return errPollerClosed
}
f, err := os.Open(name)
if err != nil {
return err
}
fi, err := os.Stat(name)
if err != nil {
return err
}
if w.watches == nil {
w.watches = make(map[string]chan struct{})
}
if _, exists := w.watches[name]; exists {
return fmt.Errorf("watch exists")
}
chClose := make(chan struct{})
w.watches[name] = chClose
go w.watch(f, fi, chClose)
return nil
}
// Remove poller
func (w *filePoller) remove(name string) error {
if w.closed {
return errPollerClosed
}
chClose, exists := w.watches[name]
if !exists {
return errNoSuchWatch
}
close(chClose)
delete(w.watches, name)
return nil
}
// Remove stops and removes watch with the specified name
func (w *filePoller) Remove(name string) error {
w.mu.Lock()
defer w.mu.Unlock()
return w.remove(name)
}
// Events returns the event channel
// This is used for notifications on events about watched files
func (w *filePoller) Events() <-chan fsnotify.Event {
return w.events
}
// Walk poller
func (w *filePoller) Walk(path string, init bool) string {
check := w.watches[path]
if err := w.Add(path); err != nil {
return ""
}
if check == nil && init {
_, err := os.Stat(path)
if err == nil {
go w.sendEvent(fsnotify.Event{Op: fsnotify.Create, Name: path}, w.watches[path])
}
}
return path
}
// sendErr publishes the specified error to the errors channel
func (w *filePoller) sendErr(e error, chClose <-chan struct{}) error {
select {
case w.errors <- e:
case <-chClose:
return fmt.Errorf("closed")
}
return nil
}
// sendEvent publishes the specified event to the events channel
func (w *filePoller) sendEvent(e fsnotify.Event, chClose <-chan struct{}) error {
select {
case w.events <- e:
case <-chClose:
return fmt.Errorf("closed")
}
return nil
}
// watch is responsible for polling the specified file for changes
// upon finding changes to a file or errors, sendEvent/sendErr is called
func (w *filePoller) watch(f *os.File, lastFi os.FileInfo, chClose chan struct{}) {
defer f.Close()
for {
time.Sleep(w.interval)
select {
case <-chClose:
logrus.Debugf("watch for %s closed", f.Name())
return
default:
}
fi, err := os.Stat(f.Name())
switch {
case err != nil && lastFi != nil:
// If it doesn't exist at this point, it must have been removed
// no need to send the error here since this is a valid operation
if os.IsNotExist(err) {
if err := w.sendEvent(fsnotify.Event{Op: fsnotify.Remove, Name: f.Name()}, chClose); err != nil {
return
}
lastFi = nil
}
// at this point, send the error
w.sendErr(err, chClose)
return
case lastFi == nil:
if err := w.sendEvent(fsnotify.Event{Op: fsnotify.Create, Name: f.Name()}, chClose); err != nil {
return
}
lastFi = fi
case fi.Mode() != lastFi.Mode():
if err := w.sendEvent(fsnotify.Event{Op: fsnotify.Chmod, Name: f.Name()}, chClose); err != nil {
return
}
lastFi = fi
case fi.ModTime() != lastFi.ModTime() || fi.Size() != lastFi.Size():
if err := w.sendEvent(fsnotify.Event{Op: fsnotify.Write, Name: f.Name()}, chClose); err != nil {
return
}
lastFi = fi
}
}
}

714
src/haunt/projects.go Normal file
View File

@ -0,0 +1,714 @@
package haunt
import (
"bufio"
"bytes"
"errors"
"fmt"
"log"
"math/big"
"os"
"os/exec"
"path/filepath"
"reflect"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/fsnotify/fsnotify"
)
var (
msg string
out BufferOut
)
// Watch info
type Watch struct {
Exts []string `yaml:"extensions" json:"extensions"`
Paths []string `yaml:"paths" json:"paths"`
Scripts []Command `yaml:"scripts,omitempty" json:"scripts,omitempty"`
Hidden bool `yaml:"hidden,omitempty" json:"hidden,omitempty"`
Ignore []string `yaml:"ignored_paths,omitempty" json:"ignored_paths,omitempty"`
}
type Ignore struct {
Exts []string `yaml:"exts,omitempty" json:"exts,omitempty"`
Paths []string `yaml:"paths,omitempty" json:"paths,omitempty"`
}
// Command fields
type Command struct {
Cmd string `yaml:"command" json:"command"`
Type string `yaml:"type" json:"type"`
Path string `yaml:"path,omitempty" json:"path,omitempty"`
Global bool `yaml:"global,omitempty" json:"global,omitempty"`
Output bool `yaml:"output,omitempty" json:"output,omitempty"`
}
// Project info
type Project struct {
parent *Haunt
watcher FileWatcher
stop chan bool
exit chan os.Signal
last last
files int64
folders int64
init bool
Name string `yaml:"name" json:"name"`
Path string `yaml:"path" json:"path"`
Env map[string]string `yaml:"env,omitempty" json:"env,omitempty"`
Args []string `yaml:"args,omitempty" json:"args,omitempty"`
Tools Tools `yaml:"commands" json:"commands"`
Watcher Watch `yaml:"watcher" json:"watcher"`
Buffer Buffer `yaml:"-" json:"buffer"`
ErrPattern string `yaml:"pattern,omitempty" json:"pattern,omitempty"`
}
// Last is used to save info about last file changed
type last struct {
file string
time time.Time
}
// Response exec
type Response struct {
Name string
Out string
Err error
}
// Buffer define an array buffer for each log files
type Buffer struct {
StdOut []BufferOut `json:"stdOut"`
StdLog []BufferOut `json:"stdLog"`
StdErr []BufferOut `json:"stdErr"`
}
// BufferOut is used for exchange information between "haunt cli" and "web haunt"
type BufferOut struct {
Time time.Time `json:"time"`
Text string `json:"text"`
Path string `json:"path"`
Type string `json:"type"`
Stream string `json:"stream"`
Errors []string `json:"errors"`
}
// After stop watcher
func (p *Project) After() {
if p.parent.After != nil {
p.parent.After(Context{Project: p})
return
}
p.cmd(nil, "after", true)
}
// Before start watcher
func (p *Project) Before() {
if p.parent.Before != nil {
p.parent.Before(Context{Project: p})
return
}
// setup go tools
p.Tools.Setup()
// global commands before
p.cmd(p.stop, "before", true)
// indexing files and dirs
for _, dir := range p.Watcher.Paths {
base, _ := filepath.Abs(p.Path)
base = filepath.Join(base, dir)
if _, err := os.Stat(base); err == nil {
if err := filepath.Walk(base, p.walk); err != nil {
p.Err(err)
}
}
}
// start message
msg = fmt.Sprintln(p.pname(p.Name, 1), ":", Blue.Bold("Watching"), Magenta.Bold(p.files), "file/s", Magenta.Bold(p.folders), "folder/s")
out = BufferOut{Time: time.Now(), Text: "Watching " + strconv.FormatInt(p.files, 10) + " files/s " + strconv.FormatInt(p.folders, 10) + " folder/s"}
p.stamp("log", out, msg, "")
}
// Err occurred
func (p *Project) Err(err error) {
if p.parent.Err != nil {
p.parent.Err(Context{Project: p})
return
}
if err != nil {
msg = fmt.Sprintln(p.pname(p.Name, 2), ":", Red.Regular(err.Error()))
out = BufferOut{Time: time.Now(), Text: err.Error()}
p.stamp("error", out, msg, "")
}
}
// Change event message
func (p *Project) Change(event fsnotify.Event) {
if p.parent.Change != nil {
p.parent.Change(Context{Project: p, Event: event})
return
}
// file extension
ext := ext(event.Name)
if ext == "" {
ext = "DIR"
}
// change message
msg = fmt.Sprintln(p.pname(p.Name, 4), ":", Magenta.Bold(strings.ToUpper(ext)), "changed", Magenta.Bold(event.Name))
out = BufferOut{Time: time.Now(), Text: ext + " changed " + event.Name}
p.stamp("log", out, msg, "")
}
// Reload launches the toolchain run, build, install
func (p *Project) Reload(path string, stop <-chan bool) {
if p.parent.Reload != nil {
p.parent.Reload(Context{Project: p, Watcher: p.watcher, Path: path, Stop: stop})
return
}
var done bool
var install, build Response
go func() {
for {
<-stop
done = true
return
}
}()
if done {
return
}
// before command
p.cmd(stop, "before", false)
if done {
return
}
// Go supported tools
if len(path) > 0 {
fi, err := os.Stat(path)
if filepath.Ext(path) == "" {
fi, err = os.Stat(path)
}
if err != nil {
p.Err(err)
}
p.tools(stop, path, fi)
}
// Prevent fake events on polling startup
p.init = true
// prevent errors using haunt without config with only run flag
if p.Tools.Run.Status && !p.Tools.Install.Status && !p.Tools.Build.Status {
p.Tools.Install.Status = true
}
if done {
return
}
if p.Tools.Install.Status {
msg = fmt.Sprintln(p.pname(p.Name, 1), ":", Green.Regular(p.Tools.Install.name), "started")
out = BufferOut{Time: time.Now(), Text: p.Tools.Install.name + " started"}
p.stamp("log", out, msg, "")
start := time.Now()
install = p.Tools.Install.Compile(p.Path, stop)
install.print(start, p)
}
if done {
return
}
if p.Tools.Build.Status {
msg = fmt.Sprintln(p.pname(p.Name, 1), ":", Green.Regular(p.Tools.Build.name), "started")
out = BufferOut{Time: time.Now(), Text: p.Tools.Build.name + " started"}
p.stamp("log", out, msg, "")
start := time.Now()
build = p.Tools.Build.Compile(p.Path, stop)
build.print(start, p)
}
if done {
return
}
if install.Err == nil && build.Err == nil && p.Tools.Run.Status {
result := make(chan Response)
go func() {
for {
select {
case <-stop:
return
case r := <-result:
if r.Err != nil {
msg := fmt.Sprintln(p.pname(p.Name, 2), ":", Red.Regular(r.Err))
out := BufferOut{Time: time.Now(), Text: r.Err.Error(), Type: "Go Run"}
p.stamp("error", out, msg, "")
}
if r.Out != "" {
msg := fmt.Sprintln(p.pname(p.Name, 3), ":", Blue.Regular(r.Out))
out := BufferOut{Time: time.Now(), Text: r.Out, Type: "Go Run"}
p.stamp("out", out, msg, "")
}
}
}
}()
go func() {
log.Println(p.pname(p.Name, 1), ":", "Running..")
err := p.run(p.Path, result, stop)
if err != nil {
msg := fmt.Sprintln(p.pname(p.Name, 2), ":", Red.Regular(err))
out := BufferOut{Time: time.Now(), Text: err.Error(), Type: "Go Run"}
p.stamp("error", out, msg, "")
}
}()
}
if done {
return
}
p.cmd(stop, "after", false)
}
// Watch a project
func (p *Project) Watch(wg *sync.WaitGroup) {