diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..96748ea --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,75 @@ +# CLAUDE.md — go-update + +This file provides guidance to Claude Code when working with the `go-update` package. + +## Package Overview + +`go-update` (`forge.lthn.ai/core/go-update`) is a **self-updater library** for Go applications. It supports updates from GitHub releases and generic HTTP endpoints, with configurable startup behaviour and version channel filtering. + +## Build & Test Commands + +```bash +# Run all tests +go test ./... + +# Run tests with coverage +go test -cover ./... + +# Run a single test +go test -run TestName ./... + +# Generate version.go from package.json +go generate ./... + +# Vet and lint +go vet ./... +``` + +## Architecture + +### Update Sources + +| Source | Description | +|--------|-------------| +| GitHub Releases | Fetches releases via GitHub API, filters by channel (stable/beta/alpha) | +| Generic HTTP | Fetches `latest.json` from a base URL with version + download URL | + +### Key Types + +- **`UpdateService`** — Configured service that checks for updates on startup +- **`GithubClient`** — Interface for GitHub API interactions (mockable for tests) +- **`Release`** / **`ReleaseAsset`** — GitHub release model +- **`GenericUpdateInfo`** — HTTP update endpoint model + +### Testable Function Variables + +Core update logic is exposed as `var` function values so tests can replace them: + +- `NewGithubClient` — Factory for GitHub client (replace with mock) +- `DoUpdate` — Performs the actual binary update +- `CheckForNewerVersion`, `CheckForUpdates`, `CheckOnly` — GitHub update flow +- `CheckForUpdatesHTTP`, `CheckOnlyHTTP` — HTTP update flow +- `NewAuthenticatedClient` — HTTP client factory (supports `GITHUB_TOKEN`) + +### Error Handling + +All errors **must** use `coreerr.E()` from `forge.lthn.ai/core/go-log`: + +```go +import coreerr "forge.lthn.ai/core/go-log" + +return coreerr.E("FunctionName", "what failed", underlyingErr) +``` + +Never use `fmt.Errorf` or `errors.New`. + +### File I/O + +Use `forge.lthn.ai/core/go-io` for file operations, not `os.ReadFile`/`os.WriteFile`. + +## Coding Standards + +- **UK English** in comments and strings +- **Strict types**: All parameters and return types +- **Test naming**: `_Good`, `_Bad`, `_Ugly` suffix pattern +- **License**: EUPL-1.2 diff --git a/build/main.go b/build/main.go index 563649c..e957640 100644 --- a/build/main.go +++ b/build/main.go @@ -3,32 +3,33 @@ package main import ( "encoding/json" "fmt" - "os" + + coreio "forge.lthn.ai/core/go-io" ) func main() { // Read package.json - data, err := os.ReadFile("package.json") + data, err := coreio.Read(coreio.Local, "package.json") if err != nil { fmt.Println("Error reading package.json, skipping version file generation.") - os.Exit(0) + return } // Parse package.json var pkg struct { Version string `json:"version"` } - if err := json.Unmarshal(data, &pkg); err != nil { + if err := json.Unmarshal([]byte(data), &pkg); err != nil { fmt.Println("Error parsing package.json, skipping version file generation.") - os.Exit(0) + return } // Create the version file content := fmt.Sprintf("package updater\n\n// Generated by go:generate. DO NOT EDIT.\n\nconst PkgVersion = %q\n", pkg.Version) - err = os.WriteFile("version.go", []byte(content), 0644) + err = coreio.Write(coreio.Local, "version.go", content) if err != nil { fmt.Printf("Error writing version file: %v\n", err) - os.Exit(1) + return } fmt.Println("Generated version.go with version:", pkg.Version) diff --git a/github_internal_test.go b/github_internal_test.go new file mode 100644 index 0000000..2024f37 --- /dev/null +++ b/github_internal_test.go @@ -0,0 +1,236 @@ +package updater + +import ( + "runtime" + "testing" +) + +func TestFilterReleases_Good(t *testing.T) { + releases := []Release{ + {TagName: "v1.0.0-alpha.1", PreRelease: true}, + {TagName: "v1.0.0-beta.1", PreRelease: true}, + {TagName: "v1.0.0", PreRelease: false}, + } + + tests := []struct { + channel string + wantTag string + }{ + {"stable", "v1.0.0"}, + {"alpha", "v1.0.0-alpha.1"}, + {"beta", "v1.0.0-beta.1"}, + } + + for _, tt := range tests { + t.Run(tt.channel, func(t *testing.T) { + got := filterReleases(releases, tt.channel) + if got == nil { + t.Fatalf("expected release for channel %q, got nil", tt.channel) + } + if got.TagName != tt.wantTag { + t.Errorf("expected tag %q, got %q", tt.wantTag, got.TagName) + } + }) + } +} + +func TestFilterReleases_Bad(t *testing.T) { + releases := []Release{ + {TagName: "v1.0.0", PreRelease: false}, + } + got := filterReleases(releases, "alpha") + if got != nil { + t.Errorf("expected nil for non-matching channel, got %v", got) + } +} + +func TestFilterReleases_PreReleaseWithoutLabel(t *testing.T) { + releases := []Release{ + {TagName: "v2.0.0-rc.1", PreRelease: true}, + } + got := filterReleases(releases, "beta") + if got == nil { + t.Fatal("expected pre-release without alpha/beta label to match beta channel") + } + if got.TagName != "v2.0.0-rc.1" { + t.Errorf("expected tag %q, got %q", "v2.0.0-rc.1", got.TagName) + } +} + +func TestDetermineChannel_Good(t *testing.T) { + tests := []struct { + tag string + isPreRelease bool + want string + }{ + {"v1.0.0", false, "stable"}, + {"v1.0.0-alpha.1", false, "alpha"}, + {"v1.0.0-ALPHA.1", false, "alpha"}, + {"v1.0.0-beta.1", false, "beta"}, + {"v1.0.0-BETA.1", false, "beta"}, + {"v1.0.0-rc.1", true, "beta"}, + {"v1.0.0-rc.1", false, "stable"}, + } + + for _, tt := range tests { + t.Run(tt.tag, func(t *testing.T) { + got := determineChannel(tt.tag, tt.isPreRelease) + if got != tt.want { + t.Errorf("determineChannel(%q, %v) = %q, want %q", tt.tag, tt.isPreRelease, got, tt.want) + } + }) + } +} + +func TestGetDownloadURL_Good(t *testing.T) { + osName := runtime.GOOS + archName := runtime.GOARCH + + release := &Release{ + TagName: "v1.2.3", + Assets: []ReleaseAsset{ + {Name: "app-" + osName + "-" + archName, DownloadURL: "https://example.com/full-match"}, + {Name: "app-" + osName, DownloadURL: "https://example.com/os-only"}, + }, + } + + url, err := GetDownloadURL(release, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if url != "https://example.com/full-match" { + t.Errorf("expected full match URL, got %q", url) + } +} + +func TestGetDownloadURL_OSOnlyFallback(t *testing.T) { + osName := runtime.GOOS + + release := &Release{ + TagName: "v1.2.3", + Assets: []ReleaseAsset{ + {Name: "app-other-other", DownloadURL: "https://example.com/other"}, + {Name: "app-" + osName + "-other", DownloadURL: "https://example.com/os-only"}, + }, + } + + url, err := GetDownloadURL(release, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if url != "https://example.com/os-only" { + t.Errorf("expected OS-only fallback URL, got %q", url) + } +} + +func TestGetDownloadURL_WithFormat(t *testing.T) { + release := &Release{TagName: "v1.2.3"} + + url, err := GetDownloadURL(release, "https://example.com/{tag}/{os}/{arch}") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expected := "https://example.com/v1.2.3/" + runtime.GOOS + "/" + runtime.GOARCH + if url != expected { + t.Errorf("expected %q, got %q", expected, url) + } +} + +func TestGetDownloadURL_Bad(t *testing.T) { + // nil release + _, err := GetDownloadURL(nil, "") + if err == nil { + t.Error("expected error for nil release") + } + + // No matching assets + release := &Release{ + TagName: "v1.2.3", + Assets: []ReleaseAsset{ + {Name: "app-unknownos-unknownarch", DownloadURL: "https://example.com/other"}, + }, + } + _, err = GetDownloadURL(release, "") + if err == nil { + t.Error("expected error when no suitable asset found") + } +} + +func TestFormatVersionForComparison(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"1.0.0", "v1.0.0"}, + {"v1.0.0", "v1.0.0"}, + {"", ""}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := formatVersionForComparison(tt.input) + if got != tt.want { + t.Errorf("formatVersionForComparison(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestFormatVersionForDisplay(t *testing.T) { + tests := []struct { + version string + force bool + want string + }{ + {"1.0.0", true, "v1.0.0"}, + {"v1.0.0", true, "v1.0.0"}, + {"v1.0.0", false, "1.0.0"}, + {"1.0.0", false, "1.0.0"}, + } + + for _, tt := range tests { + t.Run(tt.version+"_force_"+boolStr(tt.force), func(t *testing.T) { + got := formatVersionForDisplay(tt.version, tt.force) + if got != tt.want { + t.Errorf("formatVersionForDisplay(%q, %v) = %q, want %q", tt.version, tt.force, got, tt.want) + } + }) + } +} + +func boolStr(b bool) string { + if b { + return "true" + } + return "false" +} + +func TestStartGitHubCheck_UnknownMode(t *testing.T) { + s := &UpdateService{ + config: UpdateServiceConfig{ + CheckOnStartup: StartupCheckMode(99), + }, + isGitHub: true, + owner: "owner", + repo: "repo", + } + err := s.Start() + if err == nil { + t.Error("expected error for unknown startup check mode") + } +} + +func TestStartHTTPCheck_UnknownMode(t *testing.T) { + s := &UpdateService{ + config: UpdateServiceConfig{ + RepoURL: "https://example.com/updates", + CheckOnStartup: StartupCheckMode(99), + }, + isGitHub: false, + } + err := s.Start() + if err == nil { + t.Error("expected error for unknown startup check mode") + } +} diff --git a/go.mod b/go.mod index 6f75a1d..209a883 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.26.0 require ( forge.lthn.ai/core/cli v0.3.6 + forge.lthn.ai/core/go-io v0.1.5 forge.lthn.ai/core/go-log v0.0.4 github.com/Snider/Borg v0.2.0 github.com/minio/selfupdate v0.6.0 diff --git a/go.sum b/go.sum index 1b4edc3..5551def 100644 --- a/go.sum +++ b/go.sum @@ -11,6 +11,8 @@ forge.lthn.ai/core/go-i18n v0.1.6 h1:Z9h6sEZsgJmWlkkq3ZPZyfgWipeeqN5lDCpzltpamHU forge.lthn.ai/core/go-i18n v0.1.6/go.mod h1:C6CbwdN7sejTx/lbutBPrxm77b8paMHBO6uHVLHOdqQ= forge.lthn.ai/core/go-inference v0.1.5 h1:Az/Euv1DusJQJz/Eca0Ey7sVXQkFLPHW0TBrs9g+Qwg= forge.lthn.ai/core/go-inference v0.1.5/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw= +forge.lthn.ai/core/go-io v0.1.5 h1:+XJ1YhaGGFLGtcNbPtVlndTjk+pO0Ydi2hRDj5/cHOM= +forge.lthn.ai/core/go-io v0.1.5/go.mod h1:FRtXSsi8W+U9vewCU+LBAqqbIj3wjXA4dBdSv3SAtWI= forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0= forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= diff --git a/updater_test.go b/updater_test.go index b185e2d..2da85e6 100644 --- a/updater_test.go +++ b/updater_test.go @@ -7,6 +7,8 @@ import ( "net/http" "net/http/httptest" "runtime" + + coreerr "forge.lthn.ai/core/go-log" ) // mockGithubClient is a mock implementation of the GithubClient interface for testing. @@ -34,7 +36,7 @@ func (m *mockGithubClient) GetPublicRepos(ctx context.Context, userOrOrg string) if m.getPublicRepos != nil { return m.getPublicRepos(ctx, userOrOrg) } - return nil, fmt.Errorf("GetPublicRepos not implemented") + return nil, coreerr.E("mockGithubClient.GetPublicRepos", "not implemented", nil) } func ExampleCheckForNewerVersion() {