diff --git a/docs/commands.generated.md b/docs/commands.generated.md index 7aae4538a..740530403 100644 --- a/docs/commands.generated.md +++ b/docs/commands.generated.md @@ -672,6 +672,8 @@ Generated from `gog schema --json`. - [`gog tasks (task) update (edit,set) [flags]`](commands/gog-tasks-update.md) - Update a task - [`gog time [flags]`](commands/gog-time.md) - Local time utilities - [`gog time now [flags]`](commands/gog-time-now.md) - Show current time + - [`gog update [flags]`](commands/gog-update.md) - Check gogcli release status + - [`gog update status (check) [flags]`](commands/gog-update-status.md) - Show installed and latest gogcli release status - [`gog upload (up,put) [flags]`](commands/gog-upload.md) - Upload a file to Drive (alias for 'drive upload') - [`gog version [flags]`](commands/gog-version.md) - Print version - [`gog whoami (who-am-i) [flags]`](commands/gog-whoami.md) - Show your profile (alias for 'people me') diff --git a/docs/commands/README.md b/docs/commands/README.md index 022456e40..31e796e45 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -2,7 +2,7 @@ Every `gog` command has a generated docs page. The source of truth is the live CLI schema; run `make docs-commands` after changing command names, flags, help text, aliases, or arguments. -Generated pages: 700. +Generated pages: 702. ## Top-level Commands @@ -46,6 +46,7 @@ Generated pages: 700. - [gog status](gog-status.md) - Show auth/config status (alias for 'auth status') - [gog tasks](gog-tasks.md) - Google Tasks - [gog time](gog-time.md) - Local time utilities +- [gog update](gog-update.md) - Check gogcli release status - [gog upload](gog-upload.md) - Upload a file to Drive (alias for 'drive upload') - [gog version](gog-version.md) - Print version - [gog whoami](gog-whoami.md) - Show your profile (alias for 'people me') @@ -724,6 +725,8 @@ Generated pages: 700. - [gog tasks update](gog-tasks-update.md) - Update a task - [gog time](gog-time.md) - Local time utilities - [gog time now](gog-time-now.md) - Show current time + - [gog update](gog-update.md) - Check gogcli release status + - [gog update status](gog-update-status.md) - Show installed and latest gogcli release status - [gog upload](gog-upload.md) - Upload a file to Drive (alias for 'drive upload') - [gog version](gog-version.md) - Print version - [gog whoami](gog-whoami.md) - Show your profile (alias for 'people me') diff --git a/docs/commands/gog-update-status.md b/docs/commands/gog-update-status.md new file mode 100644 index 000000000..f192b3ffb --- /dev/null +++ b/docs/commands/gog-update-status.md @@ -0,0 +1,47 @@ +# `gog update status` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Show installed and latest gogcli release status + +## Usage + +```bash +gog update status (check) [flags] +``` + +## Parent + +- [gog update](gog-update.md) + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`
`--account`
`--acct` | `string` | | Account email, alias, or auto for authenticated Google API commands | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed | +| `-n`
`--dry-run`
`--dryrun`
`--noop`
`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | +| `--enable-commands` | `string` | | Comma-separated list of enabled command prefixes; dot paths allowed (restricts CLI) | +| `--enable-commands-exact` | `string` | | Comma-separated list of exact enabled commands; dot paths allowed and parent commands do not enable children | +| `-y`
`--force`
`--assume-yes`
`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `--home` | `string` | | Override gogcli config/data/state/cache root (equivalent to GOG_HOME) | +| `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--readonly` | `bool` | false | Block mutating API requests at runtime; auth add also requests read-only OAuth scopes | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `--timeout` | `time.Duration` | 10s | HTTP timeout for GitHub release metadata | +| `-v`
`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | +| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers | + +## See Also + +- [gog update](gog-update.md) +- [Command index](README.md) diff --git a/docs/commands/gog-update.md b/docs/commands/gog-update.md new file mode 100644 index 000000000..a758e936a --- /dev/null +++ b/docs/commands/gog-update.md @@ -0,0 +1,50 @@ +# `gog update` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Check gogcli release status + +## Usage + +```bash +gog update [flags] +``` + +## Parent + +- [gog](gog.md) + +## Subcommands + +- [gog update status](gog-update-status.md) - Show installed and latest gogcli release status + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`
`--account`
`--acct` | `string` | | Account email, alias, or auto for authenticated Google API commands | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed | +| `-n`
`--dry-run`
`--dryrun`
`--noop`
`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | +| `--enable-commands` | `string` | | Comma-separated list of enabled command prefixes; dot paths allowed (restricts CLI) | +| `--enable-commands-exact` | `string` | | Comma-separated list of exact enabled commands; dot paths allowed and parent commands do not enable children | +| `-y`
`--force`
`--assume-yes`
`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `--home` | `string` | | Override gogcli config/data/state/cache root (equivalent to GOG_HOME) | +| `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--readonly` | `bool` | false | Block mutating API requests at runtime; auth add also requests read-only OAuth scopes | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `-v`
`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | +| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers | + +## See Also + +- [gog](gog.md) +- [Command index](README.md) diff --git a/docs/commands/gog.md b/docs/commands/gog.md index 8d8498c00..91c5ea24f 100644 --- a/docs/commands/gog.md +++ b/docs/commands/gog.md @@ -56,6 +56,7 @@ gog [flags] - [gog status](gog-status.md) - Show auth/config status (alias for 'auth status') - [gog tasks](gog-tasks.md) - Google Tasks - [gog time](gog-time.md) - Local time utilities +- [gog update](gog-update.md) - Check gogcli release status - [gog upload](gog-upload.md) - Upload a file to Drive (alias for 'drive upload') - [gog version](gog-version.md) - Print version - [gog whoami](gog-whoami.md) - Show your profile (alias for 'people me') diff --git a/internal/cmd/gmail_watch_state.go b/internal/cmd/gmail_watch_state.go index 35790bd97..e2f7e5b8f 100644 --- a/internal/cmd/gmail_watch_state.go +++ b/internal/cmd/gmail_watch_state.go @@ -51,7 +51,7 @@ func gmailWatchStatePath(layout config.Layout, account string) (string, error) { func sanitizeAccountForPath(account string) string { clean := strings.TrimSpace(strings.ToLower(account)) if clean == "" { - return "unknown" + return trackingUnknown } var builder strings.Builder diff --git a/internal/cmd/root.go b/internal/cmd/root.go index ba11e0976..43629aa4c 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -86,6 +86,7 @@ type CLI struct { Maps MapsCmd `cmd:"" aliases:"map" help:"Google Maps"` Classroom ClassroomCmd `cmd:"" aliases:"class" help:"Google Classroom"` Time TimeCmd `cmd:"" help:"Local time utilities"` + Update UpdateCmd `cmd:"" help:"Check gogcli release status"` Gmail GmailCmd `cmd:"" aliases:"mail,email" help:"Gmail"` Chat ChatCmd `cmd:"" help:"Google Chat"` Contacts ContactsCmd `cmd:"" aliases:"contact" help:"Google Contacts"` diff --git a/internal/cmd/update.go b/internal/cmd/update.go new file mode 100644 index 000000000..cd4b31398 --- /dev/null +++ b/internal/cmd/update.go @@ -0,0 +1,370 @@ +package cmd + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +const ( + updateDefaultLatestReleaseURL = "https://api.github.com/repos/openclaw/gogcli/releases/latest" + updateDefaultTimeout = 10 * time.Second +) + +var ( + updateHTTPClient = http.DefaultClient + updateLatestReleaseURL = updateDefaultLatestReleaseURL +) + +type UpdateCmd struct { + Status UpdateStatusCmd `cmd:"" name:"status" aliases:"check" help:"Show installed and latest gogcli release status"` +} + +type UpdateStatusCmd struct { + Timeout time.Duration `name:"timeout" help:"HTTP timeout for GitHub release metadata" default:"10s"` +} + +type updateStatusReport struct { + CurrentVersion string `json:"current_version"` + CurrentCommit string `json:"current_commit,omitempty"` + CurrentDate string `json:"current_date,omitempty"` + LatestVersion string `json:"latest_version,omitempty"` + LatestURL string `json:"latest_url,omitempty"` + UpdateAvailable bool `json:"update_available"` + Platform string `json:"platform"` + PlatformAsset string `json:"platform_asset,omitempty"` + PlatformAssetURL string `json:"platform_asset_url,omitempty"` + ChecksumAvailable bool `json:"checksum_available"` + ChecksumsURL string `json:"checksums_url,omitempty"` + PlatformAssetSHA256 string `json:"platform_asset_sha256,omitempty"` + InstallMethod string `json:"install_method"` + Executable string `json:"executable,omitempty"` + SelfUpdateSupported bool `json:"self_update_supported"` + Warnings []string `json:"warnings,omitempty"` +} + +type githubRelease struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + HTMLURL string `json:"html_url"` + Assets []githubReleaseAsset `json:"assets"` +} + +type githubReleaseAsset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +func (c *UpdateStatusCmd) Run(ctx context.Context) error { + report, err := buildUpdateStatusReport(ctx, c.Timeout) + if err != nil { + return err + } + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, stdoutWriter(ctx), report) + } + writeUpdateStatusText(ctx, report) + return nil +} + +func buildUpdateStatusReport(ctx context.Context, timeout time.Duration) (updateStatusReport, error) { + current := resolvedVersion() + platform := runtime.GOOS + "/" + runtime.GOARCH + installMethod, executable, installWarnings := detectUpdateInstallMethod() + report := updateStatusReport{ + CurrentVersion: current, + CurrentCommit: strings.TrimSpace(commit), + CurrentDate: strings.TrimSpace(date), + Platform: platform, + InstallMethod: installMethod, + Executable: executable, + SelfUpdateSupported: installMethod == "standalone", + Warnings: installWarnings, + } + + client := updateClient(timeout) + release, err := fetchLatestGitHubRelease(ctx, client, updateLatestReleaseURL) + if err != nil { + return updateStatusReport{}, err + } + report.LatestVersion = strings.TrimSpace(release.TagName) + report.LatestURL = strings.TrimSpace(release.HTMLURL) + if report.LatestVersion == "" { + report.Warnings = append(report.Warnings, "latest release did not include tag_name") + } + + updateAvailable, versionsComparable := updateAvailable(current, report.LatestVersion) + report.UpdateAvailable = updateAvailable + if !versionsComparable { + report.Warnings = append(report.Warnings, "could not compare current and latest release versions") + } + + assetName := platformAssetName(report.LatestVersion, runtime.GOOS, runtime.GOARCH) + if assetName != "" { + report.PlatformAsset = assetName + if asset, ok := findReleaseAsset(release.Assets, assetName); ok { + report.PlatformAssetURL = asset.BrowserDownloadURL + } else { + report.Warnings = append(report.Warnings, "no release asset found for "+platform) + } + } + + if checksumAsset, ok := findReleaseAsset(release.Assets, "checksums.txt"); ok { + report.ChecksumAvailable = true + report.ChecksumsURL = checksumAsset.BrowserDownloadURL + if report.PlatformAsset != "" { + sum, checksumErr := fetchAssetChecksum(ctx, client, checksumAsset.BrowserDownloadURL, report.PlatformAsset) + if checksumErr != nil { + report.Warnings = append(report.Warnings, checksumErr.Error()) + } else { + report.PlatformAssetSHA256 = sum + } + } + } else { + report.Warnings = append(report.Warnings, "checksums.txt not found on latest release") + } + + return report, nil +} + +func writeUpdateStatusText(ctx context.Context, report updateStatusReport) { + u := ui.FromContext(ctx) + if u == nil { + return + } + u.Out().Linef("current_version\t%s", report.CurrentVersion) + if report.CurrentCommit != "" { + u.Out().Linef("current_commit\t%s", report.CurrentCommit) + } + if report.CurrentDate != "" { + u.Out().Linef("current_date\t%s", report.CurrentDate) + } + u.Out().Linef("latest_version\t%s", report.LatestVersion) + u.Out().Linef("update_available\t%t", report.UpdateAvailable) + u.Out().Linef("platform\t%s", report.Platform) + if report.PlatformAsset != "" { + u.Out().Linef("platform_asset\t%s", report.PlatformAsset) + } + if report.PlatformAssetSHA256 != "" { + u.Out().Linef("platform_asset_sha256\t%s", report.PlatformAssetSHA256) + } + u.Out().Linef("install_method\t%s", report.InstallMethod) + u.Out().Linef("self_update_supported\t%t", report.SelfUpdateSupported) + for _, warning := range report.Warnings { + u.Out().Linef("warning\t%s", warning) + } +} + +func updateClient(timeout time.Duration) *http.Client { + if timeout <= 0 { + timeout = updateDefaultTimeout + } + if updateHTTPClient == nil { + return &http.Client{Timeout: timeout} + } + if updateHTTPClient.Timeout != 0 { + return updateHTTPClient + } + clone := *updateHTTPClient + clone.Timeout = timeout + return &clone +} + +func fetchLatestGitHubRelease(ctx context.Context, client *http.Client, url string) (githubRelease, error) { + var release githubRelease + if err := fetchUpdateJSON(ctx, client, url, &release); err != nil { + return githubRelease{}, fmt.Errorf("fetch latest release: %w", err) + } + return release, nil +} + +func fetchUpdateJSON(ctx context.Context, client *http.Client, url string, dst any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "gogcli/"+resolvedVersion()) + + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + msg := strings.TrimSpace(string(body)) + if msg == "" { + msg = resp.Status + } + return fmt.Errorf("github returned %s: %s", resp.Status, msg) + } + if err := json.NewDecoder(resp.Body).Decode(dst); err != nil { + return fmt.Errorf("decode response: %w", err) + } + return nil +} + +func fetchAssetChecksum(ctx context.Context, client *http.Client, url string, assetName string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", fmt.Errorf("fetch checksums.txt: %w", err) + } + req.Header.Set("User-Agent", "gogcli/"+resolvedVersion()) + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("fetch checksums.txt: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("fetch checksums.txt: github returned %s", resp.Status) + } + + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) < 2 { + continue + } + name := strings.TrimPrefix(fields[len(fields)-1], "*") + if name == assetName { + return fields[0], nil + } + } + if err := scanner.Err(); err != nil { + return "", fmt.Errorf("read checksums.txt: %w", err) + } + return "", fmt.Errorf("checksum for %s not found in checksums.txt", assetName) +} + +func findReleaseAsset(assets []githubReleaseAsset, name string) (githubReleaseAsset, bool) { + for _, asset := range assets { + if asset.Name == name { + return asset, true + } + } + return githubReleaseAsset{}, false +} + +func platformAssetName(tag string, goos string, goarch string) string { + version := strings.TrimPrefix(strings.TrimSpace(tag), "v") + if version == "" { + return "" + } + ext := ".tar.gz" + if goos == "windows" { + ext = ".zip" + } + return fmt.Sprintf("gogcli_%s_%s_%s%s", version, goos, goarch, ext) +} + +func updateAvailable(current string, latest string) (bool, bool) { + cmp, ok := compareReleaseVersions(current, latest) + if !ok { + return false, false + } + return cmp < 0, true +} + +func compareReleaseVersions(current string, latest string) (int, bool) { + currentParts, okCurrent := releaseVersionParts(current) + latestParts, okLatest := releaseVersionParts(latest) + if !okCurrent || !okLatest { + return 0, false + } + maxLen := len(currentParts) + if len(latestParts) > maxLen { + maxLen = len(latestParts) + } + for i := 0; i < maxLen; i++ { + currentPart := 0 + if i < len(currentParts) { + currentPart = currentParts[i] + } + latestPart := 0 + if i < len(latestParts) { + latestPart = latestParts[i] + } + if currentPart < latestPart { + return -1, true + } + if currentPart > latestPart { + return 1, true + } + } + return 0, true +} + +func releaseVersionParts(value string) ([]int, bool) { + v := strings.TrimSpace(value) + v = strings.TrimPrefix(v, "v") + if v == "" || v == sentinelDev { + return nil, false + } + if idx := strings.IndexAny(v, "-+"); idx >= 0 { + v = v[:idx] + } + fields := strings.Split(v, ".") + if len(fields) == 0 { + return nil, false + } + parts := make([]int, 0, len(fields)) + for _, field := range fields { + if field == "" { + return nil, false + } + n, err := strconv.Atoi(field) + if err != nil || n < 0 { + return nil, false + } + parts = append(parts, n) + } + return parts, true +} + +func detectUpdateInstallMethod() (method string, executable string, warnings []string) { + exe, err := os.Executable() + if err != nil { + return trackingUnknown, "", []string{"could not determine executable path: " + err.Error()} + } + resolved := exe + if resolvedExe, evalErr := filepath.EvalSymlinks(exe); evalErr == nil { + resolved = resolvedExe + } + lower := strings.ToLower(resolved) + switch { + case isDockerRuntime(): + method = "docker" + case strings.Contains(lower, "/cellar/") || strings.Contains(lower, "/homebrew/") || strings.Contains(lower, "/linuxbrew/"): + method = "homebrew" + case strings.Contains(lower, string(filepath.Separator)+"go-build") || strings.HasSuffix(lower, ".test"): + method = "source" + default: + method = "standalone" + } + return method, resolved, nil +} + +func isDockerRuntime() bool { + if _, err := os.Stat("/.dockerenv"); err == nil { + return true + } + data, err := os.ReadFile("/proc/1/cgroup") + if err != nil { + return false + } + text := strings.ToLower(string(data)) + return strings.Contains(text, "docker") || strings.Contains(text, "kubepods") || strings.Contains(text, "containerd") +} diff --git a/internal/cmd/update_test.go b/internal/cmd/update_test.go new file mode 100644 index 000000000..f7d228da5 --- /dev/null +++ b/internal/cmd/update_test.go @@ -0,0 +1,138 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "runtime" + "strings" + "testing" +) + +func TestUpdateStatusCmdJSON(t *testing.T) { + oldClient := updateHTTPClient + oldLatestURL := updateLatestReleaseURL + oldVersion := version + oldCommit := commit + oldDate := date + defer func() { + updateHTTPClient = oldClient + updateLatestReleaseURL = oldLatestURL + version = oldVersion + commit = oldCommit + date = oldDate + }() + + version = "v0.31.0" + commit = "abc1234" + date = "2026-06-26T10:00:00Z" + + var serverURL string + assetName := platformAssetName("v0.31.1", runtime.GOOS, runtime.GOARCH) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/latest": + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{ + "tag_name": "v0.31.1", + "html_url": "https://github.com/openclaw/gogcli/releases/tag/v0.31.1", + "assets": [ + {"name": %q, "browser_download_url": %q}, + {"name": "checksums.txt", "browser_download_url": %q} + ] + }`, assetName, serverURL+"/download/"+assetName, serverURL+"/checksums.txt") + case "/checksums.txt": + _, _ = fmt.Fprintf(w, "0123456789abcdef %s\n", assetName) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + serverURL = server.URL + updateHTTPClient = server.Client() + updateLatestReleaseURL = server.URL + "/latest" + + result := executeWithTestRuntime(t, []string{"--json", "update", "status"}, nil) + if result.err != nil { + t.Fatalf("execute: %v\nstderr=%s", result.err, result.stderr) + } + + var parsed updateStatusReport + if err := json.Unmarshal([]byte(result.stdout), &parsed); err != nil { + t.Fatalf("json parse: %v\nstdout=%s", err, result.stdout) + } + if parsed.CurrentVersion != "v0.31.0" { + t.Fatalf("current_version = %q", parsed.CurrentVersion) + } + if parsed.CurrentCommit != "abc1234" { + t.Fatalf("current_commit = %q", parsed.CurrentCommit) + } + if parsed.LatestVersion != "v0.31.1" { + t.Fatalf("latest_version = %q", parsed.LatestVersion) + } + if !parsed.UpdateAvailable { + t.Fatalf("expected update_available") + } + if parsed.PlatformAsset != assetName { + t.Fatalf("platform_asset = %q, want %q", parsed.PlatformAsset, assetName) + } + if parsed.PlatformAssetSHA256 != "0123456789abcdef" { + t.Fatalf("platform_asset_sha256 = %q", parsed.PlatformAssetSHA256) + } + if !parsed.ChecksumAvailable { + t.Fatalf("expected checksum_available") + } +} + +func TestUpdateStatusCheckAlias(t *testing.T) { + oldClient := updateHTTPClient + oldLatestURL := updateLatestReleaseURL + oldVersion := version + defer func() { + updateHTTPClient = oldClient + updateLatestReleaseURL = oldLatestURL + version = oldVersion + }() + version = "v0.31.1" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/latest" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `{"tag_name":"v0.31.1","assets":[]}`) + })) + defer server.Close() + updateHTTPClient = server.Client() + updateLatestReleaseURL = server.URL + "/latest" + + result := executeWithTestRuntime(t, []string{"--json", "update", "check"}, nil) + if result.err != nil { + t.Fatalf("execute: %v\nstderr=%s", result.err, result.stderr) + } + if !strings.Contains(result.stdout, `"update_available": false`) { + t.Fatalf("unexpected stdout: %s", result.stdout) + } +} + +func TestUpdateVersionComparison(t *testing.T) { + tests := []struct { + current string + latest string + want bool + ok bool + }{ + {current: "v0.31.0", latest: "v0.31.1", want: true, ok: true}, + {current: "v0.31.1", latest: "v0.31.1", want: false, ok: true}, + {current: "v0.31.2-dev", latest: "v0.31.1", want: false, ok: true}, + {current: "dev", latest: "v0.31.1", want: false, ok: false}, + } + for _, tt := range tests { + got, ok := updateAvailable(tt.current, tt.latest) + if got != tt.want || ok != tt.ok { + t.Fatalf("updateAvailable(%q, %q) = (%t, %t), want (%t, %t)", tt.current, tt.latest, got, ok, tt.want, tt.ok) + } + } +}