gorun
File watching run utilities for go and general use.
| Tool |
Package |
Description |
gorun |
pkg/gorun |
Drop-in go run replacement with auto-rebuild on file changes, can also have config file |
execrun |
pkg/execrun |
Generic, language-agnostic file-watching command runner (YAML config) |
runctl |
pkg/runctl |
Multi-target orchestrator — manage multiple gorun/execrun targets with HTTP API |
runui |
pkg/runui |
runctl + embedded web dashboard for monitoring and control |
All tools use content-based change detection (SHA-256 hashing) with polling and fsnotify.
All rebuilding and watching is based on glob patterns.
Install
# gorun — Go auto-rebuild
go install github.com/gur-shatz/go-run@latest
# execrun — generic file watcher
go install github.com/gur-shatz/go-run/cmd/execrun@latest
# runctl — multi-target orchestrator (API only)
go install github.com/gur-shatz/go-run/cmd/runctl@latest
# runui — multi-target orchestrator with web dashboard
go install github.com/gur-shatz/go-run/cmd/runui@latest
Or from source:
make install
gorun
Use directly from the CLI or with a config file:
# CLI — no config needed
gorun ./cmd/server -port 8080
# Config — run with configuration from gorun.yaml
gorun
Both modes build to a temp binary, watch for changes (default: **/*.go, go.mod, go.sum), and auto-rebuild on change.
Config File
gorun.yaml (gorun init generates a starter):
watch:
- "**/*.go"
- "go.mod"
- "go.sum"
- "!vendor/**"
args: "./cmd/server -port 8080"
exec:
- "go generate ./..."
| Field |
Required |
Description |
watch |
no |
Glob patterns for files to watch (defaults to **/*.go, go.mod, go.sum) |
args |
yes |
Build flags + target + app args, parsed like go run arguments |
exec |
no |
Commands to run before go build |
Usage
gorun [flags] [--] [go-build-flags] <target> [app-args...]
gorun [-c <file>] Load config and run
gorun [-c <file>] init Generate a default config
gorun [-c <file>] sum Snapshot file hashes
Flags
| Flag |
Default |
Description |
-c, --config <file> |
gorun.yaml |
Config file path |
--poll <duration> |
500ms |
Poll interval for file changes |
--debounce <duration> |
300ms |
Nagle debounce window |
--stdout <file> |
|
Redirect child stdout to file (append mode) |
--stderr <file> |
|
Redirect child stderr to file (append mode) |
-v, --verbose |
false |
Show config, patterns, and file counts |
--stdout/--stderr only redirect the child process output; gorun's own messages still print to the terminal.
Examples
gorun ./cmd/server -port 8080 # CLI args, no config needed
gorun -- -race ./cmd/server -port 8080 # Pass build flags with --
gorun -c myapp.yaml # Use a specific config file
gorun # Load gorun.yaml from current dir
Behavior
- Build failure: previous process keeps running
- Child exits on its own: gorun keeps watching, rebuilds on next change
- Sum files (
gorun.sum) are persisted and updated on each rebuild
Library Usage
import "github.com/gur-shatz/go-run/pkg/gorun"
cfg, _ := gorun.LoadConfig("gorun.yaml")
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
gorun.Run(ctx, *cfg, gorun.Options{Verbose: true})
Subprocess Control
Run gorun as a child process and receive structured events:
cmd := gorun.Command("./cmd/server", "-port", "8080")
cmd.OnEvent = func(event gorun.Event) {
switch event.Type {
case gorun.EventStarted:
log.Printf("started pid=%d build=%dms", event.PID, event.BuildTimeMs)
case gorun.EventRebuilt:
log.Printf("rebuilt pid=%d", event.PID)
case gorun.EventBuildFailed:
log.Printf("build failed: %s", event.Error)
}
}
cmd.Start()
defer cmd.Stop()
cmd.Wait()
execrun
Generic, language-agnostic file-watching command runner. Works with any language or toolchain — configured via a simple YAML file.
Reuses gorun's internals (watcher, hasher, glob, sumfile) with shell commands instead of go build.
Quick Start
# Generate a starter config
execrun init
# Edit execrun.yaml for your project, then run
execrun
Usage
execrun [flags] [command]
execrun init
execrun sum
Flags
| Flag |
Default |
Description |
-c, --config <path> |
execrun.yaml |
Path to config file |
--poll <duration> |
500ms |
Poll interval for file changes |
--debounce <duration> |
300ms |
Debounce window |
--stdout <file> |
|
Redirect child stdout to file (append mode) |
--stderr <file> |
|
Redirect child stderr to file (append mode) |
-v |
false |
Verbose output |
Commands
| Command |
Description |
execrun init |
Generate a starter execrun.yaml |
execrun -c myapp.yaml init |
Generate myapp.yaml |
execrun sum |
Snapshot watched file hashes to execrun.sum |
Config File
execrun.yaml:
# File patterns to watch (gitignore-style globs)
watch:
- "**/*.go"
- "go.mod"
- "go.sum"
# Build commands — preparation steps that run to completion.
build:
- "go build -o ./bin/app ."
# Exec commands — the last command is the long-running managed process.
exec:
- "./bin/app"
Commands are executed via sh -c, so pipes, redirects, and environment variables all work.
| Field |
Required |
Description |
watch |
yes |
Glob patterns for files to watch (gitignore-style, ! for exclusions) |
build |
no |
Preparation commands that run to completion before starting the process |
exec |
no |
Run commands — the last is the managed process. Empty = build-only |
At least one of build or exec must be non-empty.
Examples
Python (no build steps):
watch:
- "**/*.py"
- "requirements.txt"
exec:
- "python app.py"
Node.js with TypeScript:
watch:
- "src/**/*.ts"
- "package.json"
build:
- "npm run build"
exec:
- "node dist/index.js"
Rust:
watch:
- "src/**/*.rs"
- "Cargo.toml"
build:
- "cargo build"
exec:
- "./target/debug/myapp"
Build-only (no managed process):
watch:
- "*.css"
build:
- "mkdir -p dist && cp style.src.css dist/style.css"
- "echo CSS build complete"
Multi-step with code generation:
watch:
- "**/*.go"
- "api/**/*.proto"
- "!**/*.pb.go"
build:
- "protoc --go_out=. api/*.proto"
- "go generate ./..."
- "go build -o ./bin/server ./cmd/server"
exec:
- "./bin/server"
Restart Flow
File change detected
→ Run build steps sequentially (fail → keep old process)
→ Stop old process (SIGTERM → 5s timeout → SIGKILL)
→ Start last exec command as new process
If there are no build steps, the old process is stopped and restarted directly.
If the managed process exits on its own, execrun waits for the next file change to re-run the pipeline.
Library Usage
import "github.com/gur-shatz/go-run/pkg/execrun"
cfg, err := execrun.LoadConfig("execrun.yaml")
if err != nil {
log.Fatal(err)
}
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
err = execrun.Run(ctx, *cfg, execrun.Options{
Verbose: true,
})
runctl / runui
Multi-target orchestrator. Manage multiple gorun and execrun targets from a single runctl.yaml, with an HTTP API for status and control. runui adds an embedded web dashboard on top.
runctl # API only
runui # API + web dashboard at http://localhost:9100
Flags
| Flag |
Default |
Description |
-c, --config |
runctl.yaml |
Config file path |
-v |
false |
Verbose output |
Config File
env:
PORT: "8080"
DB_HOST: "localhost"
api:
port: 9100 # HTTP API port (default: 9100)
logs_dir: /tmp/runctl-logs # Optional: directory for build/run log files
targets:
api:
type: gorun # "gorun" or "execrun" (default: execrun)
config: services/api/gorun.yaml
enabled: true # Start on launch (default: true)
links:
- name: HTTP
url: "http://localhost:$PORT"
frontend:
config: services/frontend/execrun.yaml
| Field |
Required |
Description |
env |
no |
Environment variables available to all targets |
api.port |
no |
HTTP API port (default: 9100) |
logs_dir |
no |
Directory for log files (<target>.build.log/.run.log) |
targets |
yes |
Map of target name to target config |
targets.*.type |
no |
gorun or execrun (default: execrun) |
targets.*.config |
yes |
Path to the target's gorun/execrun YAML config |
targets.*.enabled |
no |
Whether to start on launch (default: true) |
targets.*.links |
no |
Named URLs shown in the dashboard |
The config path is relative to the runctl.yaml directory. The target's working directory is derived from the config path's directory.
Web Dashboard (runui)
The web UI provides two tabs:
- Build — target type, last build duration/timestamp, build count, errors, and a rebuild button
- Run — target state, PID, uptime, restart count, custom links, and start/stop/restart buttons
Each target has a log viewer with virtual scrolling and a real-time tail mode.
HTTP API
GET /api/health Health check
GET /api/targets List all targets
GET /api/targets/{name} Get target status
POST /api/targets/{name}/build Trigger rebuild + restart
POST /api/targets/{name}/start Start target
POST /api/targets/{name}/stop Stop target
POST /api/targets/{name}/restart Stop + rebuild + restart
POST /api/targets/{name}/enable Enable + start
POST /api/targets/{name}/disable Disable + stop
GET /api/targets/{name}/logs Get logs (?stage=build|run&offset=N&limit=M)
Library Usage
import "github.com/gur-shatz/go-run/pkg/runctl"
cfg, err := runctl.LoadConfig("runctl.yaml")
if err != nil {
log.Fatal(err)
}
ctl, err := runctl.New(*cfg, ".")
if err != nil {
log.Fatal(err)
}
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
ctl.Run(ctx)
Watch Patterns
Both gorun and execrun use the same glob pattern syntax (doublestar):
| Pattern |
Matches |
**/*.go |
All .go files recursively |
cmd/**/*.go |
.go files under cmd/ |
*.go |
.go files in root only |
{src,internal}/**/*.go |
.go files under src/ or internal/ |
**/*.{go,mdx,yaml} |
Multiple extensions |
Patterns starting with ! are exclusions:
| Pattern |
Effect |
!**/*.pb.go |
Exclude protobuf generated files |
!vendor/** |
Exclude vendor directory |
Excludes always win. All include patterns are expanded first, then all exclude patterns are removed. You cannot re-include a file that was excluded.
Sum File
The sum file (e.g., gorun.sum, execrun.sum) is a human-readable snapshot of watched files and their SHA-256 hashes:
cmd/server/main.go a1b2c3d
go.mod 9abcdef
internal/handler.go e4f5678
Sum files are derived from the config filename (x.yaml generates x.sum), persisted in the working directory, and updated on each rebuild.
Stdout Protocol
gorun emits structured protocol lines to stdout for consumption by parent services:
[gorun:<event>] <json>
| Event |
Description |
started |
Initial build and run complete |
changed |
File changes detected, before rebuild |
rebuilt |
Successful rebuild and restart |
build_failed |
Build failed, old process kept running |
stopping |
Shutting down |
Use gorun.ScanOutput() or gorun.ParseProtocolLine() to parse these events programmatically.
Environment Variables
Env var expansion in YAML configs
All YAML configs (gorun.yaml, execrun.yaml, runctl.yaml) support $VAR and ${VAR} syntax. Variables are expanded from the OS environment before YAML parsing, so you can use them in any string field:
# execrun.yaml
exec:
- "./bin/server --port $PORT"
Passing env vars to child processes
Child processes (build steps, exec commands, compiled binaries) inherit the full environment of the parent process. There is no filtering or isolation — whatever is in the parent's environment is available to every child.
This means you can pass env vars to targets using any standard method:
# Inline
PORT=8080 gorun ./cmd/server
# Export
export PORT=8080
gorun ./cmd/server
# .env via direnv, dotenv, etc.
runctl/runui env: block
runctl.yaml supports a top-level env: block for defining env vars shared across all targets. These are set into the process environment via os.Setenv before any target configs are loaded:
env:
PORT: "8080"
DB_HOST: "localhost"
targets:
api:
type: gorun
config: api/gorun.yaml
links:
- name: HTTP
url: "http://localhost:$PORT"
Two-pass expansion: The config is parsed twice. First, existing OS env vars are expanded and the env: block is extracted and applied via os.Setenv. Then the full config is re-expanded with the new vars in place. This means:
- Values in
env: can reference pre-existing OS vars (e.g., HOME: "$HOME/app")
- Vars defined in
env: are available everywhere else in runctl.yaml (link URLs, etc.)
- Target configs (
gorun.yaml, execrun.yaml) are loaded after env: is applied, so they see these vars too
- Child processes inherit them automatically
Propagation flow:
OS environment (PATH, HOME, …)
+ runctl.yaml env: block (PORT=8080, DB_HOST=localhost)
→ set via os.Setenv into parent process
→ target configs expand $PORT when loaded
→ child processes inherit PORT=8080 automatically
Note: gorun.yaml and execrun.yaml do not have their own env: block. To define env vars centrally, use the runctl/runui env: block or set them in the shell before running.
Design
- Polling + hashing over fsnotify: simpler, portable, no file descriptor limits on macOS, catches content-only changes
go build to temp binary: clean process lifecycle — can keep the old binary running on build failure
- Nagle debounce: batches rapid IDE saves into a single rebuild without adding latency to single-file changes
- Content-based detection: only rebuilds when file contents actually change, not on metadata updates