sap-devs update Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add sap-devs update for self-update from GitHub Releases, a weekly background update hint, and a goreleaser + GitHub Actions release pipeline that produces versioned binaries.
Architecture: A three-file internal/update/ package provides ShouldCheck/RecordCheck (cache), CheckLatest (GitHub API), and Install (download, verify, replace). A thin cmd/update.go wires the manual flow. cmd/root.go gains PersistentPreRunE/PersistentPostRunE for the background goroutine. .goreleaser.yml + .github/workflows/release.yml complete the pipeline.
Tech Stack: Go stdlib only — archive/tar, compress/gzip, archive/zip, crypto/sha256, encoding/json, net/http. No external semver or archive libraries. goreleaser v2. GitHub Actions.
File Map
| File | Action | Purpose |
|---|---|---|
.goreleaser.yml | Create | Cross-platform release build config |
.github/workflows/release.yml | Create | Tag-triggered release workflow |
internal/update/check_cache.go | Create | ShouldCheck / RecordCheck — TTL-based check gating |
internal/update/check_cache_test.go | Create | Unit tests for cache (white-box, package update) |
internal/update/checker.go | Create | CheckLatest — GitHub Releases API, version comparison |
internal/update/checker_test.go | Create | Unit tests for checker (httptest server) |
internal/update/installer.go | Create | Install — download, SHA256 verify, extract, replace binary |
internal/update/installer_test.go | Create | Unit tests for installer (httptest server + temp binary) |
cmd/update.go | Create | update Cobra command, manual flow |
cmd/root.go | Modify | Add PersistentPreRunE / PersistentPostRunE for background check |
Task 1: Release pipeline — goreleaser config + GitHub Actions workflow
Files:
- Create:
.goreleaser.yml - Create:
.github/workflows/release.yml
No unit tests for config files. Verify with
goreleaser checkafter writing.
[ ] Step 1: Install goreleaser (if not present)
bashgoreleaser --version 2>/dev/null || go install github.com/goreleaser/goreleaser/v2@latest[ ] Step 2: Create
.goreleaser.ymlyamlversion: 2 builds: - main: . binary: sap-devs env: - CGO_ENABLED=0 goos: - linux - darwin - windows goarch: - amd64 - arm64 ignore: - goos: windows goarch: arm64 ldflags: - -X github.com/SAP-samples/sap-devs-cli/cmd.Version={{ .Version }} archives: - format: tar.gz format_overrides: - goos: windows format: zip name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" wrap_in_directory: false files: - none* checksum: name_template: checksums.txt algorithm: sha256 changelog: sort: ascKey decisions:
wrap_in_directory: false→ binary is at archive root (required byinstaller.goextraction logic)files: [none*]→ archives contain only the binary, no README/LICENSEignore: windows/arm64→ no Windows ARM64 build- Asset naming produces e.g.
sap-devs_1.2.0_linux_amd64.tar.gz(novprefix on version)
[ ] Step 3: Create
.github/workflows/release.ymlyamlname: Release on: push: tags: - 'v*' jobs: release: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: actions/setup-go@v5 with: go-version: 'stable' - uses: goreleaser/goreleaser-action@v6 with: version: latest args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}Note:
fetch-depth: 0gives goreleaser full git history for changelog generation.[ ] Step 4: Verify goreleaser config is valid
bashgoreleaser checkExpected:
• config is valid(warnings about missing GoReleaser token are fine)[ ] Step 5: Commit
bashgit add .goreleaser.yml .github/workflows/release.yml git commit -m "feat: add goreleaser config and release workflow"
Task 2: internal/update/check_cache.go — TTL-based update check gating
Files:
- Create:
internal/update/check_cache.go - Create:
internal/update/check_cache_test.go
Tests are white-box (package update) so they can manipulate the cache file path directly.
[ ] Step 1: Write the failing tests
Create
internal/update/check_cache_test.go:gopackage update import ( "encoding/json" "os" "path/filepath" "testing" "time" ) func TestShouldCheck_MissingFile(t *testing.T) { dir := t.TempDir() if !ShouldCheck(dir, time.Hour) { t.Fatal("expected true for missing cache file") } } func TestShouldCheck_RecentCheck(t *testing.T) { dir := t.TempDir() writeCache(t, dir, time.Now().Add(-10*time.Minute)) if ShouldCheck(dir, time.Hour) { t.Fatal("expected false: recent check within TTL") } } func TestShouldCheck_ExpiredCheck(t *testing.T) { dir := t.TempDir() writeCache(t, dir, time.Now().Add(-25*time.Hour)) if !ShouldCheck(dir, 24*time.Hour) { t.Fatal("expected true: check expired beyond TTL") } } func TestShouldCheck_CorruptFile(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "update_check.json") os.WriteFile(path, []byte("not json"), 0o644) if !ShouldCheck(dir, time.Hour) { t.Fatal("expected true: fail-open on corrupt file") } } func TestRecordCheck_WritesTimestamp(t *testing.T) { dir := t.TempDir() if err := RecordCheck(dir); err != nil { t.Fatalf("RecordCheck failed: %v", err) } path := filepath.Join(dir, "update_check.json") data, err := os.ReadFile(path) if err != nil { t.Fatalf("cache file not created: %v", err) } var rec struct { LastCheck string `json:"last_check"` } if err := json.Unmarshal(data, &rec); err != nil { t.Fatalf("invalid JSON in cache file: %v", err) } ts, err := time.Parse(time.RFC3339, rec.LastCheck) if err != nil { t.Fatalf("last_check is not RFC3339: %v", err) } if time.Since(ts) > 5*time.Second { t.Fatalf("last_check timestamp is too old: %v", ts) } } // writeCache writes a cache file with the given timestamp. func writeCache(t *testing.T, dir string, ts time.Time) { t.Helper() path := filepath.Join(dir, "update_check.json") data, _ := json.Marshal(map[string]string{"last_check": ts.Format(time.RFC3339)}) if err := os.WriteFile(path, data, 0o644); err != nil { t.Fatal(err) } }[ ] Step 2: Verify tests fail
bashgo build ./internal/update/...Expected: compile error (package does not exist yet)
[ ] Step 3: Implement
check_cache.goCreate
internal/update/check_cache.go:gopackage update import ( "encoding/json" "os" "path/filepath" "time" ) const cacheFile = "update_check.json" type checkRecord struct { LastCheck string `json:"last_check"` } // ShouldCheck returns true if enough time has passed since the last update check. // Returns true if the cache file is missing or unreadable (fail-open). func ShouldCheck(cacheDir string, ttl time.Duration) bool { path := filepath.Join(cacheDir, cacheFile) data, err := os.ReadFile(path) if err != nil { return true // missing or unreadable → check } var rec checkRecord if err := json.Unmarshal(data, &rec); err != nil { return true // corrupt → check } last, err := time.Parse(time.RFC3339, rec.LastCheck) if err != nil { return true // unparseable → check } return time.Since(last) >= ttl } // RecordCheck writes the current time to the cache file. // Only called after a successful response from CheckLatest (not on network errors). func RecordCheck(cacheDir string) error { path := filepath.Join(cacheDir, cacheFile) rec := checkRecord{LastCheck: time.Now().Format(time.RFC3339)} data, err := json.Marshal(rec) if err != nil { return err } return os.WriteFile(path, data, 0o644) }[ ] Step 4: Verify build succeeds
bashgo build ./internal/update/... go vet ./internal/update/...Expected: no errors (CI will run tests on ubuntu-latest)
[ ] Step 5: Commit
bashgit add internal/update/check_cache.go internal/update/check_cache_test.go git commit -m "feat: add update check cache (ShouldCheck, RecordCheck)"
Task 3: internal/update/checker.go — GitHub Releases API + version comparison
Files:
- Create:
internal/update/checker.go - Create:
internal/update/checker_test.go
The package-level var apiBase string allows tests to override the API endpoint to an httptest server.
[ ] Step 1: Write the failing tests
Create
internal/update/checker_test.go:gopackage update import ( "encoding/json" "net/http" "net/http/httptest" "testing" ) func TestCheckLatest_NewerAvailable(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]string{"tag_name": "v9.9.9"}) })) defer srv.Close() apiBase = srv.URL rel, newer, err := CheckLatest("https://example.com/owner/repo", "1.0.0") if err != nil { t.Fatalf("unexpected error: %v", err) } if !newer { t.Fatal("expected newer=true") } if rel.Version != "9.9.9" { t.Fatalf("expected Version=9.9.9, got %s", rel.Version) } if rel.TagName != "v9.9.9" { t.Fatalf("expected TagName=v9.9.9, got %s", rel.TagName) } } func TestCheckLatest_AlreadyLatest(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]string{"tag_name": "v1.0.0"}) })) defer srv.Close() apiBase = srv.URL _, newer, err := CheckLatest("https://example.com/owner/repo", "1.0.0") if err != nil { t.Fatalf("unexpected error: %v", err) } if newer { t.Fatal("expected newer=false") } } func TestCheckLatest_NetworkError(t *testing.T) { apiBase = "http://127.0.0.1:1" // nothing listening _, _, err := CheckLatest("https://example.com/owner/repo", "1.0.0") if err == nil { t.Fatal("expected error on network failure") } } func TestCheckLatest_MalformedJSON(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("not json")) })) defer srv.Close() apiBase = srv.URL _, _, err := CheckLatest("https://example.com/owner/repo", "1.0.0") if err == nil { t.Fatal("expected error on malformed JSON") } }[ ] Step 2: Verify tests fail
bashgo build ./internal/update/...Expected: compile error —
CheckLatestandapiBasenot defined[ ] Step 3: Implement
checker.goCreate
internal/update/checker.go:gopackage update import ( "encoding/json" "fmt" "net/http" "net/url" "strconv" "strings" ) // apiBase overrides the GitHub API base URL in tests. // When empty, it is derived from the repoURL (github enterprise pattern). var apiBase string // Release holds the relevant fields from a GitHub release. type Release struct { Version string // e.g. "1.2.0" (no leading "v") TagName string // e.g. "v1.2.0" } // CheckLatest fetches the latest GitHub release and returns it along with // whether it is newer than currentVersion. // Returns a real error on failure — callers decide whether to surface or swallow it. func CheckLatest(repoURL, currentVersion string) (*Release, bool, error) { apiURL, err := buildAPIURL(repoURL) if err != nil { return nil, false, fmt.Errorf("invalid repo URL: %w", err) } req, err := http.NewRequest("GET", apiURL, nil) if err != nil { return nil, false, err } req.Header.Set("Accept", "application/vnd.github+json") resp, err := http.DefaultClient.Do(req) if err != nil { return nil, false, fmt.Errorf("could not reach GitHub: %w", err) } defer resp.Body.Close() var result struct { TagName string `json:"tag_name"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, false, fmt.Errorf("could not parse release response: %w", err) } if result.TagName == "" { return nil, false, fmt.Errorf("release response missing tag_name") } rel := &Release{ TagName: result.TagName, Version: strings.TrimPrefix(result.TagName, "v"), } newer := compareVersions(rel.Version, strings.TrimPrefix(currentVersion, "v")) > 0 return rel, newer, nil } // buildAPIURL constructs the releases/latest API URL from a repo URL. // If apiBase is set (tests), uses that as the base directly. func buildAPIURL(repoURL string) (string, error) { if apiBase != "" { // tests: apiBase is the full server URL; just append a known path u, err := url.Parse(repoURL) if err != nil { return "", err } parts := strings.Split(strings.Trim(u.Path, "/"), "/") if len(parts) < 2 { return "", fmt.Errorf("expected owner/repo in path, got %q", u.Path) } owner, repo := parts[len(parts)-2], parts[len(parts)-1] return apiBase + "/repos/" + owner + "/" + repo + "/releases/latest", nil } u, err := url.Parse(repoURL) if err != nil { return "", err } parts := strings.Split(strings.Trim(u.Path, "/"), "/") if len(parts) < 2 { return "", fmt.Errorf("expected owner/repo in path, got %q", u.Path) } owner, repo := parts[len(parts)-2], parts[len(parts)-1] // GitHub Enterprise: <host>/api/v3/repos/<owner>/<repo>/releases/latest base := u.Scheme + "://" + u.Host + "/api/v3" return base + "/repos/" + owner + "/" + repo + "/releases/latest", nil } // compareVersions compares two "major.minor.patch" version strings (no "v" prefix). // Returns >0 if a > b, 0 if equal, <0 if a < b. // Uses integer comparison field by field. Missing fields treated as 0. func compareVersions(a, b string) int { aParts := strings.Split(a, ".") bParts := strings.Split(b, ".") n := len(aParts) if len(bParts) > n { n = len(bParts) } for i := 0; i < n; i++ { av, bv := 0, 0 if i < len(aParts) { av, _ = strconv.Atoi(aParts[i]) } if i < len(bParts) { bv, _ = strconv.Atoi(bParts[i]) } if av != bv { return av - bv } } return 0 }[ ] Step 4: Verify build and vet pass
bashgo build ./internal/update/... go vet ./internal/update/...Expected: no errors
[ ] Step 5: Commit
bashgit add internal/update/checker.go internal/update/checker_test.go git commit -m "feat: add CheckLatest with GitHub API and version comparison"
Task 4: internal/update/installer.go — download, verify, extract, replace
Files:
- Create:
internal/update/installer.go - Create:
internal/update/installer_test.go
The installer uses archive/tar + compress/gzip for Linux/macOS and archive/zip for Windows. Tests create real in-memory archives using stdlib. executableFn overrides os.Executable for testing. downloadBase overrides the download URL base.
[ ] Step 1: Write the failing tests
Create
internal/update/installer_test.go:gopackage update import ( "archive/tar" "bytes" "compress/gzip" "crypto/sha256" "fmt" "io" "net/http" "net/http/httptest" "os" "path/filepath" "runtime" "strings" "testing" ) // makeTarGz creates an in-memory tar.gz containing one file named `name` with `content`. func makeTarGz(name string, content []byte) []byte { var buf bytes.Buffer gz := gzip.NewWriter(&buf) tw := tar.NewWriter(gz) tw.WriteHeader(&tar.Header{Name: name, Mode: 0o755, Size: int64(len(content))}) tw.Write(content) tw.Close() gz.Close() return buf.Bytes() } // setupInstallServer creates an httptest server serving a tar.gz archive and checksums.txt. // Returns the server, the asset name, and the hex SHA256 of the archive. func setupInstallServer(t *testing.T, version string) (*httptest.Server, string) { t.Helper() binaryContent := []byte("fake-binary-content") assetName := fmt.Sprintf("sap-devs_%s_%s_%s.tar.gz", version, runtime.GOOS, runtime.GOARCH) archive := makeTarGz("sap-devs", binaryContent) sum := sha256.Sum256(archive) checksums := fmt.Sprintf("%x %s\n", sum, assetName) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case strings.HasSuffix(r.URL.Path, "checksums.txt"): w.Write([]byte(checksums)) case strings.HasSuffix(r.URL.Path, assetName): w.Write(archive) default: http.NotFound(w, r) } })) return srv, assetName } func TestInstall_Success(t *testing.T) { version := "9.9.9" srv, _ := setupInstallServer(t, version) defer srv.Close() downloadBase = srv.URL // Create a temp file to act as the "current binary" tmpDir := t.TempDir() fakeBin := filepath.Join(tmpDir, "sap-devs") os.WriteFile(fakeBin, []byte("old"), 0o755) executableFn = func() (string, error) { return fakeBin, nil } t.Cleanup(func() { executableFn = os.Executable; downloadBase = "" }) rel := &Release{Version: version, TagName: "v" + version} if err := Install("https://example.com/owner/repo", rel); err != nil { t.Fatalf("Install failed: %v", err) } got, _ := os.ReadFile(fakeBin) if string(got) != "fake-binary-content" { t.Fatalf("binary not replaced: got %q", got) } } func TestInstall_ChecksumMismatch(t *testing.T) { version := "9.9.9" assetName := fmt.Sprintf("sap-devs_%s_%s_%s.tar.gz", version, runtime.GOOS, runtime.GOARCH) archive := makeTarGz("sap-devs", []byte("fake-binary")) // Wrong hash in checksums.txt checksums := fmt.Sprintf("%x %s\n", sha256.Sum256([]byte("wrong")), assetName) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case strings.HasSuffix(r.URL.Path, "checksums.txt"): w.Write([]byte(checksums)) default: w.Write(archive) } })) defer srv.Close() downloadBase = srv.URL tmpDir := t.TempDir() fakeBin := filepath.Join(tmpDir, "sap-devs") os.WriteFile(fakeBin, []byte("old"), 0o755) executableFn = func() (string, error) { return fakeBin, nil } t.Cleanup(func() { executableFn = os.Executable; downloadBase = "" }) rel := &Release{Version: version, TagName: "v" + version} err := Install("https://example.com/owner/repo", rel) if err == nil || !strings.Contains(err.Error(), "checksum mismatch") { t.Fatalf("expected checksum mismatch error, got: %v", err) } } func TestInstall_UnsupportedPlatform(t *testing.T) { version := "9.9.9" // checksums.txt mentions a different platform, not current GOOS/GOARCH checksums := "abcd1234 sap-devs_9.9.9_plan9_mips.tar.gz\n" srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasSuffix(r.URL.Path, "checksums.txt") { w.Write([]byte(checksums)) } })) defer srv.Close() downloadBase = srv.URL tmpDir := t.TempDir() fakeBin := filepath.Join(tmpDir, "sap-devs") os.WriteFile(fakeBin, []byte("old"), 0o755) executableFn = func() (string, error) { return fakeBin, nil } t.Cleanup(func() { executableFn = os.Executable; downloadBase = "" }) rel := &Release{Version: version, TagName: "v" + version} err := Install("https://example.com/owner/repo", rel) if err == nil || !strings.Contains(err.Error(), "no release asset found") { t.Fatalf("expected 'no release asset found' error, got: %v", err) } }[ ] Step 2: Verify tests fail
bashgo build ./internal/update/...Expected: compile error —
Install,downloadBase,executableFnnot defined[ ] Step 3: Implement
installer.goCreate
internal/update/installer.go:gopackage update import ( "archive/tar" "archive/zip" "bytes" "compress/gzip" "crypto/sha256" "fmt" "io" "net/http" "os" "path/filepath" "runtime" "strings" ) // downloadBase overrides the releases download URL in tests. // When empty, repoURL is used as the base. var downloadBase string // Install downloads the release asset for the current OS/arch, verifies its // SHA256 checksum against checksums.txt, and replaces the running binary. func Install(repoURL string, release *Release) error { currentPath, err := executableFn() if err != nil { return fmt.Errorf("could not determine binary path: %w", err) } ext := ".tar.gz" if runtime.GOOS == "windows" { ext = ".zip" } assetName := fmt.Sprintf("sap-devs_%s_%s_%s%s", release.Version, runtime.GOOS, runtime.GOARCH, ext) base := downloadBase if base == "" { base = repoURL } downloadURL := base + "/releases/download/" + release.TagName + "/" // Download and verify checksum archive, err := httpGet(downloadURL + assetName) if err != nil { return fmt.Errorf("could not download %s: %w", assetName, err) } checksumData, err := httpGet(downloadURL + "checksums.txt") if err != nil { return fmt.Errorf("could not download checksums.txt: %w", err) } // Find expected hash for this asset expectedHash, err := findChecksum(checksumData, assetName) if err != nil { return err // "no release asset found for ..." } // Verify SHA256 actual := sha256.Sum256(archive) actualHex := fmt.Sprintf("%x", actual) if actualHex != expectedHash { return fmt.Errorf("checksum mismatch — download may be corrupt") } // Extract binary from archive binBytes, err := extractBinary(archive, ext) if err != nil { return fmt.Errorf("could not extract binary: %w", err) } // Write to temp file in same directory as current binary dir := filepath.Dir(currentPath) tmp, err := os.CreateTemp(dir, "sap-devs-update-*") if err != nil { return fmt.Errorf("could not create temp file: %w", err) } tmpPath := tmp.Name() if _, err := tmp.Write(binBytes); err != nil { tmp.Close() os.Remove(tmpPath) return fmt.Errorf("could not write temp file: %w", err) } tmp.Close() if err := os.Chmod(tmpPath, 0o755); err != nil { os.Remove(tmpPath) return err } // Replace binary (platform-specific) if runtime.GOOS == "windows" { // Windows locks running executables; remove then rename if err := os.Remove(currentPath); err != nil { os.Remove(tmpPath) return fmt.Errorf("could not remove old binary: %w", err) } } if err := os.Rename(tmpPath, currentPath); err != nil { // On Windows: original already removed, tmpPath still exists return fmt.Errorf("could not replace binary: %w", err) } return nil } func httpGet(url string) ([]byte, error) { resp, err := http.Get(url) //nolint:noctx if err != nil { return nil, err } defer resp.Body.Close() return io.ReadAll(resp.Body) } // findChecksum parses checksums.txt and returns the SHA256 hex for assetName. // Format: "<hex> <filename>" per line. func findChecksum(data []byte, assetName string) (string, error) { for _, line := range strings.Split(string(data), "\n") { line = strings.TrimSpace(line) if line == "" { continue } parts := strings.Fields(line) if len(parts) >= 2 && parts[1] == assetName { return parts[0], nil } } return "", fmt.Errorf("no release asset found for %s/%s", runtime.GOOS, runtime.GOARCH) } // extractBinary extracts the binary named "sap-devs" (or "sap-devs.exe" on Windows) // from the archive bytes. ext is ".tar.gz" or ".zip". func extractBinary(data []byte, ext string) ([]byte, error) { binName := "sap-devs" if runtime.GOOS == "windows" { binName = "sap-devs.exe" } if ext == ".zip" { return extractFromZip(data, binName) } return extractFromTarGz(data, binName) } func extractFromTarGz(data []byte, name string) ([]byte, error) { gz, err := gzip.NewReader(bytes.NewReader(data)) if err != nil { return nil, err } defer gz.Close() tr := tar.NewReader(gz) for { hdr, err := tr.Next() if err == io.EOF { break } if err != nil { return nil, err } if filepath.Base(hdr.Name) == name { return io.ReadAll(tr) } } return nil, fmt.Errorf("binary %q not found in archive", name) } func extractFromZip(data []byte, name string) ([]byte, error) { r, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) if err != nil { return nil, err } for _, f := range r.File { if filepath.Base(f.Name) == name { rc, err := f.Open() if err != nil { return nil, err } defer rc.Close() return io.ReadAll(rc) } } return nil, fmt.Errorf("binary %q not found in zip archive", name) }Note:
executableFnis already declared indetect.goasvar executableFn = os.Executable. Do NOT redeclare it. Ifdetect.gois ininternal/adapter/, thenexecutableFnneeds to be declared here ininstaller.go:go// At package level in installer.go (or a shared file): var executableFn = os.ExecutableThe
internal/updatepackage is separate frominternal/adapter, so declareexecutableFnininstaller.godirectly.[ ] Step 4: Verify build and vet pass
bashgo build ./internal/update/... go vet ./internal/update/...Expected: no errors
[ ] Step 5: Commit
bashgit add internal/update/installer.go internal/update/installer_test.go git commit -m "feat: add Install with download, SHA256 verification, and binary replacement"
Task 5: cmd/update.go — manual update Cobra command
Files:
- Create:
cmd/update.go
Version is already declared in cmd/version.go as var Version = "dev". Do NOT redeclare it here — it is shared across the cmd package.
[ ] Step 1: Review
cmd/version.goto confirmVersionvarRead
cmd/version.goand confirmvar Version = "dev"exists. Do not add it toupdate.go.[ ] Step 2: Create
cmd/update.gogopackage cmd import ( "fmt" "os" "github.com/spf13/cobra" "github.com/SAP-samples/sap-devs-cli/internal/update" ) // repoURL is the canonical repository URL used for update checks and downloads. // Accessible to all files in package cmd (e.g. root.go background check). const repoURL = "https://github.com/SAP-samples/sap-devs-cli" var updateCmd = &cobra.Command{ Use: "update", Short: "Update sap-devs to the latest release", Long: `Check for a newer release on GitHub and install it if found.`, RunE: func(cmd *cobra.Command, args []string) error { if Version == "dev" { fmt.Fprintln(os.Stderr, "cannot update a dev build") return nil } fmt.Println("Checking for updates...") rel, newer, err := update.CheckLatest(repoURL, Version) if err != nil { return fmt.Errorf("could not reach GitHub: %w", err) } if !newer { fmt.Printf("sap-devs v%s is already up to date.\n", Version) return nil } fmt.Printf("Updating sap-devs v%s → %s...\n", Version, rel.TagName) if err := update.Install(repoURL, rel); err != nil { return err } fmt.Printf("✓ Updated to %s. Restart your shell if needed.\n", rel.TagName) return nil }, } func init() { rootCmd.AddCommand(updateCmd) }[ ] Step 3: Verify build and vet pass
bashgo build ./... go vet ./...Expected: no errors
[ ] Step 4: Smoke test the dev build guard
bashgo build -o sap-devs-test . && ./sap-devs-test updateExpected output:
cannot update a dev build(becauseVersion == "dev"with no ldflags)bashrm sap-devs-test[ ] Step 5: Commit
bashgit add cmd/update.go git commit -m "feat: add update command (manual self-update flow)"
Task 6: Wire background update check into cmd/root.go
Files:
- Modify:
cmd/root.go
Add PersistentPreRunE and PersistentPostRunE to rootCmd. The background goroutine result is communicated via a buffered chan string. The channel is reset to nil at the top of each PersistentPreRunE invocation so that in-process test runs (multiple commands in one process) don't observe a stale channel.
[ ] Step 1: Read
cmd/root.goto confirm current stateVerify
rootCmdhas no existingPersistentPreRunEorPersistentPostRunE. ConfirmloadAdapters()andnewContentLoader()are present. Note existing imports.[ ] Step 2: Add
updateHintChpackage-level var and wire Pre/PostRunEAdd to
cmd/root.go— insert after the imports block, beforevar rootCmd:go// updateHintCh carries a background update hint to PersistentPostRunE. // Reset to nil at the top of each PersistentPreRunE to avoid stale channels // across multiple command invocations in a single process (e.g. tests). var updateHintCh chan stringAdd
PersistentPreRunEandPersistentPostRunEto therootCmddeclaration. The fullrootCmdbecomes:govar rootCmd = &cobra.Command{ Use: "sap-devs", Short: "AI-first SAP developer toolkit", Long: `sap-devs injects up-to-date SAP developer knowledge into your AI tools.`, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { updateHintCh = nil // reset before every invocation // Skip background check for the "update" command itself and dev builds if cmd.Name() == "update" || Version == "dev" { return nil } if !update.ShouldCheck(mustCacheDir(), 168*time.Hour) { return nil } updateHintCh = make(chan string, 1) go func() { rel, newer, err := update.CheckLatest(repoURL, Version) if err == nil { update.RecordCheck(mustCacheDir()) if newer { updateHintCh <- "↻ sap-devs " + rel.TagName + " available — run 'sap-devs update' to install" } } // on error: channel stays empty, hint is skipped, RecordCheck not called }() return nil }, PersistentPostRunE: func(cmd *cobra.Command, args []string) error { if updateHintCh == nil { return nil } select { case hint := <-updateHintCh: fmt.Fprintln(os.Stderr, hint) case <-time.After(3 * time.Second): // goroutine too slow or no update available — skip silently } return nil }, }Add a helper to get the cache directory (avoids duplicating xdg setup):
go// mustCacheDir returns the XDG cache directory, or empty string on failure. // Used for background update check — empty string means check is skipped. func mustCacheDir() string { paths, err := xdg.New() if err != nil { return "" } return paths.CacheDir }Important:
ShouldCheck("", ttl)would tryupdate_check.jsonin the current directory if the helper returns"". Guard against this inPersistentPreRunEby checking the return value:gocacheDir := mustCacheDir() if cacheDir == "" { return nil // can't determine cache dir; skip check silently } if !update.ShouldCheck(cacheDir, 168*time.Hour) { return nil } updateHintCh = make(chan string, 1) go func() { rel, newer, err := update.CheckLatest(repoURL, Version) if err == nil { update.RecordCheck(cacheDir) if newer { updateHintCh <- "↻ sap-devs " + rel.TagName + " available — run 'sap-devs update' to install" } } }()Use this expanded form instead of the inline
mustCacheDir()calls shown earlier in thePersistentPreRunEbody.Add new imports to
cmd/root.go:"time""github.com/SAP-samples/sap-devs-cli/internal/update"
The
repoURLconstant andVersionvariable are already accessible withinpackage cmd(defined incmd/update.goandcmd/version.gorespectively).[ ] Step 3: Verify build and vet pass
bashgo build ./... go vet ./...Expected: no errors
[ ] Step 4: Manual smoke test of the full binary
Build with a fake version to trigger the update check flow:
bashgo build -ldflags "-X github.com/SAP-samples/sap-devs-cli/cmd.Version=0.0.1" -o sap-devs-test . ./sap-devs-test versionExpected:
sap-devs 0.0.1printed, and after ~3s either a hint or nothing (network may or may not reach GitHub from this machine).bashrm sap-devs-test[ ] Step 5: Commit
bashgit add cmd/root.go git commit -m "feat: wire background update check into root command pre/post run hooks"
Task 7: Final verification
[ ] Step 1: Full build verification
bashgo build ./... go vet ./...Expected: no errors or warnings
[ ] Step 2: Verify new files exist
Confirm these files are present:
.goreleaser.yml.github/workflows/release.ymlinternal/update/check_cache.gointernal/update/check_cache_test.gointernal/update/checker.gointernal/update/checker_test.gointernal/update/installer.gointernal/update/installer_test.gocmd/update.go
And
cmd/root.gohas been modified.[ ] Step 3: Verify
sap-devs updateis registeredbashgo build -o sap-devs-test . && ./sap-devs-test --helpExpected:
updateappears in the commands list.bash./sap-devs-test update --helpExpected: usage for the update command.
bashrm sap-devs-test[ ] Step 4: Verify goreleaser config
bashgoreleaser checkExpected:
• config is valid[ ] Step 5: Final commit if anything was missed
bashgit statusIf any uncommitted changes remain, commit them now.
[ ] Step 6: Use
superpowers:finishing-a-development-branch