go-run

module
v0.1.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Feb 22, 2026 License: MIT

README

go-run

File watching run utilities for general use.

Tool Package Description
execrun pkg/execrun Generic, language-agnostic file-watching command runner (YAML config)
runctl pkg/runctl Multi-target orchestrator — manage multiple execrun targets with HTTP API and optional web dashboard (-ui)

All tools use content-based change detection (SHA-256 hashing) with polling and fsnotify. All rebuilding and watching is based on glob patterns.

Install

# execrun — generic file watcher
go install github.com/gur-shatz/go-run/cmd/execrun@latest

# runctl — multi-target orchestrator (API + optional web dashboard)
go install github.com/gur-shatz/go-run/cmd/runctl@latest

Or from source:

make install

execrun

Generic, language-agnostic file-watching command runner. Works with any language or toolchain — configured via a simple YAML file.

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:

vars:
  LISTEN_ADDR: '0.0.0.0:{{ .PORT | default "8081" }}'

# 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:
  - "echo address is {{ .LISTEN_ADDR }}"
  - "./bin/app"

Commands are executed via sh -c, so pipes, redirects, and environment variables all work.

Field Required Description
vars no Template variables (see Template Variables)
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

Go:

watch:
  - "**/*.go"
  - "go.mod"
  - "go.sum"
build:
  - "go build -o ./bin/server ./cmd/server"
exec:
  - "./bin/server"

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"
    "github.com/gur-shatz/go-run/pkg/config"
)

cfg, vars, err := execrun.LoadConfig("execrun.yaml")
// Or with parent vars:
cfg, vars, err = execrun.LoadConfig("execrun.yaml", config.WithVars(parentVars))

runctl

Multi-target orchestrator. Manage multiple execrun targets from a single runctl.yaml, with an HTTP API for status and control. Use -ui to enable the embedded web dashboard.

runctl                    # Watch all enabled targets (API + watchers)
runctl -ui                # API + web dashboard at http://localhost:9100
runctl -t api             # Watch only the "api" target
runctl build              # Build all enabled targets and exit
runctl -t api build       # Build only "api" and exit
runctl sum                # Write .sum files for all enabled targets
runctl -t api -t web sum  # Write .sum files for "api" and "web" only
Commands
Command Description
init Generate a starter runctl.yaml
build Run build steps for selected targets and exit (no watchers, no HTTP server)
sum Snapshot watched file hashes to .sum files and exit
Flags
Flag Default Description
-c, --config runctl.yaml Config file path
-t <name> Target filter (repeatable). Applies to watch, build, sum
-ui false Serve embedded web dashboard
-v false Verbose output

The -t flag can be specified multiple times to select specific targets. Without -t, all enabled targets are used. An error is returned if a target name doesn't exist in the config.

Config File
vars:
  BASE_PORT: '{{ env "BASE_PORT" | default "8000" }}'
  API_PORT: "{{ add .BASE_PORT 80 }}"
  UI_PORT: "{{ add .BASE_PORT 99 }}"
  DATA_DIR: '{{ env "DATA_DIR" | default "/tmp/runctl-data" }}'

api:
  port: {{ .UI_PORT }}

logs_dir: "{{ .DATA_DIR }}/logs"

targets:
  api:
    config: services/api/execrun.yaml
    vars:
      GREETING: '{{ env "GREETING" | default "Hello!" }}'
    links:
      - name: HTTP
        url: "http://localhost:{{ .API_PORT }}"

  frontend:
    config: services/frontend/execrun.yaml
Field Required Description
vars no Global template variables (see Template Variables)
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.*.config yes Path to the target's execrun YAML config
targets.*.enabled no Whether to start on launch (default: true)
targets.*.vars no Per-target template variables (override global vars)
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.

Resolved vars from runctl.yaml (both global and per-target) are automatically passed down to child execrun configs via config.WithVars(). Per-target vars override global vars of the same key. Child configs can reference parent vars with template syntax (e.g., {{ .API_PORT | default "8080" }}) and add their own vars: section.

