diff --git a/internal/cli/root.go b/internal/cli/root.go index 68a8915..e6b5b68 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -12,6 +12,7 @@ import ( "github.com/TencentCloudAgentRuntime/ags-cli/internal/config" "github.com/TencentCloudAgentRuntime/ags-cli/internal/output" + "github.com/TencentCloudAgentRuntime/ags-cli/internal/updatecheck" "github.com/spf13/cobra" ) @@ -178,7 +179,7 @@ func Execute() { initConfig() rootCmd.SetHelpCommand(newHelpCommand()) if err := explicitHelpTopicError(os.Args[1:]); err != nil { - renderExecuteError(rootCmd, err) + renderExecuteError(rootCmd, err, nil) } if hasRawFlag("--help") || hasRawFlag("-h") { @@ -197,7 +198,7 @@ func Execute() { if cmdID != "" { for _, s := range getAllSchemas() { if s.Name == cmdID && !s.SupportsJson { - renderExecuteError(targetCmd, output.NewUsageError("INVALID_USAGE", fmt.Sprintf("%s does not support -o json", cmdID), "Use text help for this command, or run 'agr schema -o json' for machine-readable command metadata.")) + renderExecuteError(targetCmd, output.NewUsageError("INVALID_USAGE", fmt.Sprintf("%s does not support -o json", cmdID), "Use text help for this command, or run 'agr schema -o json' for machine-readable command metadata."), nil) } } } @@ -208,15 +209,75 @@ func Execute() { schemaErr = renderJSONSchemaEnvelope("help", rootCmd, []string{cmdID}) } if schemaErr != nil { - renderExecuteError(targetCmd, schemaErr) + renderExecuteError(targetCmd, schemaErr, nil) } return } } + // Fire off non-blocking background update check (skip for version/update + // commands, non-interactive mode, and JSON output). + var updateCh <-chan *updatecheck.Result + if shouldRunUpdateCheck(os.Args[1:]) { + updateCh = updatecheck.Start(Version) + } + if cmd, err := rootCmd.ExecuteC(); err != nil { - renderExecuteError(cmd, err) + renderExecuteError(cmd, err, updateCh) + } + + // Print update notice after command finishes (never blocks the command). + // Note: renderExecuteError calls os.Exit directly on the error path, so it + // prints the notice itself before exiting — this line covers the success + // path only. + if updateCh != nil && !nonInteractive && !isJSON() { + updatecheck.PrintNotice(ios.ErrOut, updateCh) + } +} + +// shouldRunUpdateCheck returns false for commands that should not trigger +// a background update check (to avoid infinite loops or noisy output). +// It strips leading global flags (--config, -o, etc.) to find the real +// subcommand token. +func shouldRunUpdateCheck(args []string) bool { + if nonInteractive { + return false + } + // Also skip when JSON output is requested (the notice would never print + // anyway, but skipping the goroutine avoids unnecessary network I/O). + if hasRawOutputFlag("json") { + return false + } + // Find the first positional token (skip flags and their values). + cmd := extractCommandToken(args) + switch cmd { + case "version", "help", "update": + return false } + // When no subcommand is found (cmd == ""), the user may have passed + // --version/-v/--help/-h which extractCommandToken skips as flags. + // Only scan for these flags when there's no positional subcommand to + // avoid false positives from subcommand arguments that happen to match. + if cmd == "" { + for _, arg := range args { + switch arg { + case "--version", "-v", "--help", "-h": + return false + } + } + } + return true +} + +// extractCommandToken walks args skipping known global flags and their values +// to find the first positional argument (the subcommand name). It reuses +// extractCommandTokens for consistency. +func extractCommandToken(args []string) string { + tokens := extractCommandTokens(args) + if len(tokens) > 0 { + return tokens[0] + } + return "" } func explicitHelpTopicError(args []string) error { @@ -302,9 +363,10 @@ func commandSpecificUsageHint(cmd *cobra.Command, err error) string { return "" } -func renderExecuteError(cmd *cobra.Command, err error) { +func renderExecuteError(cmd *cobra.Command, err error, updateCh <-chan *updatecheck.Result) { var envDone *envelopeAlreadyWritten if errors.As(err, &envDone) { + printUpdateNotice(updateCh) os.Exit(envDone.code) } cliErr := classifyCLIError(err) @@ -325,15 +387,32 @@ func renderExecuteError(cmd *cobra.Command, err error) { jqFailure := output.NewUsageError("INVALID_JQ_EXPRESSION", jqErr.Error(), "Check your --jq expression syntax.") jqEnv := output.NewFailedEnvelope(cmdID, jqFailure.Failure, config.GetBackend(), 0) _ = output.RenderEnvelopeToStdout(jqEnv) + // JSON mode: notice is suppressed by printUpdateNotice's isJSON() check. + printUpdateNotice(updateCh) os.Exit(output.ExitUsage) } + // JSON mode: notice is suppressed by printUpdateNotice's isJSON() check. + printUpdateNotice(updateCh) os.Exit(cliErr.ExitCode) } failure := withIdempotencyHint(commandIDForJSONError(cmd, os.Args[1:]), cliErr.Failure) writeFailureText(ios.ErrOut, failure) + printUpdateNotice(updateCh) os.Exit(cliErr.ExitCode) } +// printUpdateNotice prints the background update notice (if any) before the +// process exits via os.Exit, which would otherwise skip the deferred print in +// Execute(). It honors the same suppression rules (non-interactive, JSON) and +// is a no-op when updateCh is nil (e.g. the pre-command help/config error paths +// that run before the background check is started). +func printUpdateNotice(updateCh <-chan *updatecheck.Result) { + if updateCh == nil || nonInteractive || isJSON() { + return + } + updatecheck.PrintNotice(ios.ErrOut, updateCh) +} + // writeFailureText renders a Failure to a human-readable, line-per-field // stderr block. Code/Message/RequestId are surfaced uniformly so all three // service-side identifiers needed for a TencentCloud support handoff appear diff --git a/internal/cli/update.go b/internal/cli/update.go new file mode 100644 index 0000000..372fbbe --- /dev/null +++ b/internal/cli/update.go @@ -0,0 +1,63 @@ +package cli + +import ( + "fmt" + "io" + + "github.com/TencentCloudAgentRuntime/ags-cli/internal/output" + "github.com/TencentCloudAgentRuntime/ags-cli/internal/updatecheck" + "github.com/spf13/cobra" +) + +var updateCmd = &cobra.Command{ + Use: "update", + Short: "Check for CLI updates", + Long: `Check whether a newer version of AGR CLI is available. + +By default, checks the remote version and reports current vs latest. +Use --check to perform an explicit check (same as running without arguments).`, + Example: exampleBlocks("agr update", "agr update --check", "agr update -o json"), +} + +func init() { + updateCmd.Flags().Bool("check", false, "Explicitly check for updates (default behavior)") + updateCmd.RunE = Wrap("update", updateFn) + rootCmd.AddCommand(updateCmd) +} + +func updateFn(cmd *cobra.Command, args []string) (*CmdResult, error) { + latest, err := updatecheck.FetchLatestVersion() + if err != nil { + return nil, output.NewCLIError(&output.Failure{ + Code: "UPDATE_CHECK_FAILED", + Kind: output.KindGenericError, + Message: fmt.Sprintf("failed to check for updates: %v", err), + Hint: "Check your network connection and try again.", + }) + } + + version, _, _ := resolvedVersionInfo() + cmp := updatecheck.CompareVersions(version, latest) + + data := map[string]any{ + "current": version, + "latest": latest, + "update_available": cmp > 0, + } + if cmp > 0 { + data["install_command"] = fmt.Sprintf("curl -fsSL %s | sh", updatecheck.InstallURL) + } + + return OK(data, func(w io.Writer) { + fmt.Fprintf(w, "Current: %s\n", version) + fmt.Fprintf(w, "Latest: %s\n", latest) + if cmp > 0 { + fmt.Fprintf(w, "\nUpdate available: %s → %s\n", version, latest) + fmt.Fprintf(w, "Run: curl -fsSL %s | sh\n", updatecheck.InstallURL) + } else if cmp == 0 { + fmt.Fprintf(w, "\nYou are up to date.\n") + } else { + fmt.Fprintf(w, "\nYour version is newer than the latest release.\n") + } + }), nil +} diff --git a/internal/cli/update_check_test.go b/internal/cli/update_check_test.go new file mode 100644 index 0000000..5448ed8 --- /dev/null +++ b/internal/cli/update_check_test.go @@ -0,0 +1,65 @@ +package cli + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("extractCommandToken", func() { + DescribeTable("extracts the first positional subcommand", + func(args []string, expected string) { + Expect(extractCommandToken(args)).To(Equal(expected)) + }, + Entry("simple subcommand", []string{"instance", "list"}, "instance"), + Entry("flags before subcommand", []string{"--config", "foo.toml", "update"}, "update"), + Entry("-o flag before subcommand", []string{"-o", "json", "status"}, "status"), + Entry("--output=json before subcommand", []string{"--output=json", "status"}, "status"), + Entry("-ojson shorthand before subcommand", []string{"-ojson", "status"}, "status"), + Entry("boolean flags before subcommand", []string{"--debug", "--no-color", "tool"}, "tool"), + Entry("--version flag only", []string{"--version"}, ""), + Entry("-v flag only", []string{"-v"}, ""), + Entry("--help flag only", []string{"--help"}, ""), + Entry("empty args", []string{}, ""), + Entry("multiple valued flags", []string{"--region", "ap-guangzhou", "--secret-id", "xxx", "instance"}, "instance"), + Entry("-- stops parsing", []string{"--", "instance"}, ""), + Entry("--config=value format", []string{"--config=custom.toml", "tool"}, "tool"), + ) +}) + +var _ = Describe("shouldRunUpdateCheck", func() { + // Save and restore global state that shouldRunUpdateCheck reads. + var savedNonInteractive bool + + BeforeEach(func() { + savedNonInteractive = nonInteractive + nonInteractive = false + }) + AfterEach(func() { + nonInteractive = savedNonInteractive + }) + + DescribeTable("determines whether to run background update check", + func(args []string, expected bool) { + Expect(shouldRunUpdateCheck(args)).To(Equal(expected)) + }, + Entry("normal command", []string{"instance", "list"}, true), + Entry("bare agr (no subcommand)", []string{}, true), + Entry("update command skipped", []string{"update"}, false), + Entry("version command skipped", []string{"version"}, false), + Entry("help command skipped", []string{"help"}, false), + Entry("--version flag skipped", []string{"--version"}, false), + Entry("-v flag skipped", []string{"-v"}, false), + Entry("--help flag skipped", []string{"--help"}, false), + Entry("-h flag skipped", []string{"-h"}, false), + Entry("--config before version", []string{"--config", "c.toml", "version"}, false), + Entry("--config before normal cmd", []string{"--config", "c.toml", "tool"}, true), + Entry("-v among other flags (no subcommand)", []string{"--debug", "-v"}, false), + Entry("--version after --config (no subcommand)", []string{"--config", "c.toml", "--version"}, false), + Entry("-v after subcommand is not misinterpreted", []string{"tool", "create", "-v"}, true), + ) + + It("returns false when nonInteractive is set", func() { + nonInteractive = true + Expect(shouldRunUpdateCheck([]string{"instance", "list"})).To(BeFalse()) + }) +}) diff --git a/internal/updatecheck/updatecheck.go b/internal/updatecheck/updatecheck.go new file mode 100644 index 0000000..5541963 --- /dev/null +++ b/internal/updatecheck/updatecheck.go @@ -0,0 +1,333 @@ +// Package updatecheck implements a background version check with 24-hour cache. +// Inspired by AgentCore CLI's update-notifier.ts. +// +// Usage (from the root command flow): +// +// ch := updatecheck.Start(currentVersion) // non-blocking goroutine +// ... execute command ... +// updatecheck.PrintNotice(ch) // after command exits +package updatecheck + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +const ( + // VersionURL is the official endpoint that returns the latest version string. + VersionURL = "https://dl.tencentags.com/agr-cli/latest/VERSION" + // InstallURL is the one-liner shown to users for upgrading. + InstallURL = "https://dl.tencentags.com/agr-cli/latest/install.sh" + // checkInterval is how long between remote checks (24 hours). + checkInterval = 24 * time.Hour + // httpTimeout prevents the background goroutine from blocking indefinitely. + httpTimeout = 5 * time.Second + // cacheFile is the name stored under ~/.agr/. + cacheFile = "update-check.json" + // maxVersionBytes limits the amount of data read from the VERSION endpoint. + maxVersionBytes = 128 +) + +// Result is the outcome of a background version check. +type Result struct { + UpdateAvailable bool + LatestVersion string + CurrentVersion string +} + +// cacheData is the JSON structure persisted to disk. +type cacheData struct { + LastCheck int64 `json:"last_check"` + LatestVersion string `json:"latest"` +} + +// Start kicks off a non-blocking background check and returns a channel that +// will receive at most one *Result (or nil on error/skip). The channel is +// closed when done. +// +// To make warm-cache results survive fast commands (e.g. `agr status`, which +// finish in microseconds, before a goroutine can be scheduled), the cache is +// read synchronously here. On a hit, the result is placed on the channel +// before Start returns, so PrintNotice always observes it. Only a cache miss +// or a stale cache spawns a goroutine for the remote fetch — which remains +// non-blocking (PrintNotice's default branch handles the not-yet-ready case). +func Start(currentVersion string) <-chan *Result { + ch := make(chan *Result, 1) + + // Synchronous warm-cache path: result is ready before we return. + if result := checkCache(currentVersion); result != nil { + ch <- result + close(ch) + return ch + } + + // Cold/stale cache: fetch remotely in the background without blocking. + go func() { + defer close(ch) + result := checkRemote(currentVersion) + if result != nil { + ch <- result + } + }() + return ch +} + +// PrintNotice attempts a non-blocking receive from the background check +// channel. If the result is ready and a newer version is available, it prints +// a notice to w (typically stderr). If the check has not completed yet (e.g. +// slow network on cold cache), it returns immediately without blocking — the +// fresh result will be cached for the next invocation. +func PrintNotice(w io.Writer, ch <-chan *Result) { + if ch == nil { + return + } + // Non-blocking: only print if the result is already available. + select { + case result, ok := <-ch: + if !ok || result == nil || !result.UpdateAvailable { + return + } + fmt.Fprintf(w, "\nUpdate available: %s → %s\n", result.CurrentVersion, result.LatestVersion) + fmt.Fprintf(w, "Run: curl -fsSL %s | sh\n", InstallURL) + default: + // Check still in progress — do not block command exit. + } +} + +// checkCache returns a Result from a fresh warm cache, or nil if the cache is +// missing/stale (in which case checkRemote should fetch remotely). It never +// touches the network. +func checkCache(currentVersion string) *Result { + cache := readCache() + if cache == nil { + return nil + } + now := time.Now() + if now.Unix()-cache.LastCheck >= int64(checkInterval.Seconds()) { + return nil + } + cmp := CompareVersions(currentVersion, cache.LatestVersion) + return &Result{ + UpdateAvailable: cmp > 0, + LatestVersion: cache.LatestVersion, + CurrentVersion: currentVersion, + } +} + +// checkRemote fetches the latest version from the remote endpoint, writes the +// cache, and returns a Result (or nil on fetch failure). +func checkRemote(currentVersion string) *Result { + latest, err := FetchLatestVersion() + if err != nil { + return nil + } + now := time.Now() + writeCache(&cacheData{LastCheck: now.Unix(), LatestVersion: latest}) + + cmp := CompareVersions(currentVersion, latest) + return &Result{ + UpdateAvailable: cmp > 0, + LatestVersion: latest, + CurrentVersion: currentVersion, + } +} + +// versionURLOverride allows tests to point at a local httptest server. +// When empty, the production VersionURL is used. +var versionURLOverride string + +// SetVersionURLForTest overrides the version check URL and returns a restore function. +func SetVersionURLForTest(url string) func() { + prev := versionURLOverride + versionURLOverride = url + return func() { versionURLOverride = prev } +} + +func resolveVersionURL() string { + if versionURLOverride != "" { + return versionURLOverride + } + if env := os.Getenv("_TEST_VERSION_URL"); env != "" { + return env + } + return VersionURL +} + +// FetchLatestVersion queries the remote VERSION endpoint. +func FetchLatestVersion() (string, error) { + client := &http.Client{Timeout: httpTimeout} + resp, err := client.Get(resolveVersionURL()) + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("HTTP %d", resp.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, maxVersionBytes)) + if err != nil { + return "", err + } + version := strings.TrimSpace(string(body)) + if version == "" { + return "", fmt.Errorf("empty version response") + } + return version, nil +} + +// CompareVersions returns: +// +// > 0 if latest is newer than current +// 0 if equal +// < 0 if current is newer (or latest is a pre-release vs stable current) +// +// Handles semver with optional "v" prefix and pre-release suffixes. +func CompareVersions(current, latest string) int { + current = strings.TrimPrefix(current, "v") + latest = strings.TrimPrefix(latest, "v") + + currCore, currPre := splitPrerelease(current) + latCore, latPre := splitPrerelease(latest) + + currNums := parseNums(currCore) + latNums := parseNums(latCore) + + // Compare major.minor.patch + for i := 0; i < 3; i++ { + c := safeIndex(currNums, i) + l := safeIndex(latNums, i) + if l > c { + return 1 + } + if l < c { + return -1 + } + } + + // Equal core versions — compare pre-release. + if currPre == "" && latPre == "" { + return 0 + } + // No pre-release is "greater" than having one (1.0.0 > 1.0.0-beta). + if currPre == "" { + return -1 + } + if latPre == "" { + return 1 + } + + // Both have pre-release — compare segments. + return comparePrerelease(currPre, latPre) +} + +func splitPrerelease(v string) (core, pre string) { + if idx := strings.IndexByte(v, '-'); idx >= 0 { + return v[:idx], v[idx+1:] + } + return v, "" +} + +func parseNums(core string) []int { + parts := strings.Split(core, ".") + nums := make([]int, len(parts)) + for i, p := range parts { + nums[i], _ = strconv.Atoi(p) + } + return nums +} + +func safeIndex(nums []int, i int) int { + if i < len(nums) { + return nums[i] + } + return 0 +} + +func comparePrerelease(a, b string) int { + aSeg := strings.Split(a, ".") + bSeg := strings.Split(b, ".") + n := len(aSeg) + if len(bSeg) > n { + n = len(bSeg) + } + for i := 0; i < n; i++ { + var as, bs string + if i < len(aSeg) { + as = aSeg[i] + } + if i < len(bSeg) { + bs = bSeg[i] + } + if as == "" { + return 1 // fewer segments = earlier version + } + if bs == "" { + return -1 + } + an, aerr := strconv.Atoi(as) + bn, berr := strconv.Atoi(bs) + if aerr == nil && berr == nil { + if bn > an { + return 1 + } + if bn < an { + return -1 + } + } else { + if bs > as { + return 1 + } + if bs < as { + return -1 + } + } + } + return 0 +} + +// --- Cache I/O --- + +func cacheFilePath() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".agr", cacheFile) +} + +func readCache() *cacheData { + path := cacheFilePath() + if path == "" { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return nil + } + var cache cacheData + if err := json.Unmarshal(data, &cache); err != nil { + return nil + } + return &cache +} + +func writeCache(data *cacheData) { + path := cacheFilePath() + if path == "" { + return + } + dir := filepath.Dir(path) + _ = os.MkdirAll(dir, 0o755) + raw, err := json.Marshal(data) + if err != nil { + return + } + _ = os.WriteFile(path, raw, 0o644) +} diff --git a/internal/updatecheck/updatecheck_test.go b/internal/updatecheck/updatecheck_test.go new file mode 100644 index 0000000..8848ec4 --- /dev/null +++ b/internal/updatecheck/updatecheck_test.go @@ -0,0 +1,547 @@ +package updatecheck + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" +) + +// helper to set up cache dir and write cache file in tests. +func setupCache(t *testing.T, data *cacheData) string { + t.Helper() + tmp := t.TempDir() + t.Setenv("HOME", tmp) + dir := filepath.Join(tmp, ".agr") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if data != nil { + raw, err := json.Marshal(data) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, cacheFile), raw, 0o644); err != nil { + t.Fatal(err) + } + } + return tmp +} + +// --- CompareVersions --- + +func TestCompareVersions(t *testing.T) { + tests := []struct { + current string + latest string + want int + }{ + // latest is newer + {"v0.6.2", "v0.7.0", 1}, + {"0.6.2", "0.7.0", 1}, + {"v1.0.0", "v2.0.0", 1}, + {"v1.0.0", "v1.1.0", 1}, + {"v1.0.0", "v1.0.1", 1}, + {"v0.0.1", "v0.0.2", 1}, + // equal + {"v0.7.0", "v0.7.0", 0}, + {"1.0.0", "1.0.0", 0}, + {"v0.0.0", "v0.0.0", 0}, + // current is newer + {"v0.8.0", "v0.7.0", -1}, + {"v2.0.0", "v1.9.9", -1}, + {"v1.0.1", "v1.0.0", -1}, + // pre-release handling + {"v1.0.0-beta.1", "v1.0.0", 1}, // stable is newer than pre-release + {"v1.0.0", "v1.0.0-beta.1", -1}, // current stable > latest pre-release + {"v1.0.0-beta.1", "v1.0.0-beta.2", 1}, // beta.2 > beta.1 + {"v1.0.0-alpha", "v1.0.0-beta", 1}, // lexicographic: beta > alpha + {"v1.0.0-rc.1", "v1.0.0-rc.1", 0}, // equal pre-release + {"v1.0.0-beta.2", "v1.0.0-beta.1", -1}, + // dev version (parses as 0.0.0) + {"dev", "v0.7.0", 1}, + {"dev", "v0.0.1", 1}, + // mixed v prefix + {"v1.2.3", "1.2.3", 0}, + } + for _, tt := range tests { + t.Run(tt.current+"_vs_"+tt.latest, func(t *testing.T) { + got := CompareVersions(tt.current, tt.latest) + if got != tt.want { + t.Fatalf("CompareVersions(%q, %q) = %d, want %d", tt.current, tt.latest, got, tt.want) + } + }) + } +} + +// --- FetchLatestVersion --- + +func TestFetchLatestVersionSuccess(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("v0.7.0\n")) + })) + defer ts.Close() + + restore := SetVersionURLForTest(ts.URL) + defer restore() + + version, err := FetchLatestVersion() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if version != "v0.7.0" { + t.Fatalf("version = %q, want v0.7.0", version) + } +} + +func TestFetchLatestVersionTrimsWhitespace(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(" v0.8.1 \n")) + })) + defer ts.Close() + + restore := SetVersionURLForTest(ts.URL) + defer restore() + + version, err := FetchLatestVersion() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if version != "v0.8.1" { + t.Fatalf("version = %q, want v0.8.1", version) + } +} + +func TestFetchLatestVersionHTTPError(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer ts.Close() + + restore := SetVersionURLForTest(ts.URL) + defer restore() + + _, err := FetchLatestVersion() + if err == nil { + t.Fatal("expected error for HTTP 500") + } +} + +func TestFetchLatestVersionEmptyResponse(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("")) + })) + defer ts.Close() + + restore := SetVersionURLForTest(ts.URL) + defer restore() + + _, err := FetchLatestVersion() + if err == nil { + t.Fatal("expected error for empty response") + } +} + +func TestFetchLatestVersionNetworkError(t *testing.T) { + restore := SetVersionURLForTest("http://localhost:1") // port 1 — unreachable + defer restore() + + _, err := FetchLatestVersion() + if err == nil { + t.Fatal("expected network error") + } +} + +// --- Cache I/O --- + +func TestCacheReadWrite(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + + dir := filepath.Join(tmp, ".agr") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + + data := &cacheData{LastCheck: time.Now().Unix(), LatestVersion: "v0.7.0"} + writeCache(data) + + got := readCache() + if got == nil { + t.Fatal("readCache returned nil") + } + if got.LatestVersion != "v0.7.0" { + t.Fatalf("LatestVersion = %q, want v0.7.0", got.LatestVersion) + } + if got.LastCheck != data.LastCheck { + t.Fatalf("LastCheck = %d, want %d", got.LastCheck, data.LastCheck) + } +} + +func TestCacheWriteCreatesDirectory(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + + // ~/.agr does not exist yet. + data := &cacheData{LastCheck: 12345, LatestVersion: "v1.0.0"} + writeCache(data) + + got := readCache() + if got == nil { + t.Fatal("expected cache to be readable after writeCache created dir") + } + if got.LatestVersion != "v1.0.0" { + t.Fatalf("LatestVersion = %q, want v1.0.0", got.LatestVersion) + } +} + +func TestCacheReadReturnsNilOnMissing(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + + got := readCache() + if got != nil { + t.Fatalf("expected nil, got %+v", got) + } +} + +func TestCacheReadReturnsNilOnInvalidJSON(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + + dir := filepath.Join(tmp, ".agr") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, cacheFile), []byte("not json"), 0o644); err != nil { + t.Fatal(err) + } + + got := readCache() + if got != nil { + t.Fatalf("expected nil, got %+v", got) + } +} + +// --- checkCache() / checkRemote() --- + +func TestCheckCacheUsesCacheWhenFresh(t *testing.T) { + setupCache(t, &cacheData{LastCheck: time.Now().Unix(), LatestVersion: "v0.8.0"}) + + result := checkCache("v0.7.0") + if result == nil { + t.Fatal("expected result, got nil") + } + if !result.UpdateAvailable { + t.Fatal("expected UpdateAvailable=true") + } + if result.LatestVersion != "v0.8.0" { + t.Fatalf("LatestVersion = %q, want v0.8.0", result.LatestVersion) + } + if result.CurrentVersion != "v0.7.0" { + t.Fatalf("CurrentVersion = %q, want v0.7.0", result.CurrentVersion) + } +} + +func TestCheckCacheReturnsNilWhenExpired(t *testing.T) { + // Stale cache → checkCache yields nil so Start falls through to remote fetch. + setupCache(t, &cacheData{LastCheck: time.Now().Add(-25 * time.Hour).Unix(), LatestVersion: "v0.5.0"}) + + if result := checkCache("v0.7.0"); result != nil { + t.Fatalf("expected nil for stale cache, got %+v", result) + } +} + +func TestCheckCacheReturnsNilWhenMissing(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + + if result := checkCache("v0.7.0"); result != nil { + t.Fatalf("expected nil for missing cache, got %+v", result) + } +} + +func TestCheckCacheSkipsWhenVersionsEqual(t *testing.T) { + setupCache(t, &cacheData{LastCheck: time.Now().Unix(), LatestVersion: "v0.7.0"}) + + result := checkCache("v0.7.0") + if result == nil { + t.Fatal("expected result, got nil") + } + if result.UpdateAvailable { + t.Fatal("expected UpdateAvailable=false") + } +} + +func TestCheckCacheCurrentIsNewer(t *testing.T) { + setupCache(t, &cacheData{LastCheck: time.Now().Unix(), LatestVersion: "v0.6.0"}) + + result := checkCache("v0.7.0") + if result == nil { + t.Fatal("expected result") + } + if result.UpdateAvailable { + t.Fatal("expected UpdateAvailable=false (current is newer)") + } +} + +func TestCheckRemoteFetchesAndUpdatesCache(t *testing.T) { + // Stale cache present; checkRemote ignores it and fetches fresh. + setupCache(t, &cacheData{LastCheck: time.Now().Add(-25 * time.Hour).Unix(), LatestVersion: "v0.5.0"}) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("v0.9.0\n")) + })) + defer ts.Close() + restore := SetVersionURLForTest(ts.URL) + defer restore() + + result := checkRemote("v0.7.0") + if result == nil { + t.Fatal("expected result, got nil") + } + if !result.UpdateAvailable { + t.Fatal("expected UpdateAvailable=true (v0.9.0 > v0.7.0)") + } + if result.LatestVersion != "v0.9.0" { + t.Fatalf("LatestVersion = %q, want v0.9.0 (fetched, not cached v0.5.0)", result.LatestVersion) + } + + // Verify cache was updated. + newCache := readCache() + if newCache == nil || newCache.LatestVersion != "v0.9.0" { + t.Fatalf("cache not updated: %+v", newCache) + } +} + +func TestCheckRemoteFetchesWhenNoCache(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("v1.0.0\n")) + })) + defer ts.Close() + restore := SetVersionURLForTest(ts.URL) + defer restore() + + result := checkRemote("v0.7.0") + if result == nil { + t.Fatal("expected result") + } + if !result.UpdateAvailable { + t.Fatal("expected UpdateAvailable=true") + } + if result.LatestVersion != "v1.0.0" { + t.Fatalf("LatestVersion = %q, want v1.0.0", result.LatestVersion) + } +} + +func TestCheckRemoteReturnsNilOnFetchError(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + + restore := SetVersionURLForTest("http://localhost:1") + defer restore() + + result := checkRemote("v0.7.0") + if result != nil { + t.Fatalf("expected nil on fetch error, got %+v", result) + } +} + +// --- Start() --- + +func TestStartReturnsChannelWithResult(t *testing.T) { + setupCache(t, &cacheData{LastCheck: time.Now().Unix(), LatestVersion: "v0.9.0"}) + + ch := Start("v0.7.0") + result := <-ch + if result == nil { + t.Fatal("expected result") + } + if !result.UpdateAvailable { + t.Fatal("expected UpdateAvailable=true") + } +} + +// Regression: on a warm cache the result must be ready before Start returns, +// so a fast command (which calls PrintNotice immediately) still observes it. +// Previously Start always spawned a goroutine, and for microsecond-fast +// commands PrintNotice's non-blocking select ran before the goroutine was +// scheduled → the notice was silently dropped. +func TestStartWarmCacheResultReadyImmediately(t *testing.T) { + setupCache(t, &cacheData{LastCheck: time.Now().Unix(), LatestVersion: "v0.9.0"}) + + ch := Start("v0.7.0") + // Non-blocking receive — simulates PrintNotice's select without waiting. + select { + case result := <-ch: + if result == nil { + t.Fatal("expected non-nil result on warm cache") + } + if !result.UpdateAvailable { + t.Fatal("expected UpdateAvailable=true") + } + default: + t.Fatal("warm-cache result was not ready immediately; fast commands would drop the notice") + } +} + +// Cold cache must NOT block Start from returning. We verify this indirectly: +// Start returns a channel, and TestStartChannelClosesOnError below drains that +// channel to completion (on an unreachable URL), proving the cold-cache path +// runs in a goroutine. Asserting "not immediately ready" here would be flaky +// on fast machines and, more importantly, racing the goroutine against the +// test's deferred SetVersionURLForTest restore — so we don't. + +func TestStartChannelClosesOnError(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + + restore := SetVersionURLForTest("http://localhost:1") + defer restore() + + ch := Start("v0.7.0") + result, ok := <-ch + // Channel should close; result is nil. + if ok && result != nil { + t.Fatalf("expected nil/closed, got %+v", result) + } +} + +// --- PrintNotice --- + +func TestPrintNoticeOutputsWhenUpdateAvailable(t *testing.T) { + ch := make(chan *Result, 1) + ch <- &Result{UpdateAvailable: true, LatestVersion: "v0.8.0", CurrentVersion: "v0.7.0"} + close(ch) + + var buf bytes.Buffer + PrintNotice(&buf, ch) + + output := buf.String() + if !bytes.Contains([]byte(output), []byte("Update available")) { + t.Fatalf("expected update notice, got: %q", output) + } + if !bytes.Contains([]byte(output), []byte("v0.7.0")) { + t.Fatalf("expected current version, got: %q", output) + } + if !bytes.Contains([]byte(output), []byte("v0.8.0")) { + t.Fatalf("expected latest version, got: %q", output) + } + if !bytes.Contains([]byte(output), []byte("curl")) { + t.Fatalf("expected install command, got: %q", output) + } +} + +func TestPrintNoticeNoOutputWhenUpToDate(t *testing.T) { + ch := make(chan *Result, 1) + ch <- &Result{UpdateAvailable: false, LatestVersion: "v0.7.0", CurrentVersion: "v0.7.0"} + close(ch) + + var buf bytes.Buffer + PrintNotice(&buf, ch) + + if buf.Len() != 0 { + t.Fatalf("expected no output, got: %q", buf.String()) + } +} + +func TestPrintNoticeNoOutputOnNilChannel(t *testing.T) { + var buf bytes.Buffer + PrintNotice(&buf, nil) + if buf.Len() != 0 { + t.Fatalf("expected no output, got: %q", buf.String()) + } +} + +func TestPrintNoticeNoOutputOnClosedEmptyChannel(t *testing.T) { + ch := make(chan *Result) + close(ch) + + var buf bytes.Buffer + PrintNotice(&buf, ch) + if buf.Len() != 0 { + t.Fatalf("expected no output, got: %q", buf.String()) + } +} + +func TestPrintNoticeNoOutputWhenResultIsNil(t *testing.T) { + ch := make(chan *Result, 1) + ch <- nil + close(ch) + + var buf bytes.Buffer + PrintNotice(&buf, ch) + if buf.Len() != 0 { + t.Fatalf("expected no output, got: %q", buf.String()) + } +} + +func TestPrintNoticeNonBlockingWhenChannelNotReady(t *testing.T) { + // Unbuffered channel with no sender — PrintNotice must not block. + ch := make(chan *Result) + // Do NOT close or send — simulates slow network. + + var buf bytes.Buffer + PrintNotice(&buf, ch) + // Should return immediately without output. + if buf.Len() != 0 { + t.Fatalf("expected no output, got: %q", buf.String()) + } +} + +// --- Integration: full flow with httptest server --- + +func TestFullFlowFetchAndCache(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + + callCount := 0 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + _, _ = w.Write([]byte("v1.2.0\n")) + })) + defer ts.Close() + restore := SetVersionURLForTest(ts.URL) + defer restore() + + // First call: no cache → fetches from server. + ch := Start("v1.0.0") + result := <-ch + if result == nil || !result.UpdateAvailable { + t.Fatalf("first call: expected update available, got %+v", result) + } + if callCount != 1 { + t.Fatalf("expected 1 HTTP call, got %d", callCount) + } + + // Second call: cache is fresh → no HTTP call. + ch = Start("v1.0.0") + result = <-ch + if result == nil || !result.UpdateAvailable { + t.Fatalf("second call: expected update available, got %+v", result) + } + if callCount != 1 { + t.Fatalf("expected still 1 HTTP call (cached), got %d", callCount) + } + + // Expire cache and call again → should fetch. + cache := readCache() + cache.LastCheck = time.Now().Add(-25 * time.Hour).Unix() + writeCache(cache) + + ch = Start("v1.0.0") + result = <-ch + if result == nil || !result.UpdateAvailable { + t.Fatalf("third call: expected update available, got %+v", result) + } + if callCount != 2 { + t.Fatalf("expected 2 HTTP calls after cache expired, got %d", callCount) + } +} diff --git a/tests/root/update_test.go b/tests/root/update_test.go new file mode 100644 index 0000000..0614bbe --- /dev/null +++ b/tests/root/update_test.go @@ -0,0 +1,120 @@ +package root_test + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + + "github.com/TencentCloudAgentRuntime/ags-cli/tests/testutil" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("agr update command", func() { + var cli *testutil.CLI + + BeforeEach(func() { + cli = testutil.NewCLI() + DeferCleanup(cli.Cleanup) + }) + + // Tests that call the real VERSION endpoint are guarded by AGR_LIVE_TEST. + // They are skipped in CI unless the env var is set, avoiding flakes from + // network unavailability. + + It("checks for updates in text mode", func() { + if os.Getenv("AGR_LIVE_TEST") == "" { + Skip("requires network (set AGR_LIVE_TEST=1)") + } + result := cli.Run(context.Background(), "update") + result.ExpectSuccess() + Expect(result.Stdout).To(ContainSubstring("Current:")) + Expect(result.Stdout).To(ContainSubstring("Latest:")) + }) + + It("checks for updates with --check flag", func() { + if os.Getenv("AGR_LIVE_TEST") == "" { + Skip("requires network (set AGR_LIVE_TEST=1)") + } + result := cli.Run(context.Background(), "update", "--check") + result.ExpectSuccess() + Expect(result.Stdout).To(ContainSubstring("Current:")) + }) + + It("returns JSON envelope with update data", func() { + if os.Getenv("AGR_LIVE_TEST") == "" { + Skip("requires network (set AGR_LIVE_TEST=1)") + } + result := cli.Run(context.Background(), "--output", "json", "update") + result.ExpectSuccess() + env := result.Envelope() + Expect(env.Command).To(Equal("update")) + Expect(env.Status).To(Equal("succeeded")) + Expect(env.Data).To(HaveKey("current")) + Expect(env.Data).To(HaveKey("latest")) + Expect(env.Data).To(HaveKey("update_available")) + }) + + // The following tests do NOT depend on network — they verify suppression + // behavior which does not call the remote endpoint. + + It("does not show background update notice for 'agr update' itself", func() { + if os.Getenv("AGR_LIVE_TEST") == "" { + Skip("requires network (set AGR_LIVE_TEST=1)") + } + result := cli.Run(context.Background(), "update") + result.ExpectSuccess() + Expect(result.Stderr).NotTo(ContainSubstring("Update available")) + }) + + It("does not show update notice in non-interactive mode", func() { + result := cli.Run(context.Background(), "--non-interactive", "version") + result.ExpectSuccess() + Expect(result.Stderr).NotTo(ContainSubstring("Update available")) + }) + + It("does not show update notice in JSON mode", func() { + result := cli.Run(context.Background(), "--output", "json", "version") + result.ExpectSuccess() + Expect(result.Stderr).NotTo(ContainSubstring("Update available")) + }) + + // Offline test using a local httptest server via _TEST_VERSION_URL. + // This verifies the full update flow without depending on real network. + // Uses cli.ExtraEnv instead of os.Setenv to avoid race conditions in + // parallel test execution. + It("checks for updates using mock server (offline)", func() { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "v99.0.0\n") + })) + defer ts.Close() + + cli.ExtraEnv = append(cli.ExtraEnv, "_TEST_VERSION_URL="+ts.URL) + + result := cli.Run(context.Background(), "update") + result.ExpectSuccess() + Expect(result.Stdout).To(ContainSubstring("Current:")) + Expect(result.Stdout).To(ContainSubstring("Latest:")) + Expect(result.Stdout).To(ContainSubstring("v99.0.0")) + Expect(result.Stdout).To(ContainSubstring("Update available")) + }) + + It("returns JSON envelope with mock server (offline)", func() { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "v99.0.0\n") + })) + defer ts.Close() + + cli.ExtraEnv = append(cli.ExtraEnv, "_TEST_VERSION_URL="+ts.URL) + + result := cli.Run(context.Background(), "--output", "json", "update") + result.ExpectSuccess() + env := result.Envelope() + Expect(env.Command).To(Equal("update")) + Expect(env.Status).To(Equal("succeeded")) + Expect(env.Data["latest"]).To(Equal("v99.0.0")) + Expect(env.Data["update_available"]).To(BeTrue()) + }) +}) diff --git a/tests/testutil/cli.go b/tests/testutil/cli.go index f96b780..4f54d93 100644 --- a/tests/testutil/cli.go +++ b/tests/testutil/cli.go @@ -23,6 +23,10 @@ type CLI struct { Home string Config LiveConfig Timeout time.Duration + // ExtraEnv holds additional KEY=VALUE pairs appended to the subprocess + // environment. Use this instead of os.Setenv to avoid race conditions + // when tests run in parallel. + ExtraEnv []string } // CommandResult captures one CLI process execution for assertions. @@ -154,6 +158,7 @@ func (c *CLI) env() []string { if c.Config.CloudEndpoint != "" { env = append(env, "AGR_CLOUD_ENDPOINT="+c.Config.CloudEndpoint) } + env = append(env, c.ExtraEnv...) return env }