Files
gbld/gbld.go
T
2026-08-12 17:03:43 +02:00

654 lines
20 KiB
Go

// ============================================================================ simple golang builder (mwx'2026)
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"flag"
"io"
"io/fs"
"os"
"os/exec"
"os/signal"
"path/filepath"
"regexp"
"runtime"
"slices"
"strings"
"sync"
"syscall"
"time"
"github.com/AlecAivazis/survey/v2"
"github.com/fsnotify/fsnotify"
)
// version is a var, not a const, so build.sh can put the built number in via
// -ldflags "-X main.version=…". The value here only ever shows up in a bare
// `go build`; the version a release carries is the one in version.txt. Note
// that -a below names its artifacts from this line and not from version.txt —
// for gbld's own releases build.sh is the way, not -a.
var version = "1.22.0"
var oses = []string{"darwin", "linux", "windows"}
var arches = []string{"arm64", "amd64"}
var skipdirs = []string{"bin", "tmp", "vendor", "node_modules", "testdata"}
const KEEPBACKUPS = 20 // backups per file kept in tmp/
var GO string
var GOIMPORTS string
var mu sync.Mutex // guards 'running'
var running *proc
var r1 = regexp.MustCompile(`^\.`) // hidden file
var r2 = regexp.MustCompile(`^build\.go$`) // own build counter
var r3 = regexp.MustCompile(`\.go$`) // go source
var r4 = regexp.MustCompile(`(?m)^(\s*var\s+build\s*=\s*")([^"]*)(")`) // build counter
var r5 = regexp.MustCompile(`(?im)^\s*var\s+version\s*=\s*"([^"]*)"`) // target version
var r6 = regexp.MustCompile(`(?s)import\s*\((.*?)\)`) // import block
type opts struct { // ------------------------------------------------------------------------- command line options
buildonly bool
once bool
imports bool
exitafter bool
postcmd string
runargs string
}
type proc struct { // ------------------------------------------------------ started build, reaped by its own goroutine
cmd *exec.Cmd
done chan struct{}
}
func main() { // ========================================================================================== main
if len(os.Args) == 1 { // ······································· no arguments: read predefined builds or default
os.Args = defaultargs()
}
opt_b := flag.Bool("b", false, "")
opt_1 := flag.Bool("1", false, "")
opt_i := flag.Bool("i", false, "")
opt_x := flag.Bool("x", false, "")
opt_c := flag.String("c", "", "")
opt_o := flag.String("o", "", "")
opt_a := flag.Bool("a", false, "")
opt_v := flag.Bool("v", false, "")
opt_h := flag.Bool("h", false, "")
opt_update := flag.Bool("update", false, "") // selfupdate.go
opt_checkupdate := flag.Bool("check-update", false, "")
opt_refresh := flag.Bool(strings.TrimPrefix(updateRefreshFlag, "--"), false, "") // background run, not in the
// help, name tied to the const
flag.Usage = func() {
P()
info()
P()
P(Cw("Usage:"))
P(Cy(" gbld [-b] [-1] [-i] [-x] [-c <CMD>] [-o <OPTIONS>] [build name]"))
P(Cy(" gbld [-a]"))
P(Cw(" -b build only"))
P(Cw(" -1 run only once, no watching (exit code: build error or exit code of the build)"))
P(Cw(" -i run goimports before build"))
P(Cw(" -x exit after the first successful build, leave the started build running"))
P(Cw(" -o <OPTIONS> exec options"))
P(Cw(" -c <CMD> run command after build"))
P(Cw(" -v show version"))
P(Cw(" -h show help"))
P(Cw(" -a build for all platforms"))
P(Cw(" --check-update look for a newer release"))
P(Cw(" --update download and install the newest release"))
P()
P(Cw(" · if no build name is supplied the current directory name will be used"))
P()
P(Cw(" · if no option supplied try to read '") + Cy("gbld.menu.json") + Cw("'"))
P(Cm(` {`))
P(Cm(` "build": ["-b","gbld"],`))
P(Cm(` "help": ["-h"]`))
P(Cm(` }`))
P()
P(Cw(" · if no option is supplied and no '") + Cy("gbld.menu.json") + Cw("' is available"))
P(Cw(" the current directory name with ") + Cy("-b -i -1") + Cw(" as options will be used"))
P()
P(Cw(" · set ") + Cy("GBLD_NO_UPDATE=1") + Cw(" to skip the update check"))
P()
}
flag.Parse()
o := opts{buildonly: *opt_b, once: *opt_1, imports: *opt_i, exitafter: *opt_x,
postcmd: *opt_c, runargs: *opt_o}
// These run before the toolchain is looked for: none of them needs 'go' or // no go,
// 'goimports', and selfupdate.go probes a fresh download with 'gbld -v', // no goimports
// which therefore has to work on a machine that has neither.
if (*opt_refresh) { selfUpdate.refresh(); return // update options
} else if (*opt_update) { runupdate(selfUpdate.install); return
} else if (*opt_checkupdate) { runupdate(selfUpdate.check); return
} else if (*opt_v) { info(); return
} else if (*opt_h) { flag.Usage(); return
}
GO = lookup("go") // ······················································· check for the required toolchain
if o.imports {
GOIMPORTS = lookup("goimports")
}
name := flag.Arg(0) // ································································ command line arguments
if len(name) == 0 {
name = dirName()
if len(name) == 0 {
flag.Usage()
os.Exit(1)
}
}
file := name + ".go"
if !fileExists(file) {
PE("file not found", file)
os.Exit(1)
}
if *opt_a { // ······························································· build for other platforms
info()
buildall(name)
os.Exit(0)
}
catchsignals() // ································· stop the started build when gbld itself is asked to quit
ok := buildAndRun(name, file, o) // ································································ first build
if o.once { // ······················· no watching: report the build result and wait for the started process
if !ok {
os.Exit(1)
}
os.Exit(waitRunning())
}
watcher, err := fsnotify.NewWatcher() // ····································· watch the tree for .go changes
if err != nil {
PE("cannot create watcher", err.Error())
os.Exit(1)
}
defer watcher.Close()
if err := watchtree(watcher, "."); err != nil {
PE("cannot watch directory", err.Error())
os.Exit(1)
}
trigger := make(chan struct{}, 1)
go func() {
for {
select {
case event, ok := <-watcher.Events:
if !ok {
return
}
if event.Op&fsnotify.Create == fsnotify.Create { // new directory: watch it as well
if fi, err := os.Stat(event.Name); err == nil && fi.IsDir() {
watchtree(watcher, event.Name)
continue
}
}
if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename|fsnotify.Remove) == 0 {
continue
}
base := filepath.Base(event.Name)
if !r3.MatchString(base) || r1.MatchString(base) || r2.MatchString(base) {
continue
}
select { // never block the watcher, one pending rebuild is enough
case trigger <- struct{}{}:
default:
}
case err, ok := <-watcher.Errors:
if !ok {
return
}
PE("watcher", err.Error())
}
}
}()
for range trigger { // ································· debounce, then build serially - never in parallel
for settled := false; !settled; {
select {
case <-trigger:
case <-time.After(100 * time.Millisecond):
settled = true
}
}
buildAndRun(name, file, o)
}
}
func defaultargs() []string { // ------------------------------------------------- arguments if none were supplied
c, err := os.ReadFile("gbld.menu.json")
if err != nil {
return []string{"gbld", "-b", "-i", "-1", dirName()}
}
var buildjson map[string][]string
if err := json.Unmarshal(c, &buildjson); err != nil {
PE("cannot parse gbld.menu.json", err.Error())
os.Exit(1)
}
sel := []string{}
for k := range buildjson {
sel = append(sel, k)
}
slices.Sort(sel) // map order is random - keep the menu stable
tmp := ""
if err := survey.AskOne(&survey.Select{Message: "Select build", Options: sel}, &tmp); err != nil {
P(Crb("Interrupted."))
os.Exit(0)
}
args, ok := buildjson[tmp]
if !ok {
PE("unknown build", tmp)
os.Exit(1)
}
return append([]string{"gbld"}, args...)
}
func buildAndRun(name, file string, o opts) bool { // ------------------------- build and (re)start it, ok on success
if !o.once {
PN("\033[2J\033[1;1H")
}
info()
// Costs nothing: the hint comes from the note in the cache, the asking
// happens once a day at most and in the background. It stands after the
// screen is cleared, or every rebuild would wipe it away again.
if hint := selfUpdate.daily(); hint != "" {
P(Cy(hint))
}
P(Cc("--- start compiling " + file + " ---"))
stopRunning()
bumpbuild()
if o.imports {
runimports()
}
target := filepath.Join("bin", name+exesuffix(runtime.GOOS))
PF("%s %s\n", Cw("running:"), Cyb("go build "+name))
cmd := exec.Command(GO, "build", "-o", target, ".")
out, err := cmd.CombinedOutput()
if err != nil { // ·········································································· build failed
PN(Cy(string(out)))
P(Cr("--- build failed: " + name + " ---"))
return false
}
P(Cb("--- compiling done: " + name + " ---")) // ·········································· build succeeded
if len(o.postcmd) > 0 { // ······················································· execute post command
params := strings.Fields(o.postcmd)
P(Cw(o.postcmd))
post := exec.Command(params[0], params[1:]...)
post.Stdout = os.Stdout
post.Stdin = os.Stdin
post.Stderr = os.Stderr
if err := post.Run(); err != nil {
PE("post command failed", err.Error())
}
}
if !o.buildonly { // ······················································· start the new build
args := []string{}
if len(o.runargs) > 0 {
args = strings.Fields(o.runargs)
}
cmd := exec.Command(target, args...)
cmd.Stdout = os.Stdout
cmd.Stdin = os.Stdin
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
PE("cannot start "+target, err.Error())
} else {
p := &proc{cmd: cmd, done: make(chan struct{})}
go func() { cmd.Wait(); close(p.done) }() // reap it as soon as it ends on its own
mu.Lock()
running = p
mu.Unlock()
PF("%s %d\n", Cw("process started:"), cmd.Process.Pid)
}
}
P(Cg("--- end building: " + name + " ---"))
if o.exitafter { // leaves the started process running on purpose
os.Exit(0)
}
return true
}
func waitRunning() int { // ------------------------------------- wait for the started build, return its exit code
mu.Lock()
p := running
running = nil
mu.Unlock()
if p == nil {
return 0
}
<-p.done
if p.cmd.ProcessState == nil {
return 0
}
return p.cmd.ProcessState.ExitCode()
}
func bumpbuild() { // ------------------------------------------------------------------ increase build number
c, err := os.ReadFile("build.go")
if err != nil {
return // no build.go, nothing to count
}
m := r4.FindSubmatch(c)
if m == nil {
return
}
old := string(m[2])
b := Atoi(old) + 1
if err := backup("build.go"); err != nil { // never touch a file we could not save first
PE("backup of build.go failed", err.Error())
return
}
if err := os.WriteFile("build.go", r4.ReplaceAll(c, []byte("${1}"+Itoa(b)+"${3}")), 0644); err != nil {
PE("cannot write build.go", err.Error())
return
}
P("build:", old, "->", b)
}
func runimports() { // ------------------------------------------------- update import blocks with goimports
files, err := os.ReadDir(".")
if err != nil {
PE("cannot read directory", err.Error())
return
}
for _, f := range files {
if f.IsDir() || !strings.HasSuffix(f.Name(), ".go") {
continue
}
fileName := f.Name()
output, err := exec.Command(GOIMPORTS, fileName).Output()
if err != nil {
continue // does not compile yet - the build will report it
}
m := r6.FindStringSubmatch(string(output))
if len(m) < 2 {
continue
}
origContent, err := os.ReadFile(fileName)
if err != nil {
continue
}
updatedContent := ReplaceFirst(r6, string(origContent), SF("import (%s)", m[1]))
if string(origContent) == updatedContent {
continue
}
if err := backup(fileName); err != nil {
PE("backup of "+fileName+" failed", err.Error())
continue
}
if err := os.WriteFile(fileName, []byte(updatedContent), 0644); err != nil {
PE("cannot write "+fileName, err.Error())
continue
}
PF("%s %s\n", Cw("imports updated:"), Cmb(fileName))
}
}
func buildall(name string) { // ------------------------------------------------------ build for all platforms
targetversion, err := gettargetversion()
if err != nil {
PE(err.Error())
os.Exit(1)
}
P(Ccb("build for all platforms"), Cwb(targetversion))
if err := os.MkdirAll("bin", 0755); err != nil {
PE("cannot create bin/", err.Error())
os.Exit(1)
}
sums := []string{}
failed := 0
for _, osys := range oses {
for _, arch := range arches {
ofile := filepath.Join("bin", artifact(name, targetversion, osys, arch))
cmd := exec.Command(GO, "build", "-o", ofile, ".")
cmd.Env = append(os.Environ(), "GOOS="+osys, "GOARCH="+arch)
if out, err := cmd.CombinedOutput(); err != nil {
PE("build failed: "+ofile, strings.TrimSpace(string(out)))
failed++
continue
}
sum, err := sha256file(ofile)
if err != nil {
PE("cannot checksum "+ofile, err.Error())
failed++
continue
}
sums = append(sums, SF("%s %s", sum, filepath.Base(ofile)))
P(Cy(ofile), Cw(sum[:16]))
}
}
if failed > 0 { // do not publish a version whose binaries are incomplete
PE(SF("%d of %d builds failed", failed, len(oses)*len(arches)), "version.txt not written")
os.Exit(1)
}
if err := os.WriteFile(filepath.Join("bin", "checksums.txt"), []byte(strings.Join(sums, "\n")+"\n"), 0644); err != nil {
PE("cannot write bin/checksums.txt", err.Error())
os.Exit(1)
}
if err := os.WriteFile(filepath.Join("bin", "version.txt"), []byte(targetversion+"\n"), 0644); err != nil {
PE("cannot write bin/version.txt", err.Error())
os.Exit(1)
}
PO("all builds done", targetversion)
}
func runupdate(task func(io.Writer) error) { // ----------------------------------- run a task from selfupdate.go
if err := task(os.Stdout); err != nil {
PE(err.Error())
os.Exit(1)
}
}
func artifact(name, version, osys, arch string) string { // ------------------------------ name of a build artifact
return SF("%s_%s_%s_%s%s", name, version, osys, arch, exesuffix(osys))
}
func exesuffix(osys string) string { // ------------------------------------------------ windows wants '.exe'
if osys == "windows" {
return ".exe"
}
return ""
}
func sha256file(path string) (string, error) { // --------------------------------------------- checksum of a file
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func watchtree(w *fsnotify.Watcher, root string) error { // ------------------------- watch a directory recursively
return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil || !d.IsDir() {
return nil //nolint - unreadable entries are simply not watched
}
base := filepath.Base(path)
if path != root && (r1.MatchString(base) || slices.Contains(skipdirs, base)) {
return filepath.SkipDir
}
return w.Add(path)
})
}
func backup(src string) error { // ---------------------------------------------------------------- backup files
if err := os.MkdirAll("tmp", 0755); err != nil {
return err
}
sourceFile, err := os.Open(src)
if err != nil {
return err
}
defer sourceFile.Close()
destFile, err := os.Create(SF("tmp/%s.%s", src, time.Now().Format("20060102150405")))
if err != nil {
return err
}
defer destFile.Close()
if _, err := io.Copy(destFile, sourceFile); err != nil {
return err
}
if err := destFile.Sync(); err != nil {
return err
}
prunebackups(src, KEEPBACKUPS)
return nil
}
func prunebackups(src string, keep int) { // ------------------------- keep tmp/ from growing with every rebuild
old, err := filepath.Glob(SF("tmp/%s.*", src))
if err != nil || len(old) <= keep {
return
}
slices.Sort(old) // the timestamp suffix sorts chronologically
for _, f := range old[:len(old)-keep] {
os.Remove(f)
}
}
func gettargetversion() (string, error) { // --------------------------------------------- detect target version
files, err := filepath.Glob("*.go")
if err != nil {
return "", err
}
for _, file := range files {
c, err := os.ReadFile(file)
if err != nil {
continue
}
if m := r5.FindSubmatch(c); m != nil {
return string(m[1]), nil
}
}
return "", SE(`target version not found, add 'var version = "x.y.z"' to your sources`)
}
func catchsignals() { // ---------------------------------------------- stop the running build before we exit
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
go func() {
<-sig
P()
stopRunning()
os.Exit(0)
}()
}
func stopRunning() { // ------------------------------------------------------------- stop running old process
mu.Lock()
p := running
running = nil
mu.Unlock()
if p == nil || p.cmd.Process == nil {
return
}
pid := p.cmd.Process.Pid
select {
case <-p.done: // already gone
return
default:
}
p.cmd.Process.Signal(os.Interrupt)
select {
case <-p.done:
P("process stopped:", pid)
case <-time.After(1 * time.Second):
p.cmd.Process.Kill()
<-p.done
P("process stopped forcefully:", pid)
}
}
func lookup(what string) string { // ------------------------------------------------- find a required program
path, err := exec.LookPath(what)
if err != nil {
PE(what+" not found", "please install it and make sure it is in your PATH")
os.Exit(1)
}
return path
}
func fileExists(filename string) bool { // ------------------------------------------------ check if file exists
info, err := os.Stat(filename)
if err != nil {
return false
}
return !info.IsDir()
}
func dirName() string { // ------------------------------------------------------- get name of current directory
pwd, err := os.Getwd()
if err == nil {
return filepath.Base(pwd)
}
return ""
}
func info() { // ------------------------------------------------------------------------------------- show info
PF("%s (%s (%s), toolbox %s, %s)\n", Cwb("gbld - simple golang builder"),
Cgb("v"+version), Cgb(build), Cgb("v"+tbversion), Ccb("mwx'2026"))
}
// ========================================================================================================= END