From 4ad3d2e13d60742632aedcc47f820697d99ea0b7 Mon Sep 17 00:00:00 2001 From: Michael Wesemann Date: Mon, 27 Jul 2026 17:07:25 +0200 Subject: [PATCH] [mike@mwxm4] --- README.md | 98 ++++++- build.go | 2 +- gbld.go | 799 +++++++++++++++++++++++++++++++++------------------ gbld_test.go | 235 +++++++++++++++ go.mod | 31 +- go.sum | 61 ++-- tools.go | 262 +++++++++++++---- 7 files changed, 1093 insertions(+), 395 deletions(-) create mode 100644 gbld_test.go diff --git a/README.md b/README.md index ef4f4e4..e1bb58a 100644 --- a/README.md +++ b/README.md @@ -6,19 +6,20 @@ ## Key Features -- ๐Ÿ”„ **Hot Reloader**: Automatically watches for file changes (via `fsnotify`) and rebuilds/restarts your application instantly. +- ๐Ÿ”„ **Hot Reloader**: Automatically watches for file changes (via `fsnotify`), recursively including subdirectories, and rebuilds/restarts your application instantly. - ๐Ÿ”ข **Build Counter**: Increments a build number in `build.go` automatically with every compiled build. -- ๐Ÿงน **Auto-Import & Formatting**: Runs `goimports` on save (optional via `-i`) to keep your imports and formatting clean. +- ๐Ÿงน **Auto-Import**: Runs `goimports` on save (optional via `-i`) to keep your imports clean โ€” without touching the rest of your formatting. - ๐Ÿ–ฅ๏ธ **Interactive Menu**: Uses a select menu (`gbld.menu.json`) if run without arguments, letting you define and run custom build tasks. - ๐Ÿš€ **Cross-Compilation**: Easily builds binaries for multiple target operating systems (macOS, Linux, Windows) and architectures (amd64, arm64) using a single command. -- โ˜๏ธ **Remote Uploads**: Integrated command to securely upload build artifacts to a deployment server (`gozilla`). +- โ˜๏ธ **Remote Uploads**: Integrated command to upload build artifacts to a deployment server (`gozilla`). +- ๐Ÿ” **Verified Self-Update**: Downloads updates over HTTPS and refuses to install anything that does not match its published SHA-256 checksum. --- ## Installation ### Prerequisites -Make sure you have [Go](https://go.dev/) and `goimports` installed and available in your system path: +Make sure you have [Go](https://go.dev/) installed and available in your system path. `goimports` is only required if you use the `-i` flag: ```bash go install golang.org/x/tools/cmd/goimports@latest ``` @@ -39,15 +40,16 @@ go build -o /usr/local/bin/gbld gbld [flags] [target_name] ``` > [!NOTE] -> If `target_name` is omitted, the name of the current directory is used. The tool expects a main source file matching that name (e.g., `target_name.go`). +> If `target_name` is omitted, the name of the current directory is used. The tool expects a main source file matching that name (e.g., `target_name.go`). The binary is always built from the package in the current directory, so the module path does not have to match the target name. ### CLI Flags | Flag | Argument | Description | | :--- | :--- | :--- | | `-b` | None | **Build-only** mode (compiles the binary but does not run it). | -| `-1` | None | Run **once** (does not watch the directory for changes). | +| `-1` | None | Run **once** (does not watch the directory for changes), see [Exit Codes](#exit-codes). | | `-i` | None | Runs `goimports` to clean up imports before building. | +| `-x` | None | Exits after the first successful build and **leaves the started binary running**. | | `-o` | `` | Space-separated arguments to pass to the compiled binary on execution. | | `-c` | `` | Command to execute immediately after a successful build (before execution). | | `-a` | None | Builds binaries for all supported platforms (Windows, macOS, Linux for AMD64/ARM64). | @@ -55,11 +57,17 @@ gbld [flags] [target_name] | `-v` | None | Displays the current version of `gbld`. | | `-h` | None | Displays help and usage information. | +### Environment + +| Variable | Description | +| :--- | :--- | +| `GBLD_NO_UPDATE` | Set to any value to skip the update check at startup (useful offline or in CI). | + --- ## Configuration (`gbld.menu.json`) -To make running common tasks easier, you can place a `gbld.menu.json` file in your project directory. If you run `gbld` without any flags, it will parse this file and present an interactive selection menu: +To make running common tasks easier, you can place a `gbld.menu.json` file in your project directory. If you run `gbld` without any flags, it will parse this file and present an interactive selection menu (entries are shown in alphabetical order): ```json { @@ -74,16 +82,88 @@ To make running common tasks easier, you can place a `gbld.menu.json` file in yo ## Under the Hood ### Build Increment -When compiling, `gbld` parses `build.go` to find a pattern matching `var build = ""`. It increments this number by 1 and updates the file automatically. This is useful for stamping build numbers/versions inside your binary. +When compiling, `gbld` parses `build.go` to find a pattern matching `var build = ""`. It increments this number by 1 and updates the file automatically, leaving the rest of the line (including trailing comments) untouched. This is useful for stamping build numbers/versions inside your binary. If there is no `build.go`, this step is silently skipped. + +### Target Version +`-a` and `-u` name their artifacts after the first `var version = "x.y.z"` found in the `*.go` files of the project. Ordinary builds do not need this variable. ### Automatic Backups -Before `gbld` runs `goimports` on a file or modifies `build.go`, it creates a timestamped backup in a `./tmp/` directory inside the project workspace: +Before `gbld` runs `goimports` on a file or modifies `build.go`, it creates a timestamped backup in a `./tmp/` directory inside the project workspace (the directory is created on demand): ``` ./tmp/gbld.go.20260625145800 ``` +If a backup cannot be written, the file is **not** modified. + +### File Watching +The project directory is watched recursively; directories created while `gbld` is running are picked up automatically. Hidden entries (`.git`, โ€ฆ) as well as `bin`, `tmp`, `vendor`, `node_modules` and `testdata` are skipped. Only writes and newly created `.go` files trigger a rebuild โ€” a pure `touch` (mtime only) does not. Rebuilds are debounced by 100 ms and never run in parallel. ### Process Management When hot-reloading, `gbld` handles process termination gracefully: 1. It sends an `os.Interrupt` (SIGINT) to the running binary. 2. It waits up to 1 second for the process to exit clean. 3. If it does not exit within the timeout, it terminates the process forcefully (`SIGKILL`). + +`gbld` itself also traps SIGINT/SIGTERM and stops the started binary before exiting, so no orphaned processes are left behind โ€” except with `-x`, where detaching is the point. + +### Exit Codes +With `-1`, `gbld` is usable from scripts and CI: + +| Situation | Exit code | +| :--- | :--- | +| Build failed | `1` | +| `-b -1`, build succeeded | `0` | +| `-1` without `-b` | exit code of the built program (gbld waits for it) | + +--- + +## Cross-Compilation and Upload + +```bash +gbld -a # build bin/___ for all 6 targets +gbld -u # upload them to gozilla +``` + +`-a` writes Windows artifacts with an `.exe` suffix and additionally produces: + +- `bin/checksums.txt` โ€” SHA-256 of every artifact, in the format used by `shasum -a 256 -c` +- `bin/version.txt` โ€” the published version + +If any target fails to build, `version.txt` is **not** written and `gbld` exits with `1`, so a half-published version can never be announced. `-u` refuses build names containing anything but `A-Za-z0-9._-` and uploads the artifacts together with `checksums.txt` and `version.txt`. + +### Self-Update +On startup `gbld` fetches `version.txt` from the update server (3 s timeout โ€” an unreachable server never blocks a build). If a newer version is published, it asks before downloading, then verifies the download against the SHA-256 from `checksums.txt` before replacing itself. **If no matching checksum is published, the update is refused.** Publish new versions with `gbld -a` followed by `gbld -u`. + +--- + +## Colors + +Output uses the [Catppuccin Mocha](https://terminalcolors.com/themes/catppuccin/mocha/) palette as 24-bit color: + +| Helper | Palette | Hex | +| :--- | :--- | :--- | +| `Cr` / `Crb` | Red | `#f38ba8` | +| `Cg` / `Cgb` | Green | `#a6e3a1` | +| `Cy` / `Cyb` | Yellow | `#f9e2af` | +| `Cb` / `Cbb` | Blue | `#89b4fa` | +| `Cm` / `Cmb` | Pink | `#f5c2e7` | +| `Cc` / `Ccb` | Teal | `#94e2d5` | +| `Cw` | Subtext1 | `#bac2de` | +| `Cwb` | Text | `#cdd6f4` | + +Mocha maps identical values to the normal and bright ANSI slots, so the bold helpers keep their hue and only add weight. The full palette is available as `CatMauve`, `CatPeach`, `CatLavender`, โ€ฆ in `tools.go`. + +The interactive prompts (`survey`) are themed as well: their templates resolve colors through `{{color "green+hb"}}`-style names, and `tools.go` replaces that template function with one that maps those names onto the palette. This covers every prompt including its icons โ€” question mark, select arrow, help and error markers โ€” without copying any template. + +Terminals that do not announce 24-bit color via `COLORTERM` fall back to the basic ANSI colors, so their own theme keeps deciding. `NO_COLOR` and non-TTY output disable colors entirely. + +--- + +## Development + +```bash +go test ./... # unit tests (build counter, version detection, update verification, toolbox) +go vet ./... +``` + +> [!NOTE] +> The sources are deliberately **not** `gofmt`-formatted (2-space indentation). The `-i` flag only replaces the `import (...)` block of a file and leaves the rest of the formatting alone โ€” do not run `gofmt` over the tree. diff --git a/build.go b/build.go index b7f6f5f..983c9eb 100644 --- a/build.go +++ b/build.go @@ -1,3 +1,3 @@ package main -var build = "362" +var build = "368" diff --git a/gbld.go b/gbld.go index 9ac90ae..6873abb 100644 --- a/gbld.go +++ b/gbld.go @@ -2,67 +2,73 @@ package main import ( - "bufio" - "bytes" + "crypto/sha256" + "encoding/hex" "encoding/json" "flag" - "fmt" "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" ) -var version = "1.20.1" +var version = "1.21.0" -var UPDATEURL = "http://gozilla.fhi.mpg.de/gbld" +var UPDATEURL = "https://gozilla.fhi.mpg.de/gbld" +var UPLOADHOST = "root@gozilla" +var UPLOADPATH = "/db/www" 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 xmd *exec.Cmd -var done chan bool -var r1 = regexp.MustCompile(`^\.`) -var r2 = regexp.MustCompile(`build\.go`) -var r3 = regexp.MustCompile(`\.go$`) -var r4 = regexp.MustCompile(`var\s+build\s*=\s*"(.*)"$`) -var r5 = regexp.MustCompile(`(?i)var\s+version\s*=\s*"(.*)"$`) +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 +var r7 = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) // safe build name + +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 - - checkforupdate(UPDATEURL) - if len(os.Args) == 1 { // read predefined builds - c, err := os.ReadFile("gbld.menu.json") - if err == nil { - var sel []string - var buildjson map[string][]string - - err := json.Unmarshal([]byte(c), &buildjson) - if err == nil { - for k := range buildjson { - sel = append(sel, k) - } - tmp := "" - err = survey.AskOne(&survey.Select{Message: "Select build", Options: sel}, &tmp) - os.Args = []string{"gbld"} - os.Args = append(os.Args, buildjson[tmp]...) - } else { - PE(err.Error()) - os.Exit(1) - } - } else { - os.Args = []string{"gbld","-b","-i","-1",dirName()} - } + if len(os.Args) == 1 { // ยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยท no arguments: read predefined builds or default + os.Args = defaultargs() } opt_b := flag.Bool("b", false, "") @@ -81,19 +87,20 @@ func main() { // =============================================================== info() P() P(Cw("Usage:")) - P(Cy(" gbld [-b] [-1] [-i] [-c ] [-o ] [build name]")) + P(Cy(" gbld [-b] [-1] [-i] [-x] [-c ] [-o ] [build name]")) P(Cy(" gbld [-a] [-u]")) P(Cw(" -b build only")) - P(Cw(" -1 run only once")) - P(Cw(" -i run gpimports before build")) + 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 exec options")) P(Cw(" -c run command after build")) P(Cw(" -v show version")) P(Cw(" -h show help")) P(Cw(" -a build for all platforms")) - P(Cw(" -u upload builds to gozilla")) + P(Cw(" -u upload builds to " + UPLOADHOST)) P() - P(Cw(" ยท if build is supplied the current directory name will be used")) + 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(` {`)) @@ -104,23 +111,14 @@ func main() { // =============================================================== 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() - path, err := exec.LookPath("go") // ยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยท check for 'go' - if err != nil { - fmt.Println(Crb("Error: ") + Cwb("go") + " not found") - os.Exit(1) - } - GO = path - - path, err = exec.LookPath("goimports") // ยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยท check for 'goimports' - if err != nil { - fmt.Println(Crb("Error: ") + Cwb("goimports") + " not found") - os.Exit(1) - } - GOIMPORTS = path + o := opts{buildonly: *opt_b, once: *opt_1, imports: *opt_i, exitafter: *opt_x, + postcmd: *opt_c, runargs: *opt_o} if *opt_v { // ยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยท show version info() @@ -132,9 +130,18 @@ func main() { // =============================================================== os.Exit(0) } + if os.Getenv("GBLD_NO_UPDATE") == "" { // ยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยท check for a newer gbld version + checkforupdate(UPDATEURL) + } + + 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() + name = dirName() if len(name) == 0 { flag.Usage() os.Exit(1) @@ -143,82 +150,48 @@ func main() { // =============================================================== file := name + ".go" if !fileExists(file) { - P(Crb("file not found: " + file)) + PE("file not found", file) os.Exit(1) } - if (!*opt_1 && !*opt_a && !*opt_u) { - PN("\033[2J\033[1;1H") - } - info() - if *opt_a { // ยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยท build for other platforms - targetversion:=gettargetversion() - P(Ccb("build for all platforms")) - for _, osys := range oses { - for _, arch := range arches { - ofile:="bin/"+name+"_"+targetversion+"_"+osys+"_"+arch - P(Cy(ofile)) - - cmd := exec.Command(GO, "build", "-o", ofile, name) - cmd.Env = append(os.Environ(), "GOOS="+osys, "GOARCH="+arch) - cmd.Run() - } - } - os.WriteFile("bin/version.txt", []byte(targetversion), 0644) + info() + buildall(name) } if *opt_u { // ยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยท upload builds to gozilla - targetversion:=gettargetversion() - P(Ccb("upload builds to gozilla")) - - for _, osys := range oses { - for _, arch := range arches { - ofile:="bin/"+name+"_"+targetversion+"_"+osys+"_"+arch - _, err := os.Stat(ofile) - if err == nil { - cmd := exec.Command("ssh","root@gozilla","rm", "/db/www/"+name+"/*_"+osys+"_"+arch ) - P(Cy(cmd)) - cmd.Run() - - cmd = exec.Command("scp",ofile, "root@gozilla:/db/www/"+name) - P(Cy(cmd)) - cmd.Run() - } - } - } - _, err := os.Stat("bin/version.txt") - if err == nil { - cmd := exec.Command("scp","bin/version.txt", "root@gozilla:/db/www/"+name) - P(Cy(cmd)) - cmd.Run() - } + info() + uploadall(name) } - if (*opt_a || *opt_u) { os.Exit(0) } - - // Initial build - buildAndRun(name, file, *opt_b, *opt_1, *opt_i, *opt_c, *opt_o, *opt_x) - - if *opt_1 { + if *opt_a || *opt_u { os.Exit(0) } - // Set up fsnotify watcher - watcher, err := fsnotify.NewWatcher() + 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(err.Error()) + PE("cannot create watcher", err.Error()) os.Exit(1) } defer watcher.Close() - err = watcher.Add(".") - if err != nil { - PE(err.Error()) + if err := watchtree(watcher, "."); err != nil { + PE("cannot watch directory", err.Error()) os.Exit(1) } - events := make(chan bool) + trigger := make(chan struct{}, 1) go func() { for { select { @@ -226,221 +199,495 @@ func main() { // =============================================================== if !ok { return } - if event.Op&fsnotify.Write == fsnotify.Write || event.Op&fsnotify.Create == fsnotify.Create { - fileName := filepath.Base(event.Name) - if r3.MatchString(fileName) && !r1.MatchString(fileName) && !r2.MatchString(fileName) { - events <- true + 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(err.Error()) + PE("watcher", err.Error()) } } }() - var timer *time.Timer - for { - select { - case <-events: - if timer != nil { - timer.Stop() + 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 } - timer = time.AfterFunc(100*time.Millisecond, func() { - buildAndRun(name, file, *opt_b, *opt_1, *opt_i, *opt_c, *opt_o, *opt_x) - }) } + buildAndRun(name, file, o) } } -func buildAndRun(name, file string, opt_b, opt_1, opt_i bool, opt_c, opt_o string, opt_x bool) { - PN("\033[2J\033[1;1H") - P(Cc("--- start compiling " + file + " ---")) - - stopRunning(xmd) - - o := "" // ยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยท increase build number - func() { - readFile, err := os.Open("build.go") - if err == nil { - defer readFile.Close() - backup("build.go") - fileScanner := bufio.NewScanner(readFile) - fileScanner.Split(bufio.ScanLines) - for fileScanner.Scan() { - line := fileScanner.Text() - s := r4.FindStringSubmatch(line) - if len(s) > 0 { - b := Atoi(s[1]) + 1 - o = o + "var build = \"" + Itoa(b) + "\"\n" - P("build:", s[1], "->", b) - } else { - o = o + line + "\n" - } - } - os.WriteFile("build.go", []byte(o), 0644) - } - }() - - _ = gettargetversion() - - if opt_i { // ยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยท run goimports - re := regexp.MustCompile(`(?s)import\s*\((.*?)\)`) - files, err := os.ReadDir(".") - if err != nil { fmt.Printf("Error: %v\n", err); return } - - for _, f := range files { - if !f.IsDir() && strings.HasSuffix(f.Name(), ".go") { - fileName := f.Name() - cmd := exec.Command("goimports", fileName) - output, err := cmd.Output() - if err != nil { continue } - origContent, _ := os.ReadFile(fileName) - tempMatch := re.FindStringSubmatch(string(output)) - if len(tempMatch) < 2 { continue } - newImportBlock := tempMatch[1] - updatedContent := ReplaceFirst(re,string(origContent),fmt.Sprintf("import (%s)", newImportBlock)) - if (string(origContent) != updatedContent) { - backup(fileName) - PF("%s %s\n",Cw("imports updated:"),Cmb(fileName)) - os.WriteFile(fileName, []byte(updatedContent), 0644) - } - } - } +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()} } - cmd := exec.Command("go", "build", "-o", "bin/"+name, name) + 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() + 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 + " ---")) - } else { // ยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยท build succeeded - P(Cb("--- compiling done: " + name + " ---")) + return false + } - if len(opt_c) > 0 { // ยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยท execute post command - params := strings.Fields(opt_c) - app := params[0] - args := params[1:] + P(Cb("--- compiling done: " + name + " ---")) // ยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยท build succeeded - P(Cw(opt_c)) - xmd = exec.Command(app, args...) - xmd.Stdout = os.Stdout - xmd.Stdin = os.Stdin - xmd.Stderr = os.Stderr - xmd.Run() - } - - if !opt_b { - stopRunning(xmd) - - args := []string{} - if len(opt_o) > 0 { - args = strings.Fields(opt_o) - } - - xmd = exec.Command("./bin/"+name, args...) // ยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยทยท start new build - var stdoutBuf, stderrBuf bytes.Buffer - xmd.Stdout = io.MultiWriter(os.Stdout, &stdoutBuf) - xmd.Stderr = io.MultiWriter(os.Stderr, &stderrBuf) - xmd.Start() - - PF("%s %d\n", Cw("process started:"), xmd.Process.Pid) - } - - P(Cg("--- end building: " + name + " ---")) - if opt_x { - os.Exit(0) + 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 backup(src string) { // ---------------------------------------------------------------------- backup files - sourceFile,_ := os.Open(src) - defer sourceFile.Close() - dst:=SF("./tmp/%s.%s",src,time.Now().Format("20060102150405")) - destFile,_ := os.Create(dst) - defer destFile.Close() - io.Copy(destFile, sourceFile) - destFile.Sync() - destFile.Close() +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 gettargetversion() string { // ------------------------------------------- detect target version - files, err := filepath.Glob("*.go") +func bumpbuild() { // ------------------------------------------------------------------ increase build number + c, err := os.ReadFile("build.go") if err != nil { - PE(SF("Error listing files: %v", err)) - os.Exit(1) - } - - targetversion := "" - for _, file := range files { - readFile, err := os.Open(file) - if err != nil { - continue - } - fileScanner := bufio.NewScanner(readFile) - fileScanner.Split(bufio.ScanLines) - for fileScanner.Scan() { - line := fileScanner.Text() - s := r5.FindStringSubmatch(line) - if len(s) > 0 { - targetversion = s[1] - readFile.Close() - return targetversion - } - } - readFile.Close() + return // no build.go, nothing to count } - if targetversion == "" { - PE("target version not found") - os.Exit(1) - } - - return targetversion -} - -func stopRunning(cmd *exec.Cmd) { // -------------------------------------------------- stop running old process - if cmd == nil || cmd.ProcessState != nil && cmd.ProcessState.Exited() || cmd.Process == nil { + m := r4.FindSubmatch(c) + if m == nil { return } - err := cmd.Process.Signal(os.Interrupt) - if err == nil { - done := make(chan error, 1) - go func() { done <- cmd.Wait() }() - select { - case <-time.After(1 * time.Second): - cmd.Process.Kill() - <-done - case <-done: - } - P("process stopped:", cmd.Process.Pid) - } else { - cmd.Process.Kill() - cmd.Wait() - P("process stopped forcefully:", cmd.Process.Pid) + 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 uploadall(name string) { // ------------------------------------------------------- upload builds to gozilla + if !r7.MatchString(name) { // the name ends up in a remote shell command + PE("invalid build name", name) + os.Exit(1) + } + + targetversion, err := gettargetversion() + if err != nil { + PE(err.Error()) + os.Exit(1) + } + + files := []string{} + for _, osys := range oses { + for _, arch := range arches { + ofile := filepath.Join("bin", artifact(name, targetversion, osys, arch)) + if fileExists(ofile) { + files = append(files, ofile) + } + } + } + if len(files) == 0 { + PE("no builds found for version "+targetversion, "run 'gbld -a' first") + os.Exit(1) + } + for _, f := range []string{"bin/checksums.txt", "bin/version.txt"} { + if !fileExists(f) { + PE("missing "+f, "run 'gbld -a' first") + os.Exit(1) + } + files = append(files, f) + } + + P(Ccb("upload builds to "+UPLOADHOST), Cwb(targetversion)) + dst := UPLOADPATH + "/" + name + + if err := run("ssh", UPLOADHOST, SF("mkdir -p '%s' && rm -f '%s'/*_*_*", dst, dst)); err != nil { + os.Exit(1) + } + if err := run(append([]string{"scp"}, append(files, UPLOADHOST+":"+dst)...)...); err != nil { + os.Exit(1) + } + PO("upload done", targetversion) +} + +func run(args ...string) error { // ------------------------------------------------- run a command, report failure + P(Cy(strings.Join(args, " "))) + out, err := exec.Command(args[0], args[1:]...).CombinedOutput() + if err != nil { + PE(args[0]+" failed", strings.TrimSpace(string(out))) + } + return err +} + +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 os.IsNotExist(err) { + if err != nil { return false } return !info.IsDir() } func dirName() string { // ------------------------------------------------------- get name of current directory - pwd,err := os.Getwd() - if err == nil { + pwd, err := os.Getwd() + if err == nil { return filepath.Base(pwd) - } + } return "" } diff --git a/gbld_test.go b/gbld_test.go new file mode 100644 index 0000000..3a492fa --- /dev/null +++ b/gbld_test.go @@ -0,0 +1,235 @@ +// ================================================================================== tests for gbld (mwx'2026) +package main + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func inTempDir(t *testing.T) { // ------------------------------------- run a test in an empty working directory + t.Helper() + old, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chdir(old) }) +} + +func write(t *testing.T, name, content string) { // ------------------------------------------ write a test file + t.Helper() + if err := os.WriteFile(name, []byte(content), 0644); err != nil { + t.Fatal(err) + } +} + +func TestGettargetversion(t *testing.T) { // ------------------------------------------- find 'var version = ..' + inTempDir(t) + + if _, err := gettargetversion(); err == nil { + t.Error("expected an error when no version is defined") + } + + write(t, "other.go", "package main\n\n"+`var tbversion = "0.1.0"`+"\n") + if _, err := gettargetversion(); err == nil { + t.Error("tbversion must not be taken for the target version") + } + + write(t, "app.go", "package main\n\n"+`var version = "2.3.0" // release`+"\n") + v, err := gettargetversion() + if err != nil { + t.Fatal(err) + } + if v != "2.3.0" { + t.Errorf("got %q, want 2.3.0 (a trailing comment must not break the match)", v) + } +} + +func TestBumpbuild(t *testing.T) { // ------------------------------------- counter increases, comment survives + inTempDir(t) + + write(t, "build.go", "package main\n\n"+`var build = "41" // counter`+"\n") + bumpbuild() + + c, err := os.ReadFile("build.go") + if err != nil { + t.Fatal(err) + } + want := "package main\n\n" + `var build = "42" // counter` + "\n" + if string(c) != want { + t.Errorf("got %q, want %q", string(c), want) + } + + files, _ := filepath.Glob("tmp/build.go.*") // the backup is what protects the users file + if len(files) != 1 { + t.Errorf("expected exactly one backup in tmp/, got %v", files) + } +} + +func TestBackupPruning(t *testing.T) { // ---------------------------------- tmp/ must not grow without bounds + inTempDir(t) + + write(t, "build.go", "package main\n\n"+`var build = "1"`+"\n") + for i := 0; i < KEEPBACKUPS+5; i++ { // same second: force distinct names to test the pruning itself + if err := backup("build.go"); err != nil { + t.Fatal(err) + } + write(t, SF("tmp/build.go.2026010112000%02d", i), "old backup") + prunebackups("build.go", KEEPBACKUPS) + } + + files, _ := filepath.Glob("tmp/build.go.*") + if len(files) > KEEPBACKUPS { + t.Errorf("got %d backups, want at most %d", len(files), KEEPBACKUPS) + } +} + +func TestBumpbuildKeepsLongLines(t *testing.T) { // -------------- a >64k line must not truncate the file anymore + inTempDir(t) + + long := strings.Repeat("x", 100*1024) + write(t, "build.go", "package main\n\n"+`var build = "1"`+"\n\nvar long = \""+long+"\"\n") + bumpbuild() + + c, err := os.ReadFile("build.go") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(c), `var build = "2"`) { + t.Error("counter was not increased") + } + if !strings.Contains(string(c), long) { + t.Error("long line was lost - the file got truncated") + } +} + +func TestArtifact(t *testing.T) { // --------------------------------------------- windows artifacts need '.exe' + if got := artifact("gbld", "1.2.3", "linux", "amd64"); got != "gbld_1.2.3_linux_amd64" { + t.Errorf("got %q", got) + } + if got := artifact("gbld", "1.2.3", "windows", "amd64"); got != "gbld_1.2.3_windows_amd64.exe" { + t.Errorf("got %q", got) + } +} + +func TestFileExists(t *testing.T) { // --------------------------------- must not panic on unreadable entries + inTempDir(t) + + write(t, "there.go", "package main\n") + if !fileExists("there.go") { + t.Error("existing file not found") + } + if fileExists("missing.go") { + t.Error("missing file reported as existing") + } + os.Mkdir("adir", 0755) + if fileExists("adir") { + t.Error("a directory is not a file") + } + + os.Mkdir("locked", 0000) // no permission: os.Stat fails with something other than IsNotExist + defer os.Chmod("locked", 0755) + if fileExists("locked/x.go") { + t.Error("unreadable path reported as existing") + } +} + +func TestGetchecksum(t *testing.T) { // ------------------------------------- checksums are read and validated + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/checksums.txt" { + http.NotFound(w, r) + return + } + w.Write([]byte("aabb gbld_1.0.0_linux_amd64\n" + + "0f0f gbld_1.0.0_windows_amd64.exe\n" + + "zzzz broken_sum\n")) + })) + defer srv.Close() + + client := &http.Client{Timeout: 5 * time.Second} + + sum, err := getchecksum(client, srv.URL+"/checksums.txt", "gbld_1.0.0_linux_amd64") + if err != nil { + t.Fatal(err) + } + if len(sum) != 2 || sum[0] != 0xaa || sum[1] != 0xbb { + t.Errorf("wrong checksum decoded: %x", sum) + } + + if _, err := getchecksum(client, srv.URL+"/checksums.txt", "gbld_1.0.0_darwin_arm64"); err == nil { + t.Error("a missing entry must be an error - never update without a checksum") + } + if _, err := getchecksum(client, srv.URL+"/checksums.txt", "broken_sum"); err == nil { + t.Error("a malformed checksum must be an error") + } + if _, err := getchecksum(client, srv.URL+"/missing.txt", "gbld_1.0.0_linux_amd64"); err == nil { + t.Error("a missing checksums.txt must be an error") + } +} + +func TestDoupdateRejectsWrongChecksum(t *testing.T) { // ------------- a manipulated download is never applied + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("this is not the binary you asked for")) + })) + defer srv.Close() + + err := doupdate(&http.Client{Timeout: 5 * time.Second}, srv.URL+"/gbld", []byte{0xde, 0xad, 0xbe, 0xef}) + if err == nil { + t.Fatal("update with a wrong checksum was accepted") + } + if !strings.Contains(strings.ToLower(err.Error()), "checksum") { + t.Errorf("expected a checksum error, got: %v", err) + } +} + +func TestFetchStatus(t *testing.T) { // -------------------------------------- http errors must not be swallowed + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "nope", http.StatusInternalServerError) + })) + defer srv.Close() + + if _, err := fetch(&http.Client{Timeout: 5 * time.Second}, srv.URL, 1<<20); err == nil { + t.Error("http 500 was reported as success") + } +} + +func TestToolbox(t *testing.T) { // ------------------------------------------------ toolbox edge cases + if got := Shortstr("abcdef", 4); got != "ab.." { + t.Errorf("Shortstr: got %q", got) + } + if got := Shortstr("abcdef", 1); got != ".." { // must not panic + t.Errorf("Shortstr with a tiny length: got %q", got) + } + if got := Dec("!!!not valid!!!"); got != "" { // must not panic + t.Errorf("Dec of garbage: got %q", got) + } + if got := Dec(Enc("secret")); got != "secret" { + t.Errorf("Enc/Dec roundtrip: got %q", got) + } + + in := []string{"a", "b", "a", "c"} + out := RemoveAllMatches(in, "a") + if strings.Join(out, "") != "bc" { + t.Errorf("RemoveAllMatches: got %v", out) + } + if strings.Join(in, "") != "abac" { + t.Errorf("RemoveAllMatches modified the callers slice: %v", in) + } + + if !Checkip("10.0.0.0/8", "10.1.2.3") || Checkip("10.0.0.0/8", "192.168.0.1") { + t.Error("Checkip cidr") + } + if Checkip("10.0.0.0/8", "not an ip") { + t.Error("Checkip accepted an invalid address") + } +} + +// ========================================================================================================= END diff --git a/go.mod b/go.mod index 738cbf6..8a3d022 100644 --- a/go.mod +++ b/go.mod @@ -7,31 +7,30 @@ require ( github.com/Masterminds/semver/v3 v3.5.0 github.com/eknkc/basex v1.0.1 github.com/fatih/color v1.19.0 - github.com/fsnotify/fsnotify v1.9.0 + github.com/fsnotify/fsnotify v1.10.1 + github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d github.com/minio/selfupdate v0.6.0 github.com/spf13/viper v1.21.0 github.com/tidwall/gjson v1.19.0 ) require ( - aead.dev/minisign v0.2.0 // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + aead.dev/minisign v0.3.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect - github.com/sagikazarmark/locafero v0.11.0 // indirect - github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/mattn/go-colorable v0.1.15 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/pelletier/go-toml/v2 v2.4.3 // indirect + github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect - github.com/tidwall/match v1.1.1 // indirect - github.com/tidwall/pretty v1.2.0 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect - golang.org/x/text v0.28.0 // indirect + github.com/tidwall/match v1.2.0 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/term v0.45.0 // indirect + golang.org/x/text v0.40.0 // indirect ) diff --git a/go.sum b/go.sum index fec9c1a..686e84a 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,6 @@ -aead.dev/minisign v0.2.0 h1:kAWrq/hBRu4AARY6AlciO83xhNnW9UaC8YipS2uhLPk= aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ= +aead.dev/minisign v0.3.0 h1:8Xafzy5PEVZqYDNP60yJHARlW1eOQtsKNp/Ph2c0vRA= +aead.dev/minisign v0.3.0/go.mod h1:NLvG3Uoq3skkRMDuc3YHpWUTMTrSExqm+Ij73W13F6Y= github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= @@ -17,10 +18,10 @@ github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= @@ -32,25 +33,24 @@ github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3x github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU= github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= +github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= -github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= -github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= -github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= +github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= @@ -67,18 +67,19 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= -github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= -github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= -github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= -github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= +github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b h1:QAqMVf3pSa6eeTsuklijukjXBlj7Es2QQplab+/RbQ4= golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -96,27 +97,25 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -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/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tools.go b/tools.go index 21ff5c6..c98aed1 100644 --- a/tools.go +++ b/tools.go @@ -2,10 +2,10 @@ package main import ( - "bufio" "crypto/rand" "database/sql" "encoding/base64" + "encoding/hex" "flag" "fmt" "io" @@ -18,71 +18,102 @@ import ( "runtime" "strconv" "strings" + "time" "github.com/AlecAivazis/survey/v2" + "github.com/AlecAivazis/survey/v2/core" "github.com/AlecAivazis/survey/v2/terminal" "github.com/Masterminds/semver/v3" "github.com/eknkc/basex" "github.com/fatih/color" + "github.com/mgutz/ansi" "github.com/minio/selfupdate" "github.com/spf13/viper" "github.com/tidwall/gjson" ) -var tbversion = "0.5.0" +var tbversion = "0.7.0" + +var HTTPTIMEOUT = 3 * time.Second // version.txt / checksums.txt - must never stall a build +var DOWNLOADTIMEOUT = 10 * time.Minute // the binary itself, http.Client.Timeout covers the whole body var CHRS = "VW9IdGJ6eXh1T25DRHdrc2M5MlhOQVNQcEJFWnJhWVY2ZEowaFJLdmoxNUdxVDRJZkZpTTdRZW0zTFc4Z2w=" var LR = []rune("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") func checkforupdate(URL string) { // ----------------------------------------------------- check for new version prg := prgname() - resp, err := http.Get(URL+"/version.txt") - if err == nil { - scanner := bufio.NewScanner(resp.Body) - if scanner.Scan() { - lversion := strings.TrimSpace(scanner.Text()) - sv_version, err := semver.NewVersion(version) - if err == nil { - sv_lversion, err := semver.NewVersion(lversion) - if err == nil { - if (sv_lversion.GreaterThan(sv_version)) { - ans:=Yesno(SF("new '%s' version found (%s -> %s), update now?", - prg,sv_version,sv_lversion),true,false); - if (ans) { - updateurl:=SF("%s/%s_%s_%s_%s",URL,prg,lversion,runtime.GOOS,runtime.GOARCH) - - if err := doupdate(updateurl); err != nil { - PO(SF("Update failed: %v\n", err)) - os.Exit(1) - } - PO("Update successful!","please run your last command again") - os.Exit(0) - - } - } - } - } - } - resp.Body.Close() + client := &http.Client{Timeout: HTTPTIMEOUT} + + body, err := fetch(client, URL+"/version.txt", 1<<20) // update server unreachable: never block the build + if err != nil { return } + + lversion := strings.TrimSpace(string(body)) // exactly as published - it names the artifact + sv_version, err := semver.NewVersion(version) + if err != nil { return } + sv_lversion, err := semver.NewVersion(lversion) + if err != nil { return } + if !sv_lversion.GreaterThan(sv_version) { return } + + if !Yesno(SF("new '%s' version found (%s -> %s), update now?",prg,sv_version,lversion),true,false) { + return } + + target := SF("%s_%s_%s_%s",prg,lversion,runtime.GOOS,runtime.GOARCH) + if runtime.GOOS == "windows" { target += ".exe" } + + sum, err := getchecksum(client, URL+"/checksums.txt", target) // no checksum -> no update + if err != nil { + PE("update aborted", err.Error()) + os.Exit(1) + } + + if err := doupdate(&http.Client{Timeout: DOWNLOADTIMEOUT}, URL+"/"+target, sum); err != nil { + PE("update failed", err.Error()) + os.Exit(1) + } + PO("update successful","please run your last command again") + os.Exit(0) } -func doupdate(url string) error { // ----------------------------------------------------------------- do update - resp, err := http.Get(url) +func doupdate(client *http.Client, url string, checksum []byte) error { // --------------------------- do update + resp, err := client.Get(url) if err != nil { return err } defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { return fmt.Errorf("server returned status: %v", resp.Status) } - err = selfupdate.Apply(resp.Body, selfupdate.Options{}) - if err != nil { return err } - return nil + if resp.StatusCode != http.StatusOK { return SE("server returned status: %v", resp.Status) } + return selfupdate.Apply(resp.Body, selfupdate.Options{Checksum: checksum}) } -func checkaccess(NETS []string) { // ------------------------------------------------- check ip net based access +func getchecksum(client *http.Client, url string, name string) ([]byte, error) { // -- sha256 of a published file + body, err := fetch(client, url, 1<<20) + if err != nil { return nil, SE("no checksums published (%v)", err) } + + for _, line := range strings.Split(string(body), "\n") { + f := strings.Fields(line) + if len(f) == 2 && f[1] == name { + sum, err := hex.DecodeString(f[0]) + if err != nil { return nil, SE("bad checksum for %s", name) } + return sum, nil + } + } + return nil, SE("no checksum for %s", name) +} + +func fetch(client *http.Client, url string, max int64) ([]byte, error) { // ------------------- fetch a small file + resp, err := client.Get(url) + if err != nil { return nil, err } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { return nil, SE("server returned status: %v", resp.Status) } + return io.ReadAll(io.LimitReader(resp.Body, max)) +} + +func checkaccess(NETS []string) { // ------------------------------------------------- check ip net based access match:=0; + addrs, err := net.InterfaceAddrs() + if err != nil { PE("Error getting addresses", err.Error()); os.Exit(1) } + for _, validnet := range NETS { - addrs, err := net.InterfaceAddrs() - if err != nil { PE("Error getting addresses"); os.Exit(1) } _, ipNet, err := net.ParseCIDR(validnet) + if err != nil { PE("invalid network", validnet); continue } for _, address := range addrs { if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() { if ipnet.IP.To4() != nil { @@ -102,8 +133,10 @@ func Enc(str string) string { // ----------------------------------------------- } func Dec(str string) string { // ----------------------------------------------------------------- decode string - enc, _ := basex.NewEncoding(Db64(CHRS)) - b, _ := enc.Decode(str) + enc, err := basex.NewEncoding(Db64(CHRS)) + if err != nil { return "" } + b, err := enc.Decode(str) + if err != nil || len(b) < 2 { return "" } return string(b[2:]) } @@ -193,7 +226,8 @@ func GETid(n int) string { // -------------------------------------------------- func Mytable(rows *sql.Rows) []map[string]interface{} { // ----------------------------- load mysql result table defer rows.Close() - columns, _ := rows.Columns() + columns, err := rows.Columns() + if err != nil { return nil } count := len(columns) tableData := make([]map[string]interface{}, 0) values := make([]interface{}, count) @@ -202,7 +236,7 @@ func Mytable(rows *sql.Rows) []map[string]interface{} { // --------------------- for i := 0; i < count; i++ { valuePtrs[i] = &values[i] } - rows.Scan(valuePtrs...) + if err := rows.Scan(valuePtrs...); err != nil { break } entry := make(map[string]interface{}) for i, col := range columns { var v interface{} @@ -248,6 +282,7 @@ func Isflagpassed(name string) bool { // --------------------------------------- } func Body(r *http.Response) string { // ------------------------------------------------------------ http body + defer r.Body.Close() body, err := io.ReadAll(r.Body) if err == nil { return string(body) @@ -281,14 +316,17 @@ func P(a ...any) (n int, err error) { return fmt.Fprintln(os.Std func PN(a ...any) (n int, err error) { return fmt.Fprint(os.Stdout, a...) } func PF(format string, a ...any) (n int, err error) { return fmt.Fprintf(os.Stdout, format, a...) } func SF(format string, a ...any) string { return fmt.Sprintf(format, a...) } +func SE(format string, a ...any) error { return fmt.Errorf(format, a...) } func PE(msg ...string) (n int, err error) { - if (len(msg)==2) { return fmt.Fprintf(os.Stdout, "%s: %s (%s)\n", Crb("ERROR"), Cwb(msg[0]),msg[1]) } + if (len(msg)==0) { return 0, nil } + if (len(msg)>=2) { return fmt.Fprintf(os.Stdout, "%s: %s (%s)\n", Crb("ERROR"), Cwb(msg[0]),msg[1]) } return fmt.Fprintf(os.Stdout, "%s: %s\n", Crb("ERROR"), Cwb(msg[0])) } func PO(msg ...string) (n int, err error) { - if (len(msg)==2) { return fmt.Fprintf(os.Stdout, "%s: %s (%s)\n", Cgb("OK"), Cwb(msg[0]),msg[1]) } - return fmt.Fprintf(os.Stdout, "%s: %s\n", Cgb("OK"), Cwb(msg[0])) + if (len(msg)==0) { return 0, nil } + if (len(msg)>=2) { return fmt.Fprintf(os.Stdout, "%s: %s (%s)\n", Cgb("OK"), Cwb(msg[0]),msg[1]) } + return fmt.Fprintf(os.Stdout, "%s: %s\n", Cgb("OK"), Cwb(msg[0])) } // ------------------------------------------------------------------------------------------ regular expression @@ -306,13 +344,16 @@ func Shortstr(s string, length int) string { if len(runes) <= length { return s } + if length <= 2 { + return ".." + } return string(runes[:length-2]) + ".." } func SR(str string, n int) string { return strings.Repeat(str, n) } func RemoveAllMatches(slice []string, target string) []string { // remove matching string from array - result := slice[:0] + result := make([]string, 0, len(slice)) // never modify the callers slice for _, v := range slice { if v != target { result = append(result, v) @@ -330,7 +371,7 @@ func prgname() string { return "" } exename := filepath.Base(exepath) - return exename + return strings.TrimSuffix(exename, ".exe") // windows: gbld.exe -> gbld } // ------------------------------------------------------------------------------------------ viper abbrevations @@ -342,21 +383,118 @@ func VB(key string) bool { return viper.GetBool(key) } func VI(key string) int { return viper.GetInt(key) } // ------------------------------------------------------------------------------------------------- text colors +// +// catppuccin mocha - https://terminalcolors.com/themes/catppuccin/mocha/ +// the palette is emitted as 24 bit color. terminals that do not announce truecolor via COLORTERM +// fall back to the basic ansi colors, so their own theme keeps deciding. NO_COLOR is honoured. -var Cr func(...interface{}) string = color.New(color.FgRed).SprintFunc() -var Cg func(...interface{}) string = color.New(color.FgGreen).SprintFunc() -var Cy func(...interface{}) string = color.New(color.FgYellow).SprintFunc() -var Cb func(...interface{}) string = color.New(color.FgBlue).SprintFunc() -var Cm func(...interface{}) string = color.New(color.FgMagenta).SprintFunc() -var Cc func(...interface{}) string = color.New(color.FgCyan).SprintFunc() -var Cw func(...interface{}) string = color.New(color.FgWhite).SprintFunc() +type rgb struct{ r, g, b int } -var Crb func(...interface{}) string = color.New(color.Bold, color.FgRed).SprintFunc() -var Cgb func(...interface{}) string = color.New(color.Bold, color.FgGreen).SprintFunc() -var Cyb func(...interface{}) string = color.New(color.Bold, color.FgYellow).SprintFunc() -var Cbb func(...interface{}) string = color.New(color.Bold, color.FgBlue).SprintFunc() -var Cmb func(...interface{}) string = color.New(color.Bold, color.FgMagenta).SprintFunc() -var Ccb func(...interface{}) string = color.New(color.Bold, color.FgCyan).SprintFunc() -var Cwb func(...interface{}) string = color.New(color.Bold, color.FgWhite).SprintFunc() +var ( // ------------------------------------------------------------------------- catppuccin mocha palette + CatRosewater = rgb{0xf5, 0xe0, 0xdc} + CatFlamingo = rgb{0xf2, 0xcd, 0xcd} + CatPink = rgb{0xf5, 0xc2, 0xe7} + CatMauve = rgb{0xcb, 0xa6, 0xf7} + CatRed = rgb{0xf3, 0x8b, 0xa8} + CatMaroon = rgb{0xeb, 0xa0, 0xac} + CatPeach = rgb{0xfa, 0xb3, 0x87} + CatYellow = rgb{0xf9, 0xe2, 0xaf} + CatGreen = rgb{0xa6, 0xe3, 0xa1} + CatTeal = rgb{0x94, 0xe2, 0xd5} + CatSky = rgb{0x89, 0xdc, 0xeb} + CatSapphire = rgb{0x74, 0xc7, 0xec} + CatBlue = rgb{0x89, 0xb4, 0xfa} + CatLavender = rgb{0xb4, 0xbe, 0xfe} + CatText = rgb{0xcd, 0xd6, 0xf4} + CatSubtext1 = rgb{0xba, 0xc2, 0xde} + CatSubtext0 = rgb{0xa6, 0xad, 0xc8} + CatOverlay1 = rgb{0x7f, 0x84, 0x9c} + CatSurface1 = rgb{0x45, 0x47, 0x5a} +) + +var catansi = map[string]rgb{ // ------------------------------- how mocha fills the ansi slots (bright = normal) + "black": CatSurface1, + "red": CatRed, + "green": CatGreen, + "yellow": CatYellow, + "blue": CatBlue, + "magenta": CatPink, + "cyan": CatTeal, + "white": CatSubtext1, + "default": CatText, +} + +func truecolor() bool { // ------------------------------------------ does the terminal understand 24 bit color + ct := strings.ToLower(os.Getenv("COLORTERM")) + return ct == "truecolor" || ct == "24bit" +} + +func cfunc(c rgb, fallback color.Attribute, attrs ...color.Attribute) func(...interface{}) string { + col := color.New(attrs...) + if truecolor() { + col.AddRGB(c.r, c.g, c.b) + } else { + col.Add(fallback) + } + return col.SprintFunc() +} + +// mocha maps its ansi slots to red/green/yellow/blue/pink/teal, bright is identical to normal - +// so the bold variants keep the hue and only add weight, exactly as before. + +var Cr func(...interface{}) string = cfunc(CatRed, color.FgRed) +var Cg func(...interface{}) string = cfunc(CatGreen, color.FgGreen) +var Cy func(...interface{}) string = cfunc(CatYellow, color.FgYellow) +var Cb func(...interface{}) string = cfunc(CatBlue, color.FgBlue) +var Cm func(...interface{}) string = cfunc(CatPink, color.FgMagenta) +var Cc func(...interface{}) string = cfunc(CatTeal, color.FgCyan) +var Cw func(...interface{}) string = cfunc(CatSubtext1, color.FgWhite) + +var Crb func(...interface{}) string = cfunc(CatRed, color.FgRed, color.Bold) +var Cgb func(...interface{}) string = cfunc(CatGreen, color.FgGreen, color.Bold) +var Cyb func(...interface{}) string = cfunc(CatYellow, color.FgYellow, color.Bold) +var Cbb func(...interface{}) string = cfunc(CatBlue, color.FgBlue, color.Bold) +var Cmb func(...interface{}) string = cfunc(CatPink, color.FgMagenta, color.Bold) +var Ccb func(...interface{}) string = cfunc(CatTeal, color.FgCyan, color.Bold) +var Cwb func(...interface{}) string = cfunc(CatText, color.FgWhite, color.Bold) // text: the themes foreground + +// ------------------------------------------------------------------------------------- survey prompt colors +// +// survey renders its prompts from templates that call {{color "green+hb"}} and friends, which normally +// ends up in mgutz/ansi - and that only speaks 16/256 colors. replacing the template function is enough +// to theme every prompt including its icons. survey computes its layout from a separate color free +// rendering, so the longer truecolor sequences cannot disturb the cursor placement. + +func init() { core.TemplateFuncsWithColor["color"] = catcolor } // must run before the first prompt is parsed + +func catcolor(style string) string { // ------------------------- mgutz/ansi style string -> catppuccin mocha + if style == "reset" { + return "\033[0m" + } + if !truecolor() || strings.Contains(style, ":") { // background colors stay with the original mapping + return ansi.ColorCode(style) + } + + name, attrs, _ := strings.Cut(style, "+") + c, ok := catansi[name] + if !ok { + return ansi.ColorCode(style) + } + + seq := "" + for _, a := range attrs { // 'h' (bright) is a no-op: mocha uses the same value for both + switch a { + case 'b': + seq += "1;" + case 'd': + seq += "2;" + case 'u': + seq += "4;" + case 'i': + seq += "7;" + } + } + return SF("\033[%s38;2;%d;%d;%dm", seq, c.r, c.g, c.b) +} // ========================================================================================================= END \ No newline at end of file