Skip to content

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

FileActionPurpose
.goreleaser.ymlCreateCross-platform release build config
.github/workflows/release.ymlCreateTag-triggered release workflow
internal/update/check_cache.goCreateShouldCheck / RecordCheck — TTL-based check gating
internal/update/check_cache_test.goCreateUnit tests for cache (white-box, package update)
internal/update/checker.goCreateCheckLatest — GitHub Releases API, version comparison
internal/update/checker_test.goCreateUnit tests for checker (httptest server)
internal/update/installer.goCreateInstall — download, SHA256 verify, extract, replace binary
internal/update/installer_test.goCreateUnit tests for installer (httptest server + temp binary)
cmd/update.goCreateupdate Cobra command, manual flow
cmd/root.goModifyAdd 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 check after writing.

  • [ ] Step 1: Install goreleaser (if not present)

    bash
    goreleaser --version 2>/dev/null || go install github.com/goreleaser/goreleaser/v2@latest
  • [ ] Step 2: Create .goreleaser.yml

    yaml
    version: 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: asc

    Key decisions:

    • wrap_in_directory: false → binary is at archive root (required by installer.go extraction logic)
    • files: [none*] → archives contain only the binary, no README/LICENSE
    • ignore: windows/arm64 → no Windows ARM64 build
    • Asset naming produces e.g. sap-devs_1.2.0_linux_amd64.tar.gz (no v prefix on version)
  • [ ] Step 3: Create .github/workflows/release.yml

    yaml
    name: 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: 0 gives goreleaser full git history for changelog generation.

  • [ ] Step 4: Verify goreleaser config is valid

    bash
    goreleaser check

    Expected: • config is valid (warnings about missing GoReleaser token are fine)

  • [ ] Step 5: Commit

    bash
    git 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:

    go
    package 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

    bash
    go build ./internal/update/...

    Expected: compile error (package does not exist yet)

  • [ ] Step 3: Implement check_cache.go

    Create internal/update/check_cache.go:

    go
    package 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

    bash
    go build ./internal/update/...
    go vet ./internal/update/...

    Expected: no errors (CI will run tests on ubuntu-latest)

  • [ ] Step 5: Commit

    bash
    git 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:

    go
    package 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

    bash
    go build ./internal/update/...

    Expected: compile error — CheckLatest and apiBase not defined

  • [ ] Step 3: Implement checker.go

    Create internal/update/checker.go:

    go
    package 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

    bash
    go build ./internal/update/...
    go vet ./internal/update/...

    Expected: no errors

  • [ ] Step 5: Commit

    bash
    git 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:

    go
    package 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

    bash
    go build ./internal/update/...

    Expected: compile error — Install, downloadBase, executableFn not defined

  • [ ] Step 3: Implement installer.go

    Create internal/update/installer.go:

    go
    package 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: executableFn is already declared in detect.go as var executableFn = os.Executable. Do NOT redeclare it. If detect.go is in internal/adapter/, then executableFn needs to be declared here in installer.go:

    go
    // At package level in installer.go (or a shared file):
    var executableFn = os.Executable

    The internal/update package is separate from internal/adapter, so declare executableFn in installer.go directly.

  • [ ] Step 4: Verify build and vet pass

    bash
    go build ./internal/update/...
    go vet ./internal/update/...

    Expected: no errors

  • [ ] Step 5: Commit

    bash
    git 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.go to confirm Version var

    Read cmd/version.go and confirm var Version = "dev" exists. Do not add it to update.go.

  • [ ] Step 2: Create cmd/update.go

    go
    package 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

    bash
    go build ./...
    go vet ./...

    Expected: no errors

  • [ ] Step 4: Smoke test the dev build guard

    bash
    go build -o sap-devs-test . && ./sap-devs-test update

    Expected output: cannot update a dev build (because Version == "dev" with no ldflags)

    bash
    rm sap-devs-test
  • [ ] Step 5: Commit

    bash
    git 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.go to confirm current state

    Verify rootCmd has no existing PersistentPreRunE or PersistentPostRunE. Confirm loadAdapters() and newContentLoader() are present. Note existing imports.

  • [ ] Step 2: Add updateHintCh package-level var and wire Pre/PostRunE

    Add to cmd/root.go — insert after the imports block, before var 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 string

    Add PersistentPreRunE and PersistentPostRunE to the rootCmd declaration. The full rootCmd becomes:

    go
    var 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 try update_check.json in the current directory if the helper returns "". Guard against this in PersistentPreRunE by checking the return value:

    go
    cacheDir := 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 the PersistentPreRunE body.

    Add new imports to cmd/root.go:

    • "time"
    • "github.com/SAP-samples/sap-devs-cli/internal/update"

    The repoURL constant and Version variable are already accessible within package cmd (defined in cmd/update.go and cmd/version.go respectively).

  • [ ] Step 3: Verify build and vet pass

    bash
    go 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:

    bash
    go build -ldflags "-X github.com/SAP-samples/sap-devs-cli/cmd.Version=0.0.1" -o sap-devs-test .
    ./sap-devs-test version

    Expected: sap-devs 0.0.1 printed, and after ~3s either a hint or nothing (network may or may not reach GitHub from this machine).

    bash
    rm sap-devs-test
  • [ ] Step 5: Commit

    bash
    git 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

    bash
    go build ./...
    go vet ./...

    Expected: no errors or warnings

  • [ ] Step 2: Verify new files exist

    Confirm these files are present:

    • .goreleaser.yml
    • .github/workflows/release.yml
    • internal/update/check_cache.go
    • internal/update/check_cache_test.go
    • internal/update/checker.go
    • internal/update/checker_test.go
    • internal/update/installer.go
    • internal/update/installer_test.go
    • cmd/update.go

    And cmd/root.go has been modified.

  • [ ] Step 3: Verify sap-devs update is registered

    bash
    go build -o sap-devs-test . && ./sap-devs-test --help

    Expected: update appears in the commands list.

    bash
    ./sap-devs-test update --help

    Expected: usage for the update command.

    bash
    rm sap-devs-test
  • [ ] Step 4: Verify goreleaser config

    bash
    goreleaser check

    Expected: • config is valid

  • [ ] Step 5: Final commit if anything was missed

    bash
    git status

    If any uncommitted changes remain, commit them now.

  • [ ] Step 6: Use superpowers:finishing-a-development-branch