diff --git a/cmd/gova/build.go b/cmd/gova/build.go index 6ca2073..7dffb44 100644 --- a/cmd/gova/build.go +++ b/cmd/gova/build.go @@ -1,50 +1,95 @@ package main import ( - "flag" + "context" "fmt" - "io" "os" + "os/exec" "path/filepath" - "runtime" + "strings" - "github.com/nv404/gova/internal/devserver" + "github.com/nv404/gova/internal/config" + "github.com/nv404/gova/internal/utils" + "github.com/urfave/cli/v3" ) -func cmdBuild(args []string, stdout, stderr io.Writer) int { - fs := flag.NewFlagSet("build", flag.ContinueOnError) - fs.SetOutput(stderr) - out := fs.String("o", "", "output path (default ./bin/)") - pkg, _, err := parseFlags("build", fs, args) - if err != nil { - return 2 +var buildCmd = &cli.Command{ + Name: "build", + Description: "Build a gova application", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "mode", + Required: true, + Value: "gova-dev", + Aliases: []string{"m"}, + Config: cli.StringConfig{ + TrimSpace: true, + }, + DefaultText: "-m gova-dev", + Usage: "-m dev", + }, + }, + Action: func(ctx context.Context, c *cli.Command) error { + mode := c.String("mode") + + cnf, err := config.GetConfig() + if err != nil { + return err + } + + if err := buildApplication(ctx, mode, cnf); err != nil { + return err + } + + return nil + }, +} + +// buildApplication compiles the Go package defined in cfg using the build +// configuration for the given mode. It resolves the output binary path, +// sanitizes user-provided build flags, injects environment variables from +// EnvFiles and Env in that order, and invokes `go build`. +// +// The output binary is placed at OutputDir/BinaryName relative to the +// current working directory. +// +// Environment variables are layered as follows: +// - The current process environment (os.Environ) as the base +// - EnvFiles merged in order, later files taking precedence +// - Inline Env values taking final precedence +func buildApplication(ctx context.Context, mode string, cfg config.Config) error { + m, ok := cfg.Modes[mode] + if !ok { + return fmt.Errorf("unknown mode %q", mode) } + wd, err := os.Getwd() if err != nil { - fmt.Fprintln(stderr, err) - return 1 + return err } - output := *out - if output == "" { - name := filepath.Base(wd) - if pkg != "." { - name = filepath.Base(pkg) - } - if runtime.GOOS == "windows" { - name += ".exe" - } - output = filepath.Join(wd, "bin", name) - if err := os.MkdirAll(filepath.Dir(output), 0o755); err != nil { - fmt.Fprintln(stderr, err) - return 1 - } + + outputDir := utils.NormalizePath(cfg.OutputDir) + output := filepath.Join(wd, outputDir, utils.BinaryName(cfg.Name)) + path := strings.Join([]string{".", utils.NormalizePath(cfg.Package)}, string(filepath.Separator)) + + flags := append([]string{"build"}, utils.SanitizeBuildFlags(m.BuildFlags)...) + flags = append(flags, "-o", output, path) + + build := exec.CommandContext(ctx, "go", flags...) + + env, err := utils.ResolveEnv(m) + if err != nil { + return err } - ctx, cancel := signalContext() - defer cancel() - if err := devserver.Build(ctx, wd, pkg, output); err != nil { - fmt.Fprintln(stderr, err) - return 1 + + build.Env = env + build.Stdin = os.Stdin + build.Stdout = os.Stdout + build.Stderr = os.Stderr + + if err := build.Run(); err != nil { + return fmt.Errorf("go build failed: %w", err) } - fmt.Fprintf(stdout, "gova build: wrote %s\n", output) - return 0 + + return nil } diff --git a/cmd/gova/dev.go b/cmd/gova/dev.go index a1abbe2..65c045d 100644 --- a/cmd/gova/dev.go +++ b/cmd/gova/dev.go @@ -1,36 +1,245 @@ package main import ( - "flag" + "context" "fmt" - "io" + "io/fs" + "log/slog" + "os" + "os/exec" + "path/filepath" "time" - "github.com/nv404/gova/internal/devserver" + "github.com/fsnotify/fsnotify" + "github.com/nv404/gova/internal/config" + "github.com/nv404/gova/internal/utils" + "github.com/urfave/cli/v3" ) -func cmdDev(args []string, stdout, stderr io.Writer) int { - fs := flag.NewFlagSet("dev", flag.ContinueOnError) - fs.SetOutput(stderr) - debounce := fs.Duration("debounce", 200*time.Millisecond, "coalesce window for file-change events") - stateDir := fs.String("state-dir", "", "directory for persisted dev state (default /.gova/dev)") - pkg, rest, err := parseFlags("dev", fs, args) +var devCommand = &cli.Command{ + Name: "dev", + Description: "Watch the module and rebuild/restart on change (defaults to .)", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "mode", + Value: "gova-dev", + Aliases: []string{"m"}, + Config: cli.StringConfig{ + TrimSpace: true, + }, + DefaultText: "-m gova-dev", + Usage: "-m dev", + }, + }, + Action: func(ctx context.Context, c *cli.Command) error { + fmt.Println("Starting") + mode := c.String("mode") + cfg, err := config.GetConfig() + if err != nil { + return err + } + + cwd, err := os.Getwd() + if err != nil { + return err + } + + server, err := NewDevServer(ctx, cwd, cfg.Package, cfg.Ignore, cfg.Debounce.Duration) + if err != nil { + return err + } + + server.Start(mode, cfg, c.Args().Slice()...) + <-ctx.Done() + return nil + }, +} + +// DevServer watches a directory tree for file changes and triggers +// a rebuild of the entry point whenever a change is detected. +type DevServer struct { + watcher *fsnotify.Watcher + root string + entryPoint string + debounce time.Duration + ctx context.Context + notifyCh chan struct{} + cmd *exec.Cmd +} + +// NewDevServer creates a new DevServer rooted at root, watching all +// directories except those matching the ignore patterns. The debounce +// duration controls how long to wait after the last change before +// triggering a rebuild. +func NewDevServer(ctx context.Context, root, entryPoint string, ignore []string, debounce time.Duration) (*DevServer, error) { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return nil, err + } + + paths, err := getWatchPaths(root, append(ignore, "gova.json")) + if err != nil { + watcher.Close() + return nil, err + } + + for _, p := range paths { + if err = watcher.Add(p); err != nil { + watcher.Close() + return nil, err + } + } + + if err != nil { + return nil, err + } + + return &DevServer{ + watcher: watcher, + root: root, + entryPoint: entryPoint, + debounce: debounce, + ctx: ctx, + notifyCh: make(chan struct{}, 1), + cmd: nil, + }, nil +} + +// Start begins watching for file changes and handling rebuild triggers. +// It spawns two goroutines: one for the watcher and one for the manager. +// Cancel the context passed to NewDevServer to stop. +func (d *DevServer) Start(mode string, cnf config.Config, args ...string) { + go d.watch() + go d.manage(mode, cnf, args...) +} + +// watch listens for filesystem events and forwards debounced change +// notifications to the manager. Closes notifyCh when it exits so the +// manager knows to stop. +func (d *DevServer) watch() { + defer close(d.notifyCh) + + notify := utils.Debounce(func() { + select { + case d.notifyCh <- struct{}{}: + default: + } + }, d.debounce) + + for { + select { + case <-d.ctx.Done(): + return + + case event, ok := <-d.watcher.Events: + if !ok { + return + } + if event.Has(fsnotify.Create) { + if err := d.watcher.Add(event.Name); err != nil { + slog.Error("[dev server] failed to watch new path", "error", err, "path", event.Name) + } + } + if event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename) { + d.watcher.Remove(event.Name) + } + notify() + + case err, ok := <-d.watcher.Errors: + if !ok { + return + } + slog.Error("[dev server] watcher error", "error", err) + } + } +} + +// manage listens for rebuild notifications and drives the build process. +// It owns the watcher lifecycle and closes it on exit. +// It also closes the running application +func (d *DevServer) manage(mode string, cnf config.Config, args ...string) { + defer d.watcher.Close() + defer d.killApplication() + + d.run(mode, cnf, args...) + + for { + select { + case <-d.ctx.Done(): + return + + case _, ok := <-d.notifyCh: + if !ok { + return + } + d.run(mode, cnf, args...) + } + } +} + +// run builds the application at the output directory. Called by the manager +// whenever a debounced change notification is received. +func (d *DevServer) run(mode string, cnf config.Config, args ...string) { + slog.Info("[dev server] change detected, rebuilding...", "entry", d.entryPoint) + d.killApplication() + + // Build the application + + err := buildApplication(d.ctx, mode, cnf) if err != nil { - return 2 - } - ctx, cancel := signalContext() - defer cancel() - - if err := devserver.Run(ctx, devserver.Options{ - Package: pkg, - Args: rest, - Debounce: *debounce, - StateDir: *stateDir, - Stdout: stdout, - Stderr: stderr, - }); err != nil { - fmt.Fprintln(stderr, err) - return 1 - } - return 0 + slog.Error("[dev server] build failed", "error", err) + return + } + + cmd, err := runApplication(d.ctx, cnf, args...) + if err != nil { + slog.Error("[dev server] failed to start process", "error", err) + d.cmd = nil + return + } + d.cmd = cmd + + slog.Info("[dev server] process started", "pid", d.cmd.Process.Pid) +} + +// killApplication terminates the running process (the application) +// This can be called before making a newer build and while terminating the dev server +func (d *DevServer) killApplication() { + if d.cmd != nil && d.cmd.Process != nil { + d.cmd.Process.Kill() + d.cmd.Wait() + d.cmd = nil + } +} + +// getWatchPaths walks the directory tree rooted at root and returns all +// directories that should be watched, skipping any entries that match +// the ignore patterns. +func getWatchPaths(root string, ignore []string) ([]string, error) { + paths := make([]string, 0) + return paths, filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + name := d.Name() + for _, ig := range ignore { + matched, err := filepath.Match(ig, name) + if err != nil { + return err + } + if matched { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + } + + if d.IsDir() { + paths = append(paths, path) + } + + return nil + }) } diff --git a/cmd/gova/init.go b/cmd/gova/init.go new file mode 100644 index 0000000..0b036e3 --- /dev/null +++ b/cmd/gova/init.go @@ -0,0 +1,40 @@ +package main + +import ( + "context" + "time" + + "github.com/nv404/gova/internal/config" + "github.com/urfave/cli/v3" +) + +// Init Command initialises a go module to work with the Gova CLI +// It creates a gova.json files with metadata related to building the project and +// running the dev server +var initCommand = &cli.Command{ + Name: "init", + Description: "Initialise a gova project", + Action: func(ctx context.Context, c *cli.Command) error { + conf := config.Config{ + Version: "0.0.1", + Name: "govaapp", + Package: "github.com/cchirag/gova", + OutputDir: "bin", + Debounce: config.Duration{Duration: time.Millisecond * 2000}, + Ignore: []string{"vendor", "node_modules", ".git"}, + Modes: map[string]config.BuildMode{ + "dev": { + BuildFlags: []string{}, + EnvFiles: []string{}, + Env: []string{}, + }, + "prod": { + BuildFlags: []string{}, + EnvFiles: []string{}, + Env: []string{}, + }, + }, + } + return config.SetConfig(conf, false) + }, +} diff --git a/cmd/gova/main.go b/cmd/gova/main.go index 44f3a94..4e00b6f 100644 --- a/cmd/gova/main.go +++ b/cmd/gova/main.go @@ -1,111 +1,30 @@ -// Command gova is the developer tool for Gova applications. It wraps the -// Go toolchain with a dev-oriented hot-reload workflow and a convenience -// build/run shortcut. -// -// gova dev [pkg] # watch and reload on file change -// gova build [pkg] # compile pkg to ./bin/ -// gova run [pkg] # build and run once -// gova version -// gova help package main import ( "context" - "flag" - "fmt" - "io" + "log" "os" - "os/signal" - "syscall" + + "github.com/urfave/cli/v3" ) const version = "0.1.0" func main() { - os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) -} - -func run(args []string, stdout, stderr io.Writer) int { - if len(args) == 0 { - usage(stdout) - return 2 - } - switch args[0] { - case "dev": - return cmdDev(args[1:], stdout, stderr) - case "build": - return cmdBuild(args[1:], stdout, stderr) - case "run": - return cmdRun(args[1:], stdout, stderr) - case "version", "--version", "-v": - fmt.Fprintf(stdout, "gova %s\n", version) - return 0 - case "help", "--help", "-h": - usage(stdout) - return 0 - default: - fmt.Fprintf(stderr, "gova: unknown command %q\n\n", args[0]) - usage(stderr) - return 2 + cmd := &cli.Command{ + Name: "gova", + Description: "Gova is a GUI toolkit for Go", + Version: version, + DefaultCommand: "help", + Commands: []*cli.Command{ + initCommand, + devCommand, + buildCmd, + runCmd, + }, } -} - -func usage(w io.Writer) { - fmt.Fprint(w, `gova: developer tool for Gova apps - -Usage: - gova [args] - -Commands: - dev [pkg] Watch the module and rebuild/restart on change (defaults to .) - build [pkg] Compile pkg to ./bin/ - run [pkg] Build and run pkg once - version Print the gova CLI version - help Show this message -Run "gova --help" for command-specific flags. -`) -} - -// signalContext returns a context that cancels on SIGINT/SIGTERM. -func signalContext() (context.Context, context.CancelFunc) { - ctx, cancel := context.WithCancel(context.Background()) - ch := make(chan os.Signal, 1) - signal.Notify(ch, os.Interrupt, syscall.SIGTERM) - go func() { - <-ch - cancel() - signal.Stop(ch) - }() - return ctx, cancel -} - -// parseFlags parses the flagset against args, treating the first non-flag arg -// as the package path. Returns the package path (default ".") and leftover -// args (passed to the child). -func parseFlags(name string, fs *flag.FlagSet, args []string) (pkg string, rest []string, err error) { - fs.Usage = func() { - fmt.Fprintf(fs.Output(), "usage: gova %s [flags] [pkg] [-- child-args...]\n\n", name) - fs.PrintDefaults() - } - sep := -1 - for i, a := range args { - if a == "--" { - sep = i - break - } - } - head := args - if sep >= 0 { - head = args[:sep] - rest = args[sep+1:] - } - if err := fs.Parse(head); err != nil { - return "", nil, err - } - pkg = "." - if fs.NArg() > 0 { - pkg = fs.Arg(0) + if err := cmd.Run(context.Background(), os.Args); err != nil { + log.Fatal(err) } - return pkg, rest, nil } diff --git a/cmd/gova/main_test.go b/cmd/gova/main_test.go index dbe2a97..dd3715c 100644 --- a/cmd/gova/main_test.go +++ b/cmd/gova/main_test.go @@ -1,82 +1,17 @@ package main import ( - "bytes" - "os" - "path/filepath" - "runtime" - "strings" "testing" ) -func TestRunUnknownCommandReturns2(t *testing.T) { - var out, errb bytes.Buffer - code := run([]string{"nope"}, &out, &errb) - if code != 2 { - t.Fatalf("exit code: got %d want 2", code) - } - if !strings.Contains(errb.String(), "unknown command") { - t.Fatalf("expected diagnostic, got %q", errb.String()) - } +func TestInit(t *testing.T) { } -func TestRunNoArgsShowsUsage(t *testing.T) { - var out, errb bytes.Buffer - code := run(nil, &out, &errb) - if code != 2 { - t.Fatalf("exit code: got %d want 2", code) - } - if !strings.Contains(out.String(), "Usage:") { - t.Fatalf("expected usage, got %q", out.String()) - } +func TestDev(t *testing.T) { } -func TestRunVersion(t *testing.T) { - var out, errb bytes.Buffer - code := run([]string{"version"}, &out, &errb) - if code != 0 { - t.Fatalf("exit code: got %d want 0", code) - } - if !strings.Contains(out.String(), "gova ") { - t.Fatalf("expected version string, got %q", out.String()) - } +func TestBuild(t *testing.T) { } -func TestRunHelp(t *testing.T) { - var out, errb bytes.Buffer - code := run([]string{"help"}, &out, &errb) - if code != 0 { - t.Fatalf("exit code: got %d", code) - } - if !strings.Contains(out.String(), "dev [pkg]") { - t.Fatalf("expected help text, got %q", out.String()) - } -} - -func TestRunBuildProducesBinary(t *testing.T) { - dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module m\n\ngo 1.26\n"), 0o644); err != nil { - t.Fatalf("go.mod: %v", err) - } - if err := os.WriteFile(filepath.Join(dir, "main.go"), - []byte("package main\n\nfunc main() {}\n"), 0o644); err != nil { - t.Fatalf("main.go: %v", err) - } - prev, _ := os.Getwd() - t.Cleanup(func() { _ = os.Chdir(prev) }) - if err := os.Chdir(dir); err != nil { - t.Fatalf("chdir: %v", err) - } - var out, errb bytes.Buffer - code := run([]string{"build"}, &out, &errb) - if code != 0 { - t.Fatalf("build failed: code=%d stderr=%q", code, errb.String()) - } - bin := filepath.Join(dir, "bin", filepath.Base(dir)) - if runtime.GOOS == "windows" { - bin += ".exe" - } - if _, err := os.Stat(bin); err != nil { - t.Fatalf("expected binary at %s: %v", bin, err) - } +func TestRun(t *testing.T) { } diff --git a/cmd/gova/run.go b/cmd/gova/run.go index bcc0bc5..93368d6 100644 --- a/cmd/gova/run.go +++ b/cmd/gova/run.go @@ -1,55 +1,66 @@ package main import ( - "flag" + "context" "fmt" - "io" - "os" "os/exec" "path/filepath" - "runtime" - "github.com/nv404/gova/internal/devserver" + "github.com/nv404/gova/internal/config" + "github.com/nv404/gova/internal/utils" + "github.com/urfave/cli/v3" ) -func cmdRun(args []string, stdout, stderr io.Writer) int { - fs := flag.NewFlagSet("run", flag.ContinueOnError) - fs.SetOutput(stderr) - pkg, rest, err := parseFlags("run", fs, args) - if err != nil { - return 2 - } - wd, err := os.Getwd() - if err != nil { - fmt.Fprintln(stderr, err) - return 1 - } - binDir, err := os.MkdirTemp("", "gova-run-") - if err != nil { - fmt.Fprintln(stderr, err) - return 1 - } - defer os.RemoveAll(binDir) - bin := filepath.Join(binDir, "app") - if runtime.GOOS == "windows" { - bin += ".exe" - } - ctx, cancel := signalContext() - defer cancel() - if err := devserver.Build(ctx, wd, pkg, bin); err != nil { - fmt.Fprintln(stderr, err) - return 1 - } - cmd := exec.CommandContext(ctx, bin, rest...) - cmd.Stdout = stdout - cmd.Stderr = stderr - cmd.Stdin = os.Stdin - if err := cmd.Run(); err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - return exitErr.ExitCode() +var runCmd = &cli.Command{ + Name: "run", + Description: "Build and run a gova application", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "mode", + Required: true, + Value: "gova-dev", + Aliases: []string{"m"}, + Config: cli.StringConfig{ + TrimSpace: true, + }, + DefaultText: "-m gova-dev", + Usage: "-m dev", + }, + }, + Action: func(ctx context.Context, c *cli.Command) error { + mode := c.String("mode") + + cnf, err := config.GetConfig() + if err != nil { + return err + } + + if err := buildApplication(ctx, mode, cnf); err != nil { + return err } - fmt.Fprintln(stderr, err) - return 1 + + if _, err := runApplication(ctx, cnf, c.Args().Slice()...); err != nil { + return err + } + + return nil + }, +} + +// runApplication executes the compiled binary for the given mode, injecting +// the mode's environment variables. The binary is expected to exist at +// OutputDir/BinaryName. The process is started and returned immediately +// without waiting for it to exit. +// +// args are passed directly to the binary at runtime, e.g. ["--port", "8080"]. +func runApplication(ctx context.Context, cfg config.Config, args ...string) (*exec.Cmd, error) { + bin := utils.NormalizePath(filepath.Join(cfg.OutputDir, utils.BinaryName(cfg.Name))) + + run := exec.CommandContext(ctx, bin, args...) + + if err := run.Start(); err != nil { + return nil, fmt.Errorf("failed to start %q: %w", bin, err) } - return 0 + + return run, nil } diff --git a/go.mod b/go.mod index 5cc11dd..ce43bf4 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,9 @@ go 1.26.2 require ( fyne.io/fyne/v2 v2.7.3 github.com/fsnotify/fsnotify v1.9.0 + github.com/go-playground/validator/v10 v10.30.2 + github.com/hashicorp/go-envparse v0.1.0 + github.com/urfave/cli/v3 v3.8.0 ) require ( @@ -16,8 +19,11 @@ require ( github.com/fyne-io/glfw-js v0.3.0 // indirect github.com/fyne-io/image v0.1.1 // indirect github.com/fyne-io/oksvg v0.2.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-text/render v0.2.0 // indirect github.com/go-text/typesetting v0.3.3 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect @@ -26,6 +32,7 @@ require ( github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect github.com/kr/text v0.2.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect @@ -34,9 +41,10 @@ require ( github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect github.com/stretchr/testify v1.11.1 // indirect github.com/yuin/goldmark v1.7.8 // indirect + golang.org/x/crypto v0.49.0 // indirect golang.org/x/image v0.24.0 // indirect - golang.org/x/net v0.35.0 // indirect - golang.org/x/sys v0.30.0 // indirect - golang.org/x/text v0.22.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 7988b55..5f97e44 100644 --- a/go.sum +++ b/go.sum @@ -21,10 +21,20 @@ github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA= github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM= github.com/fyne-io/oksvg v0.2.0 h1:mxcGU2dx6nwjJsSA9PCYZDuoAcsZ/OuJlvg/Q9Njfo8= github.com/fyne-io/oksvg v0.2.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA= github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ= +github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc= github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc= github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU= github.com/go-text/typesetting v0.3.3 h1:ihGNJU9KzdK2QRDy1Bm7FT5RFQoYb+3n3EIhI/4eaQc= @@ -39,12 +49,16 @@ github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQb github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0= github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8= github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio= +github.com/hashicorp/go-envparse v0.1.0 h1:bE++6bhIsNCPLvgDZkYqo3nA+/PFI51pkrHdmPSDFPY= +github.com/hashicorp/go-envparse v0.1.0/go.mod h1:OHheN1GoygLlAkTlXLXvAdnXdZxy8JUweQ1rAXx1xnc= github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE= github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o= github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M= github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk= @@ -63,16 +77,20 @@ github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqd github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/urfave/cli/v3 v3.8.0 h1:XqKPrm0q4P0q5JpoclYoCAv0/MIvH/jZ2umzuf8pNTI= +github.com/urfave/cli/v3 v3.8.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= -golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= -golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..aadfb01 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,184 @@ +// Package config provides a convenient way to manage creation and parsing of +// config file (gova.json) while handling CLI commands +package config + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/go-playground/validator/v10" +) + +var validate = validator.New() + +// Config is the top-level configuration for a Gova project, +// loaded from gova.json in the project root. +type Config struct { + // Version is the schema version of this config file. + // Used to handle breaking changes across Gova releases. + Version string `json:"version" validate:"required"` + + // Name is the binary name used in the build process. + Name string `json:"name" validate:"required,alphanum"` + + // Package is the Go package path to build, e.g. "./cmd/app". + // Passed directly to `go build` as the target package. + Package string `json:"package" validate:"required"` + + // Debounce is the duration to wait after a file change before + // triggering a rebuild. Accepts human-readable strings like "300ms" or "1s". + Debounce Duration `json:"debounce"` + + // OutputDir is the root directory where compiled binaries are placed. + // Each mode outputs to OutputDir/. Defaults to "./bin". + OutputDir string `json:"output_dir" validate:"required"` + + // Ignore is a list of paths or glob patterns to exclude from file watching. + // e.g. ["./vendor", "./testdata", "**/*.pb.go"] + Ignore []string `json:"ignore"` + + // Modes is a map of named build configurations. + // The key is the mode name, referenced via `gova build --mode `. + Modes map[string]BuildMode `json:"modes" validate:"required,gt=0,dive"` +} + +// BuildMode defines the build configuration for a specific mode, +// such as "dev", "staging", or "prod". +type BuildMode struct { + // BuildFlags is a list of flags passed directly to `go build`. + // e.g. ["-race", "-tags=dev"] or ["-ldflags=-s -w"] + // Do not include -o; the output path is controlled by Gova via OutputDir. + BuildFlags []string `json:"build_flags" validate:"dive,startswith=-"` + + // Env is a list of environment variables injected into the build process, + // in KEY=VALUE format. These are merged with EnvFiles, with Env taking precedence. + Env []string `json:"env" validate:"dive,contains=="` + + // EnvFiles is a list of .env files to load and inject into the build process. + // Files are merged in order, with later files taking precedence. + // e.g. [".env", ".env.local"] + EnvFiles []string `json:"env_files" validate:"dive,endswith=.env"` +} + +// Duration wraps time.Duration to support JSON unmarshaling from +// human-readable strings like "300ms", "1s", or "2m". +// The standard time.Duration type only unmarshals from nanosecond integers. +type Duration struct { + time.Duration +} + +// UnmarshalJSON parses a quoted duration string (e.g. "500ms") into a Duration. +func (d *Duration) UnmarshalJSON(b []byte) error { + var s string + if err := json.Unmarshal(b, &s); err != nil { + return fmt.Errorf("duration must be a string, e.g. \"300ms\": %w", err) + } + dur, err := time.ParseDuration(s) + if err != nil { + return fmt.Errorf("invalid duration %q: %w", s, err) + } + d.Duration = dur + return nil +} + +// MarshalJSON serializes the Duration back to a human-readable string. +func (d Duration) MarshalJSON() ([]byte, error) { + return json.Marshal(d.String()) +} + +// getConfigFilePath returns the absolute path to gova.json in the current +// working directory. +func getConfigFilePath() (string, error) { + cwd, err := os.Getwd() + if err != nil { + return "", err + } + return filepath.Join(cwd, "gova.json"), nil +} + +// parseConfig unmarshals raw JSON bytes into a Config and validates all fields. +// Returns a ValidationErrors if any field constraints are violated. +func parseConfig(data []byte) (Config, error) { + config := new(Config) + if err := json.Unmarshal(data, config); err != nil { + return Config{}, fmt.Errorf("failed to parse gova.json: %w", err) + } + + // Inject gova-dev mode for default + + config.Modes["gova-dev"] = BuildMode{ + BuildFlags: []string{}, + Env: []string{"GOVA_DEV=1"}, + EnvFiles: []string{}, + } + + if err := validate.Struct(config); err != nil { + return Config{}, formatValidationError(err) + } + return *config, nil +} + +// GetConfig reads and parses gova.json from the current working directory, +// returning a validated Config or an error if the file is missing or invalid. +func GetConfig() (Config, error) { + path, err := getConfigFilePath() + if err != nil { + return Config{}, err + } + data, err := os.ReadFile(path) + if err != nil { + return Config{}, fmt.Errorf("could not read gova.json: %w", err) + } + return parseConfig(data) +} + +// SetConfig writes the given Config to gova.json in the current working directory. +// If override is false and the file already exists, an error is returned. +func SetConfig(config Config, override bool) error { + path, err := getConfigFilePath() + if err != nil { + return err + } + + if fileExists(path) && !override { + return errors.New("gova.json already exists, pass override to overwrite") + } + + if err = validate.Struct(config); err != nil { + return formatValidationError(err) + } + + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return fmt.Errorf("failed to serialize config: %w", err) + } + + // TODO: Need a create and swap to avoid corruption + return os.WriteFile(path, data, 0o644) +} + +// formatValidationError converts validator.ValidationErrors into a human-readable +// error message listing each field and the constraint that was violated. +func formatValidationError(err error) error { + var ve validator.ValidationErrors + if !errors.As(err, &ve) { + return err + } + + msgs := make([]string, 0, len(ve)) + for _, fe := range ve { + msgs = append(msgs, fmt.Sprintf(" - %s: failed %q constraint", fe.Field(), fe.Tag())) + } + + return fmt.Errorf("invalid config:\n%s", strings.Join(msgs, "\n")) +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return !errors.Is(err, os.ErrNotExist) +} diff --git a/internal/devserver/builder.go b/internal/devserver/builder.go deleted file mode 100644 index 3759c78..0000000 --- a/internal/devserver/builder.go +++ /dev/null @@ -1,41 +0,0 @@ -package devserver - -import ( - "bytes" - "context" - "fmt" - "os/exec" -) - -// Build runs `go build -o out pkg` from the given working directory. -// Returns stderr output on failure so the caller can display it verbatim. -type BuildError struct { - Stderr string - Err error -} - -func (e *BuildError) Error() string { - if e.Stderr == "" { - return fmt.Sprintf("gova: build failed: %v", e.Err) - } - return fmt.Sprintf("gova: build failed: %v\n%s", e.Err, e.Stderr) -} - -func (e *BuildError) Unwrap() error { return e.Err } - -// Build compiles pkg (a Go package path, e.g. "." or "./cmd/app") to out. -// It uses `go build` in the provided working directory. Context cancellation -// kills the build. -func Build(ctx context.Context, workDir, pkg, out string) error { - if pkg == "" { - pkg = "." - } - var stderr bytes.Buffer - cmd := exec.CommandContext(ctx, "go", "build", "-o", out, pkg) - cmd.Dir = workDir - cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - return &BuildError{Stderr: stderr.String(), Err: err} - } - return nil -} diff --git a/internal/devserver/builder_test.go b/internal/devserver/builder_test.go deleted file mode 100644 index 6aae623..0000000 --- a/internal/devserver/builder_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package devserver - -import ( - "context" - "errors" - "os" - "path/filepath" - "runtime" - "strings" - "testing" -) - -func writeModule(t *testing.T, dir, mainBody string) { - t.Helper() - if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module m\n\ngo 1.26\n"), 0o644); err != nil { - t.Fatalf("go.mod: %v", err) - } - if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte(mainBody), 0o644); err != nil { - t.Fatalf("main.go: %v", err) - } -} - -func TestBuildSuccess(t *testing.T) { - dir := t.TempDir() - writeModule(t, dir, "package main\n\nfunc main() { println(\"ok\") }\n") - out := filepath.Join(dir, "bin") - if runtime.GOOS == "windows" { - out += ".exe" - } - if err := Build(context.Background(), dir, ".", out); err != nil { - t.Fatalf("Build: %v", err) - } - if _, err := os.Stat(out); err != nil { - t.Fatalf("output missing: %v", err) - } -} - -func TestBuildReportsCompileError(t *testing.T) { - dir := t.TempDir() - writeModule(t, dir, "package main\n\nfunc main() { nope }\n") - out := filepath.Join(dir, "bin") - err := Build(context.Background(), dir, ".", out) - if err == nil { - t.Fatal("expected build error") - } - var be *BuildError - if !errors.As(err, &be) { - t.Fatalf("expected *BuildError, got %T", err) - } - if !strings.Contains(be.Stderr, "undefined: nope") && !strings.Contains(be.Stderr, "nope") { - t.Fatalf("expected compiler message in stderr, got %q", be.Stderr) - } -} diff --git a/internal/devserver/server.go b/internal/devserver/server.go deleted file mode 100644 index 7dc7b07..0000000 --- a/internal/devserver/server.go +++ /dev/null @@ -1,125 +0,0 @@ -package devserver - -import ( - "context" - "fmt" - "io" - "os" - "path/filepath" - "time" -) - -// Options configures Run. -type Options struct { - // WorkDir is the module root. Defaults to the current working directory. - WorkDir string - // Package is the package to build and run (e.g. ".", "./cmd/app"). Defaults to ".". - Package string - // Args are forwarded to the child process after the binary. - Args []string - // Debounce overrides the file-watch debounce window. - Debounce time.Duration - // StateDir is where the child should persist state across restarts. It is - // passed to the child via GOVA_DEV_STATE. Defaults to /.gova/dev. - StateDir string - // Stdout and Stderr are the streams the dev server and child write to. - // Default to the process's os.Stdout/os.Stderr. - Stdout io.Writer - Stderr io.Writer -} - -// Run starts the hot-reload loop and blocks until ctx is cancelled or an -// unrecoverable error is returned. A build failure is NOT unrecoverable; the -// existing child keeps running and the error is printed. -func Run(ctx context.Context, opts Options) error { - if opts.WorkDir == "" { - wd, err := os.Getwd() - if err != nil { - return err - } - opts.WorkDir = wd - } - if opts.Package == "" { - opts.Package = "." - } - stdout := opts.Stdout - if stdout == nil { - stdout = os.Stdout - } - stderr := opts.Stderr - if stderr == nil { - stderr = os.Stderr - } - stateDir := opts.StateDir - if stateDir == "" { - stateDir = filepath.Join(opts.WorkDir, ".gova", "dev") - } - if err := os.MkdirAll(stateDir, 0o755); err != nil { - return fmt.Errorf("gova dev: cannot create state dir: %w", err) - } - - binDir, err := os.MkdirTemp("", "gova-dev-") - if err != nil { - return err - } - defer os.RemoveAll(binDir) - binPath := filepath.Join(binDir, "app") - if filepath.Separator == '\\' { - binPath += ".exe" - } - - fmt.Fprintf(stdout, "gova dev: building %s\n", opts.Package) - if err := Build(ctx, opts.WorkDir, opts.Package, binPath); err != nil { - fmt.Fprintln(stderr, err) - // Keep going; user can fix and save. - } - - sup := &Supervisor{ - Binary: binPath, - Args: opts.Args, - WorkDir: opts.WorkDir, - Env: append(os.Environ(), - "GOVA_DEV=1", - "GOVA_DEV_STATE="+stateDir, - ), - Stdout: stdout, - Stderr: stderr, - } - if _, statErr := os.Stat(binPath); statErr == nil { - if err := sup.Restart(); err != nil { - fmt.Fprintf(stderr, "gova dev: start failed: %v\n", err) - } - } - defer sup.Stop() - - watcher, err := NewWatcher(WatcherOptions{ - Root: opts.WorkDir, - Debounce: opts.Debounce, - }) - if err != nil { - return err - } - defer watcher.Close() - - fmt.Fprintf(stdout, "gova dev: watching %s\n", opts.WorkDir) - for { - select { - case <-ctx.Done(): - return nil - case _, ok := <-watcher.Events(): - if !ok { - return nil - } - fmt.Fprintln(stdout, "gova dev: change detected, rebuilding") - if err := Build(ctx, opts.WorkDir, opts.Package, binPath); err != nil { - fmt.Fprintln(stderr, err) - continue - } - if err := sup.Restart(); err != nil { - fmt.Fprintf(stderr, "gova dev: restart failed: %v\n", err) - continue - } - fmt.Fprintln(stdout, "gova dev: reloaded") - } - } -} diff --git a/internal/devserver/server_test.go b/internal/devserver/server_test.go deleted file mode 100644 index 7c8372b..0000000 --- a/internal/devserver/server_test.go +++ /dev/null @@ -1,113 +0,0 @@ -package devserver - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -// TestRunBuildsAndReloads is the end-to-end happy path: a tiny module that -// prints a version string, Run() watches it, we rewrite the version, and the -// child respawns with the new output. -func TestRunBuildsAndReloads(t *testing.T) { - dir := t.TempDir() - writeModule(t, dir, `package main - -import ( - "fmt" - "os/signal" - "syscall" -) - -const version = "v1" - -func main() { - fmt.Println("started:" + version) - ch := make(chan struct{}) - go func() { - c := make(chan any, 1) - _ = c - _ = signal.Ignore - _ = syscall.SIGTERM - }() - // Stay alive until killed. - sig := make(chan any) - go func() { <-sig }() - <-ch -} -`) - // Replace with a version that handles SIGTERM cleanly. - writeModule(t, dir, `package main - -import ( - "fmt" - "os" - "os/signal" - "syscall" -) - -const version = "v1" - -func main() { - fmt.Println("started:" + version) - ch := make(chan os.Signal, 1) - signal.Notify(ch, syscall.SIGTERM, syscall.SIGINT) - <-ch -} -`) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - out := &safeBuf{} - errb := &safeBuf{} - done := make(chan error, 1) - go func() { - done <- Run(ctx, Options{ - WorkDir: dir, - Package: ".", - Debounce: 80 * time.Millisecond, - Stdout: out, - Stderr: errb, - }) - }() - - waitFor(t, func() bool { return strings.Contains(out.String(), "started:v1") }, 10*time.Second) - - // Rewrite the source; expect v2. - src := `package main - -import ( - "fmt" - "os" - "os/signal" - "syscall" -) - -const version = "v2" - -func main() { - fmt.Println("started:" + version) - ch := make(chan os.Signal, 1) - signal.Notify(ch, syscall.SIGTERM, syscall.SIGINT) - <-ch -} -` - if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte(src), 0o644); err != nil { - t.Fatalf("rewrite: %v", err) - } - waitFor(t, func() bool { return strings.Contains(out.String(), "started:v2") }, 15*time.Second) - - cancel() - select { - case err := <-done: - if err != nil { - t.Fatalf("Run returned: %v", err) - } - case <-time.After(5 * time.Second): - t.Fatal("Run did not return after cancel") - } -} diff --git a/internal/devserver/stat.go b/internal/devserver/stat.go deleted file mode 100644 index 4f28096..0000000 --- a/internal/devserver/stat.go +++ /dev/null @@ -1,7 +0,0 @@ -package devserver - -import "os" - -// statDir is a thin shim so tests can stub filesystem inspection if needed. -// Kept in its own file to keep watcher.go focused on logic. -func statDir(path string) (os.FileInfo, error) { return os.Stat(path) } diff --git a/internal/devserver/supervisor.go b/internal/devserver/supervisor.go deleted file mode 100644 index 91c6f0f..0000000 --- a/internal/devserver/supervisor.go +++ /dev/null @@ -1,111 +0,0 @@ -package devserver - -import ( - "errors" - "io" - "os" - "os/exec" - "sync" - "syscall" - "time" -) - -// Supervisor manages a single long-running child process, replacing it on -// demand. Restart is safe to call from any goroutine. -type Supervisor struct { - // Binary is the executable path to run on each Start/Restart. - Binary string - // Args are passed after the binary. - Args []string - // Env is the child's environment. Nil inherits from the parent. - Env []string - // WorkDir sets the child's working directory. - WorkDir string - // Stdout and Stderr are the pipes the child writes to. Defaults to the - // parent's os.Stdout/os.Stderr when nil. - Stdout io.Writer - Stderr io.Writer - // StopTimeout is how long to wait after SIGTERM before SIGKILL. - // Defaults to 2s. - StopTimeout time.Duration - - mu sync.Mutex - cmd *exec.Cmd -} - -// Restart stops the current child (if any) and starts a fresh one. Returns -// the start error if the new process cannot be spawned. -func (s *Supervisor) Restart() error { - s.mu.Lock() - defer s.mu.Unlock() - s.stopLocked() - return s.startLocked() -} - -// Stop terminates the child and does not restart it. -func (s *Supervisor) Stop() { - s.mu.Lock() - defer s.mu.Unlock() - s.stopLocked() -} - -// Running reports whether a child process is currently alive. -func (s *Supervisor) Running() bool { - s.mu.Lock() - defer s.mu.Unlock() - return s.cmd != nil && s.cmd.Process != nil -} - -func (s *Supervisor) startLocked() error { - if s.Binary == "" { - return errors.New("supervisor: Binary not set") - } - cmd := exec.Command(s.Binary, s.Args...) - cmd.Dir = s.WorkDir - if s.Env != nil { - cmd.Env = s.Env - } - if s.Stdout != nil { - cmd.Stdout = s.Stdout - } else { - cmd.Stdout = os.Stdout - } - if s.Stderr != nil { - cmd.Stderr = s.Stderr - } else { - cmd.Stderr = os.Stderr - } - if err := cmd.Start(); err != nil { - return err - } - s.cmd = cmd - go func(c *exec.Cmd) { - _ = c.Wait() - }(cmd) - return nil -} - -func (s *Supervisor) stopLocked() { - if s.cmd == nil || s.cmd.Process == nil { - return - } - timeout := s.StopTimeout - if timeout <= 0 { - timeout = 2 * time.Second - } - _ = s.cmd.Process.Signal(syscall.SIGTERM) - - done := make(chan struct{}) - cmd := s.cmd - go func() { - _, _ = cmd.Process.Wait() - close(done) - }() - select { - case <-done: - case <-time.After(timeout): - _ = cmd.Process.Kill() - <-done - } - s.cmd = nil -} diff --git a/internal/devserver/supervisor_test.go b/internal/devserver/supervisor_test.go deleted file mode 100644 index 324c9d2..0000000 --- a/internal/devserver/supervisor_test.go +++ /dev/null @@ -1,165 +0,0 @@ -package devserver - -import ( - "bytes" - "context" - "os" - "path/filepath" - "runtime" - "sync" - "testing" - "time" -) - -// buildHelper compiles a short Go program and returns the binary path. The -// binary sleeps until SIGTERM and writes "alive\n" then "bye\n". -func buildHelper(t *testing.T) string { - t.Helper() - dir := t.TempDir() - src := `package main - -import ( - "fmt" - "os" - "os/signal" - "syscall" -) - -func main() { - fmt.Println("alive:" + os.Getenv("TAG")) - ch := make(chan os.Signal, 1) - signal.Notify(ch, syscall.SIGTERM, syscall.SIGINT) - <-ch - fmt.Println("bye") -} -` - writeModule(t, dir, src) - bin := filepath.Join(dir, "app") - if runtime.GOOS == "windows" { - bin += ".exe" - } - if err := Build(context.Background(), dir, ".", bin); err != nil { - t.Fatalf("helper build: %v", err) - } - return bin -} - -// safeBuf is a bytes.Buffer guarded by a mutex so concurrent reads/writes -// from the test and the supervised child do not race. -type safeBuf struct { - mu sync.Mutex - buf bytes.Buffer -} - -func (b *safeBuf) Write(p []byte) (int, error) { - b.mu.Lock() - defer b.mu.Unlock() - return b.buf.Write(p) -} - -func (b *safeBuf) String() string { - b.mu.Lock() - defer b.mu.Unlock() - return b.buf.String() -} - -func TestSupervisorRestartSpawnsFreshChild(t *testing.T) { - bin := buildHelper(t) - - out := &safeBuf{} - sup := &Supervisor{ - Binary: bin, - Stdout: out, - Stderr: out, - Env: append(os.Environ(), "TAG=1"), - } - if err := sup.Restart(); err != nil { - t.Fatalf("first start: %v", err) - } - waitFor(t, func() bool { return contains(out.String(), "alive:1") }, time.Second) - - sup.Env = append(os.Environ(), "TAG=2") - if err := sup.Restart(); err != nil { - t.Fatalf("second start: %v", err) - } - waitFor(t, func() bool { return contains(out.String(), "alive:2") }, time.Second) - - sup.Stop() - if sup.Running() { - t.Fatal("expected supervisor to be stopped") - } -} - -func TestSupervisorStopIsIdempotent(t *testing.T) { - bin := buildHelper(t) - sup := &Supervisor{Binary: bin, Stdout: &safeBuf{}, Stderr: &safeBuf{}} - if err := sup.Restart(); err != nil { - t.Fatalf("start: %v", err) - } - sup.Stop() - sup.Stop() // must not panic or deadlock -} - -func TestSupervisorStopKillsUnresponsiveChild(t *testing.T) { - // Helper that ignores SIGTERM. - dir := t.TempDir() - src := `package main - -import ( - "os/signal" - "syscall" - "time" -) - -func main() { - ch := make(chan struct{}) - signal.Ignore(syscall.SIGTERM) - _ = ch - time.Sleep(time.Minute) -} -` - writeModule(t, dir, src) - bin := filepath.Join(dir, "app") - if runtime.GOOS == "windows" { - bin += ".exe" - } - if err := Build(context.Background(), dir, ".", bin); err != nil { - t.Fatalf("build: %v", err) - } - sup := &Supervisor{ - Binary: bin, - Stdout: &safeBuf{}, - Stderr: &safeBuf{}, - StopTimeout: 200 * time.Millisecond, - } - if err := sup.Restart(); err != nil { - t.Fatalf("start: %v", err) - } - start := time.Now() - sup.Stop() - elapsed := time.Since(start) - if elapsed > 2*time.Second { - t.Fatalf("stop took too long: %v", elapsed) - } -} - -func waitFor(t *testing.T, cond func() bool, d time.Duration) { - t.Helper() - deadline := time.Now().Add(d) - for time.Now().Before(deadline) { - if cond() { - return - } - time.Sleep(10 * time.Millisecond) - } - t.Fatalf("condition not met within %v", d) -} - -func contains(haystack, needle string) bool { - for i := 0; i+len(needle) <= len(haystack); i++ { - if haystack[i:i+len(needle)] == needle { - return true - } - } - return false -} diff --git a/internal/devserver/watcher.go b/internal/devserver/watcher.go deleted file mode 100644 index e7e1d26..0000000 --- a/internal/devserver/watcher.go +++ /dev/null @@ -1,150 +0,0 @@ -package devserver - -import ( - "io/fs" - "path/filepath" - "strings" - "sync" - "time" - - "github.com/fsnotify/fsnotify" -) - -// Watcher recursively watches a directory tree for .go file changes and emits -// debounced notifications on Events. Close() stops the watcher and closes the -// Events channel. -type Watcher struct { - fsw *fsnotify.Watcher - events chan struct{} - debounce time.Duration - root string - - mu sync.Mutex - closed bool - closeCh chan struct{} -} - -// WatcherOptions configures NewWatcher. -type WatcherOptions struct { - Root string // directory to watch (recursively) - Debounce time.Duration // coalesce events within this window; defaults to 200ms -} - -// NewWatcher starts a watcher on opts.Root. Events is buffered size 1; callers -// that lag will coalesce further (no missed reloads, just batched). -func NewWatcher(opts WatcherOptions) (*Watcher, error) { - if opts.Debounce <= 0 { - opts.Debounce = 200 * time.Millisecond - } - fsw, err := fsnotify.NewWatcher() - if err != nil { - return nil, err - } - w := &Watcher{ - fsw: fsw, - events: make(chan struct{}, 1), - debounce: opts.Debounce, - root: opts.Root, - closeCh: make(chan struct{}), - } - if err := w.addRecursive(opts.Root); err != nil { - fsw.Close() - return nil, err - } - go w.loop() - return w, nil -} - -// Events returns a receive-only channel that fires once per debounced change. -func (w *Watcher) Events() <-chan struct{} { return w.events } - -// Close stops the watcher. Safe to call multiple times. -func (w *Watcher) Close() error { - w.mu.Lock() - if w.closed { - w.mu.Unlock() - return nil - } - w.closed = true - close(w.closeCh) - w.mu.Unlock() - return w.fsw.Close() -} - -func (w *Watcher) addRecursive(root string) error { - return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return nil - } - if !d.IsDir() { - return nil - } - name := d.Name() - if name == ".git" || name == "node_modules" || name == "vendor" { - return filepath.SkipDir - } - return w.fsw.Add(path) - }) -} - -// shouldFire returns true for events we care about: writes/creates/removes on .go files. -// Newly created directories are added to the watch set as a side effect. -func (w *Watcher) shouldFire(ev fsnotify.Event) bool { - if ev.Has(fsnotify.Chmod) { - return false - } - if ev.Has(fsnotify.Create) { - if info, err := statDir(ev.Name); err == nil && info.IsDir() { - _ = w.addRecursive(ev.Name) - return false - } - } - if !strings.HasSuffix(ev.Name, ".go") { - return false - } - if strings.HasSuffix(ev.Name, "_test.go") { - return false - } - return true -} - -func (w *Watcher) loop() { - var timer *time.Timer - fire := func() { - select { - case w.events <- struct{}{}: - default: - // Already pending; drop: the consumer will rebuild once - // and see the latest tree. - } - } - for { - select { - case <-w.closeCh: - if timer != nil { - timer.Stop() - } - close(w.events) - return - case ev, ok := <-w.fsw.Events: - if !ok { - close(w.events) - return - } - if !w.shouldFire(ev) { - continue - } - if timer == nil { - timer = time.AfterFunc(w.debounce, fire) - continue - } - timer.Reset(w.debounce) - case _, ok := <-w.fsw.Errors: - if !ok { - close(w.events) - return - } - // Non-fatal; keep watching. - } - } -} diff --git a/internal/devserver/watcher_test.go b/internal/devserver/watcher_test.go deleted file mode 100644 index 9fda066..0000000 --- a/internal/devserver/watcher_test.go +++ /dev/null @@ -1,159 +0,0 @@ -package devserver - -import ( - "os" - "path/filepath" - "testing" - "time" -) - -// waitEvent blocks for an event with a deadline. Returns true if one arrived. -func waitEvent(t *testing.T, ch <-chan struct{}, d time.Duration) bool { - t.Helper() - select { - case <-ch: - return true - case <-time.After(d): - return false - } -} - -// warmupWatcher waits briefly for the fsnotify backend to finish registering -// its subscription. On slower CI hosts — particularly the macOS GitHub -// Actions runners — the first write can race the kqueue add and get dropped. -const watcherWarmup = 150 * time.Millisecond - -// eventWait is the timeout for positive-expectation assertions. Chosen to be -// generous enough for overloaded CI without making failing tests sluggish. -const eventWait = 3 * time.Second - -func TestWatcherFiresOnGoFileWrite(t *testing.T) { - dir := t.TempDir() - w, err := NewWatcher(WatcherOptions{Root: dir, Debounce: 30 * time.Millisecond}) - if err != nil { - t.Fatalf("NewWatcher: %v", err) - } - defer w.Close() - time.Sleep(watcherWarmup) - - // Writing a fresh .go file in the watched dir fires a Create, which is - // delivered reliably on both inotify and kqueue. Seed-then-modify races - // with kqueue's file-level watch registration on macOS CI. - if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package x\n"), 0o644); err != nil { - t.Fatalf("write: %v", err) - } - if !waitEvent(t, w.Events(), eventWait) { - t.Fatal("expected debounced event after .go write") - } -} - -func TestWatcherIgnoresNonGoFiles(t *testing.T) { - dir := t.TempDir() - w, err := NewWatcher(WatcherOptions{Root: dir, Debounce: 30 * time.Millisecond}) - if err != nil { - t.Fatalf("NewWatcher: %v", err) - } - defer w.Close() - - if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil { - t.Fatalf("write: %v", err) - } - if waitEvent(t, w.Events(), 150*time.Millisecond) { - t.Fatal("expected no event for non-.go file") - } -} - -func TestWatcherIgnoresTestFiles(t *testing.T) { - dir := t.TempDir() - w, err := NewWatcher(WatcherOptions{Root: dir, Debounce: 30 * time.Millisecond}) - if err != nil { - t.Fatalf("NewWatcher: %v", err) - } - defer w.Close() - - if err := os.WriteFile(filepath.Join(dir, "foo_test.go"), []byte("package x"), 0o644); err != nil { - t.Fatalf("write: %v", err) - } - if waitEvent(t, w.Events(), 150*time.Millisecond) { - t.Fatal("expected no event for _test.go file") - } -} - -func TestWatcherDebouncesBurst(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "main.go") - w, err := NewWatcher(WatcherOptions{Root: dir, Debounce: 100 * time.Millisecond}) - if err != nil { - t.Fatalf("NewWatcher: %v", err) - } - defer w.Close() - time.Sleep(watcherWarmup) - - // First write creates the file (Create event fires everywhere); the - // subsequent writes keep the debounce timer alive. Either way, exactly - // one coalesced event must arrive. - for i := 0; i < 5; i++ { - if err := os.WriteFile(path, []byte("package x\n"), 0o644); err != nil { - t.Fatalf("write %d: %v", i, err) - } - time.Sleep(10 * time.Millisecond) - } - if !waitEvent(t, w.Events(), eventWait) { - t.Fatal("expected one event after burst") - } - // No second event should fire from the same burst. - if waitEvent(t, w.Events(), 250*time.Millisecond) { - t.Fatal("expected only one coalesced event") - } -} - -func TestWatcherPicksUpNewDirectory(t *testing.T) { - dir := t.TempDir() - w, err := NewWatcher(WatcherOptions{Root: dir, Debounce: 30 * time.Millisecond}) - if err != nil { - t.Fatalf("NewWatcher: %v", err) - } - defer w.Close() - time.Sleep(watcherWarmup) - - sub := filepath.Join(dir, "pkg") - if err := os.Mkdir(sub, 0o755); err != nil { - t.Fatalf("mkdir: %v", err) - } - // Give the watcher time to register the new subdirectory, and drain any - // directory-create signal that slipped through (it should not, since only - // .go writes fire events, but be resilient). - time.Sleep(watcherWarmup) - _ = waitEvent(t, w.Events(), 50*time.Millisecond) - - if err := os.WriteFile(filepath.Join(sub, "a.go"), []byte("package pkg"), 0o644); err != nil { - t.Fatalf("write: %v", err) - } - if !waitEvent(t, w.Events(), eventWait) { - t.Fatal("expected event after writing .go in new subdirectory") - } -} - -func TestWatcherClose(t *testing.T) { - dir := t.TempDir() - w, err := NewWatcher(WatcherOptions{Root: dir}) - if err != nil { - t.Fatalf("NewWatcher: %v", err) - } - if err := w.Close(); err != nil { - t.Fatalf("Close: %v", err) - } - // Events channel must close. - select { - case _, ok := <-w.Events(): - if ok { - t.Fatal("Events must be closed after Close") - } - case <-time.After(500 * time.Millisecond): - t.Fatal("Events channel did not close") - } - // Double close must not panic or error. - if err := w.Close(); err != nil { - t.Fatalf("double close: %v", err) - } -} diff --git a/internal/utils/utils.go b/internal/utils/utils.go new file mode 100644 index 0000000..d4bfbb2 --- /dev/null +++ b/internal/utils/utils.go @@ -0,0 +1,134 @@ +// Package utils provides shared helper utilities used across Gova's internal +// packages. It covers file system checks, debouncing, environment variable +// resolution, path normalization, and OS-aware binary naming. +package utils + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/hashicorp/go-envparse" + "github.com/nv404/gova/internal/config" +) + +// FileExists reports whether a file or directory exists at the given path. +func FileExists(path string) bool { + _, err := os.Stat(path) + return !errors.Is(err, os.ErrNotExist) +} + +// Debounce returns a debounced version of fn that delays execution by the +// given duration. If the returned function is called again before the delay +// elapses, the timer resets. Only the last call within a burst will invoke fn. +// +// The returned function is safe to call from multiple goroutines. +func Debounce(fn func(), delay time.Duration) func() { + var timer *time.Timer + ch := make(chan struct{}, 1) + + go func() { + for range ch { + if timer != nil { + timer.Stop() + } + timer = time.AfterFunc(delay, fn) + } + }() + + return func() { + select { + case ch <- struct{}{}: + default: + } + } +} + +// ResolveEnv builds the final environment variable slice for a given BuildMode. +// It starts with the current process environment as a base, then layers +// EnvFiles in order, and finally applies inline Env values which take +// the highest precedence. +// +// The resulting slice is in "KEY=VALUE" format, compatible with exec.Cmd.Env. +func ResolveEnv(m config.BuildMode) ([]string, error) { + env := os.Environ() + + for _, file := range m.EnvFiles { + envs, err := ParseEnvFile(NormalizePath(file)) + if err != nil { + return nil, fmt.Errorf("failed to parse env file %q: %w", file, err) + } + env = append(env, envs...) + } + + env = append(env, m.Env...) + + return env, nil +} + +// ParseEnvFile reads and parses a .env file at the given path, returning +// the key-value pairs as a slice of "KEY=VALUE" strings compatible with +// exec.Cmd.Env. Parsing is handled by go-envparse, which supports quoted +// values, comments, and blank lines. +func ParseEnvFile(name string) ([]string, error) { + file, err := os.Open(name) + if err != nil { + return nil, fmt.Errorf("could not open env file: %w", err) + } + defer file.Close() + + parsed, err := envparse.Parse(file) + if err != nil { + return nil, fmt.Errorf("could not parse env file: %w", err) + } + + envs := make([]string, 0, len(parsed)) + for key, value := range parsed { + envs = append(envs, key+"="+value) + } + + return envs, nil +} + +// SanitizeBuildFlags removes any -o flag from user-provided build flags. +// This prevents the user from overriding the output path that Gova computes +// from OutputDir and BinaryName. Both forms are handled: +// - "-o", "./path" (two separate arguments) +// - "-o=./path" (single argument with equals sign) +func SanitizeBuildFlags(flags []string) []string { + result := make([]string, 0, len(flags)) + for i := 0; i < len(flags); i++ { + if flags[i] == "-o" { + i++ + continue + } + if strings.HasPrefix(flags[i], "-o=") { + continue + } + result = append(result, flags[i]) + } + return result +} + +// NormalizePath converts any user-provided path to the current OS path format, +// handling both forward slashes (POSIX) and backslashes (Windows) as input. +// filepath.Clean is also applied to resolve any redundant separators or dots. +// +// This ensures paths from gova.json work correctly regardless of which OS +// authored the config file. +func NormalizePath(path string) string { + return filepath.Clean(filepath.FromSlash(path)) +} + +// BinaryName appends .exe to the binary name when running on Windows. +// On all other platforms the name is returned unchanged. +func BinaryName(name string) string { + if runtime.GOOS == "windows" { + return name + ".exe" + } + return name +} diff --git a/internal/utils/utils_test.go b/internal/utils/utils_test.go new file mode 100644 index 0000000..d4b585b --- /dev/null +++ b/internal/utils/utils_test.go @@ -0,0 +1 @@ +package utils