Web Dashboard (-ui)

The web UI provides two tabs:

  • Build — 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

All tools 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., 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.

Template Variables

All YAML configs (execrun.yaml, runctl.yaml) support Go template syntax for variable substitution, powered by pkg/config.

vars: Section

Define template variables in a top-level vars: section. Variables can reference environment variables, provide defaults, and depend on each other:

vars:
  BASE_PORT: '{{ env "BASE_PORT" | default "8000" }}'
  API_PORT: "{{ add .BASE_PORT 80 }}"
  DB_HOST: '{{ env "DB_HOST" | default "localhost" }}'

exec:
  - "./bin/server --port {{ .API_PORT }} --db {{ .DB_HOST }}"

The vars: section is removed from the final parsed config — it exists only for template resolution.

Template Syntax

Two delimiter styles are supported (useful when one conflicts with YAML quoting):

Syntax Example
{{ .VAR }} "http://localhost:{{ .API_PORT }}"
[[ .VAR ]] port: [[.API_PORT]]
Template Functions
Function Description Example
default Fallback value if empty/nil {{ .PORT | default "8080" }}
env Read OS environment variable {{ env "HOME" }}
required Error if value is empty/nil {{ .DB_URL | required "DB_URL must be set" }}
add Integer addition {{ add .BASE_PORT 80 }}
int / asInt Cast to integer {{ .PORT | int }}
Resolution

Variables are resolved iteratively (up to 10 passes) to handle dependency chains. For example, API_PORT depends on BASE_PORT — the resolver evaluates BASE_PORT first, then uses its value to resolve API_PORT.

Priority within a single config (highest wins):

  1. Parent vars passed via config.WithVars() (runctl → child configs)
  2. The config's own vars: section

Environment variables are not implicitly injected into template data. To read an env var, use {{ env "VAR" }} explicitly. This gives you full control — a var can read from the environment, provide a default, or ignore the environment entirely.

Variable Propagation (runctl)

When runctl loads child configs, resolved vars from runctl.yaml are passed down automatically. Child configs can reference parent vars and define their own.

Global vars

Defined at the top level of runctl.yaml. Available to all targets and to the runctl config itself (e.g., api.port, logs_dir):

# runctl.yaml
vars:
  BASE_PORT: '{{ env "BASE_PORT" | default "8000" }}'
  HELLO_PORT: "{{ add .BASE_PORT 80 }}"

targets:
  hello:
    config: hello/execrun.yaml
Per-target vars

Defined under targets.<name>.vars. These are resolved after global vars and can reference global vars via template syntax. Per-target vars override global vars of the same key:

vars:
  GREETING: "Hello"                    # global default

targets:
  hello:
    config: hello/execrun.yaml
    vars:
      GREETING: "Hello from hello!"    # overrides global GREETING for this target
      EXTRA: "{{ .BASE_PORT }}-extra"  # can reference global vars

  other:
    config: other/execrun.yaml
    # inherits GREETING: "Hello" from global vars

The merged vars (global + target overrides) are passed to the child execrun config as template data.

# hello/execrun.yaml — HELLO_PORT comes from parent, GREETING is the target override
build:
  - 'go build -o ./bin/hello ./main.go'
exec:
  - './bin/hello -port {{ .HELLO_PORT | default "8080" }} -greeting "{{ .GREETING }}"'
How vars reach child processes

Child processes (build steps, exec commands, compiled binaries) receive vars in two ways:

  1. Template substitution — vars are injected into the child's execrun config at load time, so {{ .MY_VAR }} in command strings is replaced before execution.
  2. Environment inheritance — resolved vars (global and per-target) are set in the process environment via os.Setenv. Child processes inherit the full parent environment, so they can read vars as env vars even without template syntax. Per-target vars override global vars in the environment.

Design

  • Polling + hashing over fsnotify: simpler, portable, no file descriptor limits on macOS, catches content-only changes
  • 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

Directories

Path Synopsis
cmd
execrun command
runctl command
internal
log
pkg

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL