From 8d375203523a2e2a10ffd63d1861342b5f88c29c Mon Sep 17 00:00:00 2001 From: "alex.stanfield" <13949480+chaptersix@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:11:53 -0500 Subject: [PATCH] Add opt-in CLI update notices --- internal/temporalcli/commands.config.go | 49 ++++ internal/temporalcli/commands.config_test.go | 28 ++ internal/temporalcli/commands.gen.go | 4 +- internal/temporalcli/commands.go | 4 + internal/temporalcli/commands.yaml | 9 + internal/temporalcli/update_check.go | 257 +++++++++++++++++++ internal/temporalcli/update_check_test.go | 103 ++++++++ 7 files changed, 452 insertions(+), 2 deletions(-) create mode 100644 internal/temporalcli/update_check.go create mode 100644 internal/temporalcli/update_check_test.go diff --git a/internal/temporalcli/commands.config.go b/internal/temporalcli/commands.config.go index fb4dc1d81..94d387dc9 100644 --- a/internal/temporalcli/commands.config.go +++ b/internal/temporalcli/commands.config.go @@ -1,6 +1,8 @@ package temporalcli import ( + "bytes" + "errors" "fmt" "os" "path/filepath" @@ -14,6 +16,10 @@ import ( ) func (c *TemporalConfigDeleteCommand) run(cctx *CommandContext, _ []string) error { + if c.Prop == updateCheckConfigProp { + configFile, _ := resolveConfigFile(cctx.Options) + return setUpdateCheckEnabled(configFile, false) + } // Load config profileName := envConfigProfileName(cctx) conf, confProfile, err := loadEnvConfigProfile(cctx, profileName, true) @@ -58,6 +64,21 @@ func (c *TemporalConfigDeleteProfileCommand) run(cctx *CommandContext, _ []strin } func (c *TemporalConfigGetCommand) run(cctx *CommandContext, _ []string) error { + if c.Prop == updateCheckConfigProp { + configFile, _ := resolveConfigFile(cctx.Options) + _, state, err := loadUpdateCheckState(configFile) + if err != nil { + return err + } + type prop struct { + Property string `json:"property"` + Value any `json:"value"` + } + return cctx.Printer.PrintStructured( + prop{Property: c.Prop, Value: state.Enabled}, + printer.StructuredOptions{Table: &printer.TableOptions{}}, + ) + } // Load config profile profileName := envConfigProfileName(cctx) conf, confProfile, err := loadEnvConfigProfile(cctx, profileName, true) @@ -166,6 +187,13 @@ func (c *TemporalConfigListCommand) run(cctx *CommandContext, _ []string) error } func (c *TemporalConfigSetCommand) run(cctx *CommandContext, _ []string) error { + if c.Prop == updateCheckConfigProp { + if c.Value != "true" && c.Value != "false" { + return fmt.Errorf("must be 'true' or 'false' to set this property") + } + configFile, _ := resolveConfigFile(cctx.Options) + return setUpdateCheckEnabled(configFile, c.Value == "true") + } // Load config conf, confProfile, err := loadEnvConfigProfile(cctx, envConfigProfileName(cctx), false) if err != nil { @@ -328,6 +356,27 @@ func writeEnvConfigFile(cctx *CommandContext, conf *envconfig.ClientConfig) erro if err != nil { return fmt.Errorf("failed building TOML: %w", err) } + // The SDK config type only knows about connection profiles. Preserve CLI-owned + // top-level configuration when rewriting those profiles. + if existing, readErr := os.ReadFile(configFile); readErr == nil { + var existingRaw, generatedRaw map[string]any + if _, decodeErr := toml.Decode(string(existing), &existingRaw); decodeErr != nil { + return fmt.Errorf("failed parsing existing config: %w", decodeErr) + } + if _, decodeErr := toml.Decode(string(b), &generatedRaw); decodeErr != nil { + return fmt.Errorf("failed parsing generated config: %w", decodeErr) + } + if cliConfig, ok := existingRaw["cli"]; ok { + generatedRaw["cli"] = cliConfig + } + var buf bytes.Buffer + if encodeErr := toml.NewEncoder(&buf).Encode(generatedRaw); encodeErr != nil { + return fmt.Errorf("failed preserving CLI config: %w", encodeErr) + } + b = buf.Bytes() + } else if !errors.Is(readErr, os.ErrNotExist) { + return fmt.Errorf("failed reading existing config: %w", readErr) + } // Write to file, making dirs as needed if err := os.MkdirAll(filepath.Dir(configFile), 0700); err != nil { diff --git a/internal/temporalcli/commands.config_test.go b/internal/temporalcli/commands.config_test.go index ee6e484b7..a955b1475 100644 --- a/internal/temporalcli/commands.config_test.go +++ b/internal/temporalcli/commands.config_test.go @@ -154,6 +154,34 @@ disable_host_verification = true`)) } } +func TestConfig_UpdateCheckOptIn(t *testing.T) { + h := NewCommandHarness(t) + configFile := t.TempDir() + "/temporal.toml" + h.Options.EnvLookup = EnvLookupMap{"TEMPORAL_CONFIG_FILE": configFile} + + res := h.Execute("config", "set", "--prop", "cli.update_check.enabled", "--value", "true") + h.NoError(res.Err) + + res = h.Execute("config", "get", "--prop", "cli.update_check.enabled", "-o", "json") + h.NoError(res.Err) + h.JSONEq(`{"property":"cli.update_check.enabled","value":true}`, res.Stdout.String()) + + res = h.Execute("config", "set", "--prop", "namespace", "--value", "testing") + h.NoError(res.Err) + var config map[string]any + _, err := toml.DecodeFile(configFile, &config) + h.NoError(err) + cli := config["cli"].(map[string]any) + updateCheck := cli["update_check"].(map[string]any) + h.Equal(true, updateCheck["enabled"]) + + res = h.Execute("config", "delete", "--prop", "cli.update_check.enabled") + h.NoError(res.Err) + res = h.Execute("config", "get", "--prop", "cli.update_check.enabled", "-o", "json") + h.NoError(res.Err) + h.JSONEq(`{"property":"cli.update_check.enabled","value":false}`, res.Stdout.String()) +} + func TestConfig_Get_NoTLSWhenUnconfigured(t *testing.T) { // Regression test for #1077: `config get` (table output) must not display // "tls true" for a profile that has no TLS configuration. diff --git a/internal/temporalcli/commands.gen.go b/internal/temporalcli/commands.gen.go index 16e075dc6..ba1e68487 100644 --- a/internal/temporalcli/commands.gen.go +++ b/internal/temporalcli/commands.gen.go @@ -1142,9 +1142,9 @@ func NewTemporalConfigCommand(cctx *CommandContext, parent *TemporalCommand) *Te s.Command.Use = "config" s.Command.Short = "Manage config files (EXPERIMENTAL)" if hasHighlighting { - s.Command.Long = "Config files are TOML files that contain profiles, with each profile\ncontaining configuration for connecting to Temporal.\n\n\x1b[1mtemporal config set \\\n --prop address \\\n --value us-west-2.aws.api.temporal.io:7233\x1b[0m\n\nThe default config file path is \x1b[1m$CONFIG_PATH/temporalio/temporal.toml\x1b[0m where\n\x1b[1m$CONFIG_PATH\x1b[0m is defined as \x1b[1m$HOME/.config\x1b[0m on Unix,\n\x1b[1m$HOME/Library/Application Support\x1b[0m on macOS, and \x1b[1m%AppData%\x1b[0m on Windows.\nThis can be overridden with the \x1b[1mTEMPORAL_CONFIG_FILE\x1b[0m environment\nvariable or \x1b[1m--config-file\x1b[0m.\n\nThe default profile is \x1b[1mdefault\x1b[0m. This can be overridden with the\n\x1b[1mTEMPORAL_PROFILE\x1b[0m environment variable or \x1b[1m--profile\x1b[0m." + s.Command.Long = "Config files are TOML files that contain profiles, with each profile\ncontaining configuration for connecting to Temporal.\n\n\x1b[1mtemporal config set \\\n --prop address \\\n --value us-west-2.aws.api.temporal.io:7233\x1b[0m\n\nThe default config file path is \x1b[1m$CONFIG_PATH/temporalio/temporal.toml\x1b[0m where\n\x1b[1m$CONFIG_PATH\x1b[0m is defined as \x1b[1m$HOME/.config\x1b[0m on Unix,\n\x1b[1m$HOME/Library/Application Support\x1b[0m on macOS, and \x1b[1m%AppData%\x1b[0m on Windows.\nThis can be overridden with the \x1b[1mTEMPORAL_CONFIG_FILE\x1b[0m environment\nvariable or \x1b[1m--config-file\x1b[0m.\n\nThe default profile is \x1b[1mdefault\x1b[0m. This can be overridden with the\n\x1b[1mTEMPORAL_PROFILE\x1b[0m environment variable or \x1b[1m--profile\x1b[0m.\n\nTo opt in to periodic checks for new Temporal CLI releases, set the\nglobal CLI property:\n\n\x1b[1mtemporal config set \\\n --prop cli.update_check.enabled \\\n --value true\x1b[0m" } else { - s.Command.Long = "Config files are TOML files that contain profiles, with each profile\ncontaining configuration for connecting to Temporal.\n\n```\ntemporal config set \\\n --prop address \\\n --value us-west-2.aws.api.temporal.io:7233\n```\n\nThe default config file path is `$CONFIG_PATH/temporalio/temporal.toml` where\n`$CONFIG_PATH` is defined as `$HOME/.config` on Unix,\n`$HOME/Library/Application Support` on macOS, and `%AppData%` on Windows.\nThis can be overridden with the `TEMPORAL_CONFIG_FILE` environment\nvariable or `--config-file`.\n\nThe default profile is `default`. This can be overridden with the\n`TEMPORAL_PROFILE` environment variable or `--profile`." + s.Command.Long = "Config files are TOML files that contain profiles, with each profile\ncontaining configuration for connecting to Temporal.\n\n```\ntemporal config set \\\n --prop address \\\n --value us-west-2.aws.api.temporal.io:7233\n```\n\nThe default config file path is `$CONFIG_PATH/temporalio/temporal.toml` where\n`$CONFIG_PATH` is defined as `$HOME/.config` on Unix,\n`$HOME/Library/Application Support` on macOS, and `%AppData%` on Windows.\nThis can be overridden with the `TEMPORAL_CONFIG_FILE` environment\nvariable or `--config-file`.\n\nThe default profile is `default`. This can be overridden with the\n`TEMPORAL_PROFILE` environment variable or `--profile`.\n\nTo opt in to periodic checks for new Temporal CLI releases, set the\nglobal CLI property:\n\n```\ntemporal config set \\\n --prop cli.update_check.enabled \\\n --value true\n```" } s.Command.Args = cobra.NoArgs s.Command.AddCommand(&NewTemporalConfigDeleteCommand(cctx, &s).Command) diff --git a/internal/temporalcli/commands.go b/internal/temporalcli/commands.go index eaf08dbdf..5ffc80bb8 100644 --- a/internal/temporalcli/commands.go +++ b/internal/temporalcli/commands.go @@ -375,6 +375,10 @@ func Execute(ctx context.Context, options CommandOptions) { defer cancel() if err == nil { + // Update checks are config-controlled, best effort, and intentionally run + // outside Cobra hooks so they also apply to the built-in --version path. + runUpdateCheck(cctx) + cmd := NewTemporalCommand(cctx) cmd.Command.SetArgs(cctx.Options.Args) cmd.Command.SetOut(cctx.Options.Stdout) diff --git a/internal/temporalcli/commands.yaml b/internal/temporalcli/commands.yaml index 9127c5f92..209aa01c2 100644 --- a/internal/temporalcli/commands.yaml +++ b/internal/temporalcli/commands.yaml @@ -813,6 +813,15 @@ commands: The default profile is `default`. This can be overridden with the `TEMPORAL_PROFILE` environment variable or `--profile`. + + To opt in to periodic checks for new Temporal CLI releases, set the + global CLI property: + + ``` + temporal config set \ + --prop cli.update_check.enabled \ + --value true + ``` docs: description-header: >- Temporal CLI 'config' commands allow the getting, setting, deleting, and diff --git a/internal/temporalcli/update_check.go b/internal/temporalcli/update_check.go new file mode 100644 index 000000000..4327dc249 --- /dev/null +++ b/internal/temporalcli/update_check.go @@ -0,0 +1,257 @@ +package temporalcli + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "hash/fnv" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "time" + + "github.com/BurntSushi/toml" + "go.temporal.io/sdk/contrib/envconfig" + "golang.org/x/mod/semver" +) + +const updateCheckConfigProp = "cli.update_check.enabled" + +var releaseVersionPattern = regexp.MustCompile(`/releases/download/(v[^/]+)/`) + +type updateCheckState struct { + Enabled bool `toml:"enabled,omitempty"` + LatestVersion string `toml:"latest_version,omitempty"` + LastCheckedAt time.Time `toml:"last_checked_at,omitempty"` + LastNotifiedAt time.Time `toml:"last_notified_at,omitempty"` + NoticeCount int `toml:"notice_count,omitempty"` +} + +type updateCheckHTTPClient interface { + Do(*http.Request) (*http.Response, error) +} + +func resolveConfigFile(options CommandOptions) (path string, disabled bool) { + for i, arg := range options.Args { + switch { + case arg == "--disable-config-file": + disabled = true + case arg == "--config-file" && i+1 < len(options.Args): + path = options.Args[i+1] + case strings.HasPrefix(arg, "--config-file="): + path = strings.TrimPrefix(arg, "--config-file=") + } + } + if path == "" && options.EnvLookup != nil { + path, _ = options.EnvLookup.LookupEnv("TEMPORAL_CONFIG_FILE") + } + if path == "" { + path = envconfig.DefaultConfigFilePath() + } + return path, disabled +} + +func loadUpdateCheckState(path string) (map[string]any, updateCheckState, error) { + raw := make(map[string]any) + b, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return raw, updateCheckState{}, nil + } + if err != nil { + return nil, updateCheckState{}, err + } + if _, err := toml.Decode(string(b), &raw); err != nil { + return nil, updateCheckState{}, err + } + var decoded struct { + CLI struct { + UpdateCheck updateCheckState `toml:"update_check"` + } `toml:"cli"` + } + if _, err := toml.Decode(string(b), &decoded); err != nil { + return nil, updateCheckState{}, err + } + return raw, decoded.CLI.UpdateCheck, nil +} + +func storeUpdateCheckState(path string, raw map[string]any, state updateCheckState) error { + cli, _ := raw["cli"].(map[string]any) + if cli == nil { + cli = make(map[string]any) + raw["cli"] = cli + } + cli["update_check"] = state + + var buf bytes.Buffer + if err := toml.NewEncoder(&buf).Encode(raw); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + f, err := os.CreateTemp(filepath.Dir(path), ".temporal.toml-*") + if err != nil { + return err + } + tmp := f.Name() + defer os.Remove(tmp) + if err := f.Chmod(0600); err != nil { + f.Close() + return err + } + if _, err := f.Write(buf.Bytes()); err != nil { + f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + return os.Rename(tmp, path) +} + +func setUpdateCheckEnabled(path string, enabled bool) error { + raw, state, err := loadUpdateCheckState(path) + if err != nil { + return err + } + state.Enabled = enabled + return storeUpdateCheckState(path, raw, state) +} + +func updateCheckInterval(path string, checkedAt time.Time) time.Duration { + h := fnv.New64a() + _, _ = io.WriteString(h, path) + _, _ = io.WriteString(h, checkedAt.UTC().Format("2006-01-02")) + const jitterWindow = 24 * time.Hour + jitter := time.Duration(h.Sum64()%uint64(jitterWindow)) - 12*time.Hour + return 72*time.Hour + jitter +} + +func noticeInterval(count int) time.Duration { + switch count { + case 1: + return 24 * time.Hour + case 2: + return 72 * time.Hour + case 3: + return 7 * 24 * time.Hour + default: + return 14 * 24 * time.Hour + } +} + +func normalizedVersion(version string) string { + version = strings.TrimSpace(version) + if !strings.HasPrefix(version, "v") { + version = "v" + version + } + if !semver.IsValid(version) { + return "" + } + return version +} + +func fetchLatestVersion(ctx context.Context, client updateCheckHTTPClient) (string, error) { + url := fmt.Sprintf("https://temporal.download/cli/latest?platform=%s&arch=%s", runtime.GOOS, runtime.GOARCH) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", "temporal-cli/"+Version) + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("unexpected status %s", resp.Status) + } + var info struct { + ArchiveURL string `json:"archiveUrl"` + } + if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { + return "", err + } + match := releaseVersionPattern.FindStringSubmatch(info.ArchiveURL) + if len(match) != 2 || normalizedVersion(match[1]) == "" { + return "", fmt.Errorf("download response contains no valid release version") + } + return match[1], nil +} + +func runUpdateCheck(cctx *CommandContext) { + current := normalizedVersion(Version) + if current == "" || strings.Contains(strings.ToUpper(Version), "DEV") { + return + } + path, disabled := resolveConfigFile(cctx.Options) + if disabled { + return + } + _, initialState, err := loadUpdateCheckState(path) + if err != nil || !initialState.Enabled { + return + } + + lockPath := path + ".update-check.lock" + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return + } + lock, err := os.OpenFile(lockPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if errors.Is(err, os.ErrExist) { + if info, statErr := os.Stat(lockPath); statErr == nil && time.Since(info.ModTime()) > 5*time.Minute { + _ = os.Remove(lockPath) + lock, err = os.OpenFile(lockPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + } + } + if err != nil { + return + } + lock.Close() + defer os.Remove(lockPath) + + raw, state, err := loadUpdateCheckState(path) + if err != nil || !state.Enabled { + return + } + now := time.Now().UTC() + if state.LastCheckedAt.IsZero() || now.Sub(state.LastCheckedAt) >= updateCheckInterval(path, state.LastCheckedAt) { + // Persist the attempt before performing I/O so concurrent invocations do not + // fan out when the endpoint is slow or unavailable. + state.LastCheckedAt = now + if err := storeUpdateCheckState(path, raw, state); err != nil { + return + } + ctx, cancel := context.WithTimeout(cctx, time.Second) + latest, err := fetchLatestVersion(ctx, http.DefaultClient) + cancel() + if err == nil { + latest = normalizedVersion(latest) + if latest != state.LatestVersion { + state.LatestVersion = latest + state.LastNotifiedAt = time.Time{} + state.NoticeCount = 0 + } + _ = storeUpdateCheckState(path, raw, state) + } + } + + latest := normalizedVersion(state.LatestVersion) + if latest == "" || semver.Compare(latest, current) <= 0 { + return + } + if state.NoticeCount > 0 && now.Sub(state.LastNotifiedAt) < noticeInterval(state.NoticeCount) { + return + } + fmt.Fprintf(cctx.Options.Stderr, "[notice] A new Temporal CLI release is available: %s -> %s\n", strings.TrimPrefix(current, "v"), strings.TrimPrefix(latest, "v")) + fmt.Fprintf(cctx.Options.Stderr, "[notice] Release notes: https://github.com/temporalio/cli/releases/tag/%s\n", latest) + state.LastNotifiedAt = now + state.NoticeCount++ + _ = storeUpdateCheckState(path, raw, state) +} diff --git a/internal/temporalcli/update_check_test.go b/internal/temporalcli/update_check_test.go new file mode 100644 index 000000000..b8bfb622c --- /dev/null +++ b/internal/temporalcli/update_check_test.go @@ -0,0 +1,103 @@ +package temporalcli + +import ( + "bytes" + "context" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type updateCheckClientFunc func(*http.Request) (*http.Response, error) + +func (f updateCheckClientFunc) Do(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestUpdateCheckStateRoundTripPreservesConfig(t *testing.T) { + path := t.TempDir() + "/temporal.toml" + raw := map[string]any{ + "profile": map[string]any{"default": map[string]any{"address": "localhost:7233"}}, + } + state := updateCheckState{ + Enabled: true, + LatestVersion: "v1.8.0", + LastCheckedAt: time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC), + LastNotifiedAt: time.Date(2026, 7, 11, 13, 0, 0, 0, time.UTC), + NoticeCount: 2, + } + require.NoError(t, storeUpdateCheckState(path, raw, state)) + + loadedRaw, loaded, err := loadUpdateCheckState(path) + require.NoError(t, err) + require.Equal(t, state, loaded) + profiles := loadedRaw["profile"].(map[string]any) + require.Equal(t, "localhost:7233", profiles["default"].(map[string]any)["address"]) +} + +func TestUpdateCheckIntervals(t *testing.T) { + checkedAt := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC) + interval := updateCheckInterval("/tmp/temporal.toml", checkedAt) + require.GreaterOrEqual(t, interval, 60*time.Hour) + require.Less(t, interval, 84*time.Hour) + require.Equal(t, 24*time.Hour, noticeInterval(1)) + require.Equal(t, 72*time.Hour, noticeInterval(2)) + require.Equal(t, 7*24*time.Hour, noticeInterval(3)) + require.Equal(t, 14*24*time.Hour, noticeInterval(4)) +} + +func TestFetchLatestVersion(t *testing.T) { + client := updateCheckClientFunc(func(req *http.Request) (*http.Response, error) { + require.Equal(t, "temporal.download", req.URL.Host) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{ + "archiveUrl":"https://temporal.download/assets/temporalio/cli/releases/download/v1.8.0/temporal_cli_1.8.0_linux_amd64.tar.gz" + }`)), + }, nil + }) + version, err := fetchLatestVersion(context.Background(), client) + require.NoError(t, err) + require.Equal(t, "v1.8.0", version) +} + +func TestNormalizedVersion(t *testing.T) { + require.Equal(t, "v1.8.0", normalizedVersion("1.8.0")) + require.Equal(t, "v1.8.0", normalizedVersion("v1.8.0")) + require.Empty(t, normalizedVersion("not-a-version")) +} + +func TestRunUpdateCheckUsesCachedVersionAndBacksOffNotice(t *testing.T) { + path := t.TempDir() + "/temporal.toml" + now := time.Now().UTC() + require.NoError(t, storeUpdateCheckState(path, map[string]any{}, updateCheckState{ + Enabled: true, + LatestVersion: "v1.8.0", + LastCheckedAt: now, + })) + + originalVersion := Version + Version = "1.7.0" + t.Cleanup(func() { Version = originalVersion }) + var stderr bytes.Buffer + cctx := &CommandContext{ + Context: context.Background(), + Options: CommandOptions{ + Args: []string{"--version", "--config-file", path}, + IOStreams: IOStreams{Stderr: &stderr}, + }, + } + runUpdateCheck(cctx) + require.Contains(t, stderr.String(), "1.7.0 -> 1.8.0") + + stderr.Reset() + runUpdateCheck(cctx) + require.Empty(t, stderr.String()) + _, state, err := loadUpdateCheckState(path) + require.NoError(t, err) + require.Equal(t, 1, state.NoticeCount) +}