From 1698664089e926df1123e2b3d4af5558dc5164d8 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Thu, 16 Jul 2026 23:53:09 +0200 Subject: [PATCH] fix: surface update notice even when a command fails The update notifier ran only at the end of main(), so any command that exited via fatal() (e.g. `asobi health` against a down backend) called os.Exit before the notice - and os.Exit skips deferred work, so a main-level defer wouldn't help either. New users, whose early commands often fail, never got nudged to upgrade. Route every exit through a single exit() chokepoint that emits the notice first; fatal() and the direct os.Exit sites now go through it. Add a writer seam (notifyUpdateTo) and a hermetic test driving the failing- command path off a seeded cache. --- cmd/asobi/main.go | 33 +++++++++++++++++++++++++-------- cmd/asobi/main_test.go | 39 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 9 deletions(-) diff --git a/cmd/asobi/main.go b/cmd/asobi/main.go index 98a0eb2..d7fb66e 100644 --- a/cmd/asobi/main.go +++ b/cmd/asobi/main.go @@ -4,6 +4,7 @@ import ( "bufio" "encoding/json" "fmt" + "io" "os" "strconv" "strings" @@ -31,7 +32,7 @@ var ( func main() { if len(os.Args) < 2 { usage() - os.Exit(1) + exit(1) } switch os.Args[1] { @@ -80,10 +81,10 @@ func main() { default: fmt.Fprintf(os.Stderr, "Unknown command: %s\n", os.Args[1]) usage() - os.Exit(1) + exit(1) } - update.Notify(version, os.Args[1], os.Stderr) + notifyUpdate() } func usage() { @@ -355,7 +356,7 @@ func firstArg(args []string) string { func cmdConfig() { if len(os.Args) < 3 { fmt.Println("Usage: asobi config ") - os.Exit(1) + exit(1) } switch os.Args[2] { @@ -376,7 +377,7 @@ func cmdConfig() { if len(os.Args) < 5 { fmt.Println("Usage: asobi config set ") fmt.Println("Keys: url, api_key, saas_url") - os.Exit(1) + exit(1) } key, value := os.Args[3], os.Args[4] cfg, err := config.Load() @@ -401,7 +402,7 @@ func cmdConfig() { default: fmt.Println("Usage: asobi config ") - os.Exit(1) + exit(1) } } @@ -433,7 +434,23 @@ func startSpinner() func() { func fatal(format string, args ...any) { fmt.Fprintf(os.Stderr, "Error: "+format+"\n", args...) - os.Exit(1) + exit(1) +} + +// exit is the single process-exit chokepoint. It surfaces the update notice +// before terminating, so a failing command (which reaches os.Exit via fatal, +// bypassing any deferred main-level notice) still nudges the user to upgrade. +func exit(code int) { + notifyUpdate() + os.Exit(code) +} + +func notifyUpdate() { notifyUpdateTo(os.Stderr) } + +func notifyUpdateTo(w io.Writer) { + if len(os.Args) > 1 { + update.Notify(version, os.Args[1], w) + } } // --- Destroy --- @@ -460,7 +477,7 @@ func cmdDestroy() { func cmdEnv() { if len(os.Args) < 3 { fmt.Println("Usage: asobi env list [--ephemeral] [--json]") - os.Exit(1) + exit(1) } switch os.Args[2] { diff --git a/cmd/asobi/main_test.go b/cmd/asobi/main_test.go index 0aee12e..4c7aba7 100644 --- a/cmd/asobi/main_test.go +++ b/cmd/asobi/main_test.go @@ -1,6 +1,43 @@ package main -import "testing" +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// A command that exits via fatal() must still surface the upgrade notice: +// os.Exit skips deferred main-level notices, so failing commands route +// through exit() -> notifyUpdate. This drives that wiring off a seeded +// cache so it never touches the network. +func TestNotifyUpdateWiring(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Setenv("ASOBI_NO_UPDATE_CHECK", "") + t.Setenv("CI", "") + asobiDir := filepath.Join(dir, ".asobi") + if err := os.MkdirAll(asobiDir, 0o700); err != nil { + t.Fatal(err) + } + cache := `{"checked_at":"` + time.Now().Format(time.RFC3339) + `","latest":"v9.9.9"}` + if err := os.WriteFile(filepath.Join(asobiDir, "version_check.json"), []byte(cache), 0o600); err != nil { + t.Fatal(err) + } + + oldVersion, oldArgs := version, os.Args + version = "v0.0.1" + os.Args = []string{"asobi", "health"} + t.Cleanup(func() { version, os.Args = oldVersion, oldArgs }) + + var buf bytes.Buffer + notifyUpdateTo(&buf) + if !strings.Contains(buf.String(), "v9.9.9") { + t.Errorf("failing-command exit path did not surface upgrade notice; got %q", buf.String()) + } +} func TestIsHealthy(t *testing.T) { for _, s := range []string{"ok", "healthy"} {