diff --git a/cli/azd/cmd/middleware/error.go b/cli/azd/cmd/middleware/error.go index 36558c2884b..855f4bdcfad 100644 --- a/cli/azd/cmd/middleware/error.go +++ b/cli/azd/cmd/middleware/error.go @@ -34,6 +34,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/project" "github.com/azure/azure-dev/cli/azd/pkg/tools" "github.com/azure/azure-dev/cli/azd/pkg/tools/pack" + "github.com/azure/azure-dev/cli/azd/pkg/update" uxlib "github.com/azure/azure-dev/cli/azd/pkg/ux" "go.opentelemetry.io/otel/codes" ) @@ -120,8 +121,16 @@ func shouldSkipErrorAnalysis(err error) bool { } // Environment was already initialized - _, ok := errors.AsType[*environment.EnvironmentInitError](err) - return ok + if _, ok := errors.AsType[*environment.EnvironmentInitError](err); ok { + return true + } + + // Update errors have their own user-facing messages and suggestions + if _, ok := errors.AsType[*update.UpdateError](err); ok { + return true + } + + return false } func NewErrorMiddleware( diff --git a/cli/azd/cmd/middleware/error_test.go b/cli/azd/cmd/middleware/error_test.go index 49c71286485..d73c552a7a4 100644 --- a/cli/azd/cmd/middleware/error_test.go +++ b/cli/azd/cmd/middleware/error_test.go @@ -26,6 +26,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/tools" "github.com/azure/azure-dev/cli/azd/pkg/tools/github" "github.com/azure/azure-dev/cli/azd/pkg/tools/pack" + "github.com/azure/azure-dev/cli/azd/pkg/update" "github.com/azure/azure-dev/cli/azd/test/mocks" "github.com/blang/semver/v4" "github.com/stretchr/testify/require" @@ -399,6 +400,19 @@ func Test_ShouldSkipErrorAnalysis(t *testing.T) { wrapped := fmt.Errorf("preflight declined: %w", internal.ErrAbortedByUser) require.True(t, shouldSkipErrorAnalysis(wrapped)) }) + + t.Run("UpdateError is skipped", func(t *testing.T) { + t.Parallel() + err := &update.UpdateError{Code: update.CodeDownloadFailed, Err: errors.New("download failed")} + require.True(t, shouldSkipErrorAnalysis(err)) + }) + + t.Run("Wrapped UpdateError is skipped", func(t *testing.T) { + t.Parallel() + inner := &update.UpdateError{Code: update.CodeReplaceFailed, Err: errors.New("replace failed")} + wrapped := fmt.Errorf("update error: %w", inner) + require.True(t, shouldSkipErrorAnalysis(wrapped)) + }) } func Test_TroubleshootCategory_Constants(t *testing.T) { diff --git a/cli/azd/cmd/root.go b/cli/azd/cmd/root.go index 9e3fccffe1b..d99c9c8337e 100644 --- a/cli/azd/cmd/root.go +++ b/cli/azd/cmd/root.go @@ -217,6 +217,9 @@ func newRootCmd( ActionResolver: newUpdateAction, OutputFormats: []output.Format{output.NoneFormat}, DefaultFormat: output.NoneFormat, + GroupingOptions: actions.CommandGroupOptions{ + RootLevelHelp: actions.CmdGroupBeta, + }, }) root.Add("vs-server", &actions.ActionDescriptorOptions{ diff --git a/cli/azd/cmd/testdata/TestFigSpec.ts b/cli/azd/cmd/testdata/TestFigSpec.ts index 164f0467d0c..b2f2b40cd25 100644 --- a/cli/azd/cmd/testdata/TestFigSpec.ts +++ b/cli/azd/cmd/testdata/TestFigSpec.ts @@ -2828,6 +2828,30 @@ const completionSpec: Fig.Spec = { }, ], }, + { + name: ['update'], + description: 'Updates azd to the latest version.', + options: [ + { + name: ['--channel'], + description: 'Update channel: stable or daily.', + args: [ + { + name: 'channel', + }, + ], + }, + { + name: ['--check-interval-hours'], + description: 'Override the update check interval in hours.', + args: [ + { + name: 'check-interval-hours', + }, + ], + }, + ], + }, { name: ['version'], description: 'Print the version number of Azure Developer CLI.', @@ -3623,6 +3647,10 @@ const completionSpec: Fig.Spec = { name: ['up'], description: 'Provision and deploy your project to Azure with a single command.', }, + { + name: ['update'], + description: 'Updates azd to the latest version.', + }, { name: ['version'], description: 'Print the version number of Azure Developer CLI.', diff --git a/cli/azd/cmd/testdata/TestUsage-azd-update.snap b/cli/azd/cmd/testdata/TestUsage-azd-update.snap new file mode 100644 index 00000000000..28970b06c8a --- /dev/null +++ b/cli/azd/cmd/testdata/TestUsage-azd-update.snap @@ -0,0 +1,21 @@ + +Updates azd to the latest version. + +Usage + azd update [flags] + +Flags + --channel string : Update channel: stable or daily. + --check-interval-hours int : Override the update check interval in hours. + +Global Flags + -C, --cwd string : Sets the current working directory. + --debug : Enables debugging and diagnostics logging. + --docs : Opens the documentation for azd update in your web browser. + -e, --environment string : The name of the environment to use. + -h, --help : Gets help for update. + --no-prompt : Accepts the default value instead of prompting, or it fails if there is no default. + +Find a bug? Want to let us know how we're doing? Fill out this brief survey: https://aka.ms/azure-dev/hats. + + diff --git a/cli/azd/cmd/testdata/TestUsage-azd.snap b/cli/azd/cmd/testdata/TestUsage-azd.snap index 9ca798a1ce5..46400066c1f 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd.snap @@ -33,6 +33,7 @@ Commands pipeline : Manage and configure your deployment pipelines. restore : Restores the project's dependencies. template : Find and view template details. + update : Updates azd to the latest version. Enabled alpha commands copilot : Manage GitHub Copilot agent settings. (Preview) diff --git a/cli/azd/cmd/update.go b/cli/azd/cmd/update.go index cdfcf3134e2..1b2151968df 100644 --- a/cli/azd/cmd/update.go +++ b/cli/azd/cmd/update.go @@ -8,13 +8,13 @@ import ( "errors" "fmt" "io" + "log" "github.com/azure/azure-dev/cli/azd/cmd/actions" "github.com/azure/azure-dev/cli/azd/internal" "github.com/azure/azure-dev/cli/azd/internal/tracing" "github.com/azure/azure-dev/cli/azd/internal/tracing/fields" "github.com/azure/azure-dev/cli/azd/internal/tracing/resource" - "github.com/azure/azure-dev/cli/azd/pkg/alpha" "github.com/azure/azure-dev/cli/azd/pkg/config" "github.com/azure/azure-dev/cli/azd/pkg/exec" "github.com/azure/azure-dev/cli/azd/pkg/input" @@ -57,20 +57,18 @@ func (f *updateFlags) Bind(local *pflag.FlagSet, global *internal.GlobalCommandO func newUpdateCmd() *cobra.Command { return &cobra.Command{ - Use: "update", - Short: "Updates azd to the latest version.", - Hidden: true, + Use: "update", + Short: "Updates azd to the latest version.", } } type updateAction struct { - flags *updateFlags - console input.Console - formatter output.Formatter - writer io.Writer - configManager config.UserConfigManager - commandRunner exec.CommandRunner - alphaFeatureManager *alpha.FeatureManager + flags *updateFlags + console input.Console + formatter output.Formatter + writer io.Writer + configManager config.UserConfigManager + commandRunner exec.CommandRunner } func newUpdateAction( @@ -80,16 +78,14 @@ func newUpdateAction( writer io.Writer, configManager config.UserConfigManager, commandRunner exec.CommandRunner, - alphaFeatureManager *alpha.FeatureManager, ) actions.Action { return &updateAction{ - flags: flags, - console: console, - formatter: formatter, - writer: writer, - configManager: configManager, - commandRunner: commandRunner, - alphaFeatureManager: alphaFeatureManager, + flags: flags, + console: console, + formatter: formatter, + writer: writer, + configManager: configManager, + commandRunner: commandRunner, } } @@ -102,27 +98,6 @@ func (a *updateAction) Run(ctx context.Context) (*actions.ActionResult, error) { } } - // Auto-enable the alpha feature if not already enabled. - // The user's intent is clear by running `azd update` directly. - if !a.alphaFeatureManager.IsEnabled(update.FeatureUpdate) { - userCfg, err := a.configManager.Load() - if err != nil { - userCfg = config.NewEmptyConfig() - } - - if err := userCfg.Set(fmt.Sprintf("alpha.%s", update.FeatureUpdate), "on"); err != nil { - return nil, fmt.Errorf("failed to enable update feature: %w", err) - } - - if err := a.configManager.Save(userCfg); err != nil { - return nil, fmt.Errorf("failed to save config: %w", err) - } - - a.console.MessageUxItem(ctx, &ux.MessageTitle{ - Title: "azd update is in alpha. Channel-aware version checks are now enabled.\n", - }) - } - // Track install method for telemetry installedBy := installer.InstalledBy() tracing.SetUsageAttributes( @@ -134,13 +109,31 @@ func (a *updateAction) Run(ctx context.Context) (*actions.ActionResult, error) { userConfig = config.NewEmptyConfig() } + // Show notice on first use + if !update.HasUpdateConfig(userConfig) { + a.console.MessageUxItem(ctx, &ux.MessageTitle{ + Title: fmt.Sprintf( + "azd update is currently in Beta. "+ + "To learn more about feature stages, visit %s.", + output.WithLinkFormat("https://aka.ms/azd-feature-stages")), + }) + + // Write a default channel so HasUpdateConfig returns true next time. + if err := update.SaveChannel(userConfig, update.LoadUpdateConfig(userConfig).Channel); err != nil { + log.Printf("warning: failed to persist default update channel: %v", err) + } else if err := a.configManager.Save(userConfig); err != nil { + log.Printf("warning: failed to save config after setting default channel: %v", err) + } + } + // Determine current channel BEFORE persisting any flags currentCfg := update.LoadUpdateConfig(userConfig) switchingChannels := a.flags.channel != "" && update.Channel(a.flags.channel) != currentCfg.Channel - // Persist non-channel config flags immediately (auto-update, check-interval) + // Persist non-channel config flags immediately (check-interval) configChanged, err := a.persistNonChannelFlags(userConfig) if err != nil { + tracing.SetUsageAttributes(fields.UpdateResult.String(update.CodeConfigFailed)) return nil, err } @@ -149,6 +142,7 @@ func (a *updateAction) Run(ctx context.Context) (*actions.ActionResult, error) { if switchingChannels { newChannel, err := update.ParseChannel(a.flags.channel) if err != nil { + tracing.SetUsageAttributes(fields.UpdateResult.String(update.CodeInvalidInput)) return nil, err } _ = update.SaveChannel(userConfig, newChannel) @@ -156,6 +150,7 @@ func (a *updateAction) Run(ctx context.Context) (*actions.ActionResult, error) { } else if a.flags.channel != "" { // Same channel explicitly set — just persist it if err := update.SaveChannel(userConfig, update.Channel(a.flags.channel)); err != nil { + tracing.SetUsageAttributes(fields.UpdateResult.String(update.CodeConfigFailed)) return nil, err } configChanged = true @@ -169,6 +164,22 @@ func (a *updateAction) Run(ctx context.Context) (*actions.ActionResult, error) { fields.UpdateFromVersion.String(internal.VersionInfo().Version.String()), ) + // If only config flags were set (no channel change, no update needed), just confirm + if a.onlyConfigFlagsSet() { + if configChanged { + if err := a.configManager.Save(userConfig); err != nil { + tracing.SetUsageAttributes(fields.UpdateResult.String(update.CodeConfigFailed)) + return nil, fmt.Errorf("failed to save config: %w", err) + } + } + tracing.SetUsageAttributes(fields.UpdateResult.String(update.CodeSuccess)) + return &actions.ActionResult{ + Message: &actions.ResultMessage{ + Header: "Update preferences saved.", + }, + }, nil + } + mgr := update.NewManager(a.commandRunner, nil) // Block update in CI/CD environments @@ -201,21 +212,6 @@ func (a *updateAction) Run(ctx context.Context) (*actions.ActionResult, error) { } } - // If only config flags were set (no channel change, no update needed), just confirm - if a.onlyConfigFlagsSet() { - if configChanged { - if err := a.configManager.Save(userConfig); err != nil { - return nil, fmt.Errorf("failed to save config: %w", err) - } - } - tracing.SetUsageAttributes(fields.UpdateResult.String(update.CodeSuccess)) - return &actions.ActionResult{ - Message: &actions.ResultMessage{ - Header: "Update preferences saved.", - }, - }, nil - } - // Check for updates (always fresh for manual invocation) a.console.ShowSpinner(ctx, "Checking for updates...", input.Step) versionInfo, err := mgr.CheckForUpdate(ctx, cfg, true) @@ -273,6 +269,7 @@ func (a *updateAction) Run(ctx context.Context) (*actions.ActionResult, error) { // Now persist all config changes (including channel) after confirmation if configChanged { if err := a.configManager.Save(userConfig); err != nil { + tracing.SetUsageAttributes(fields.UpdateResult.String(update.CodeConfigFailed)) return nil, fmt.Errorf("failed to save config: %w", err) } } diff --git a/cli/azd/cmd/update_test.go b/cli/azd/cmd/update_test.go index ab5b0c2aa5c..7d1fe0e56b0 100644 --- a/cli/azd/cmd/update_test.go +++ b/cli/azd/cmd/update_test.go @@ -4,6 +4,7 @@ package cmd import ( + "strings" "testing" "github.com/azure/azure-dev/cli/azd/pkg/config" @@ -101,3 +102,30 @@ func TestPersistNonChannelFlags(t *testing.T) { assert.Equal(t, 12, updateCfg.CheckIntervalHours) }) } + +func TestUpdateErrorCodes(t *testing.T) { + t.Parallel() + + // Verify telemetry result codes used in updateAction.Run() are non-empty + // and follow the expected "update." prefix convention. + codes := []string{ + update.CodeSuccess, + update.CodeAlreadyUpToDate, + update.CodeVersionCheckFailed, + update.CodeSkippedCI, + update.CodePackageManagerFailed, + update.CodeChannelSwitchDecline, + update.CodeReplaceFailed, + update.CodeConfigFailed, + update.CodeInvalidInput, + } + + seen := make(map[string]bool, len(codes)) + for _, code := range codes { + assert.NotEmpty(t, code) + assert.True(t, strings.HasPrefix(code, "update."), + "code %q should have prefix %q", code, "update.") + assert.False(t, seen[code], "duplicate code %q", code) + seen[code] = true + } +} diff --git a/cli/azd/cmd/version.go b/cli/azd/cmd/version.go index 8298ebc9dc0..1012fcd8053 100644 --- a/cli/azd/cmd/version.go +++ b/cli/azd/cmd/version.go @@ -10,7 +10,6 @@ import ( "github.com/azure/azure-dev/cli/azd/cmd/actions" "github.com/azure/azure-dev/cli/azd/internal" - "github.com/azure/azure-dev/cli/azd/pkg/alpha" "github.com/azure/azure-dev/cli/azd/pkg/contracts" "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/output" @@ -35,11 +34,10 @@ func newVersionFlags(cmd *cobra.Command, global *internal.GlobalCommandOptions) } type versionAction struct { - flags *versionFlags - formatter output.Formatter - writer io.Writer - console input.Console - alphaFeatureManager *alpha.FeatureManager + flags *versionFlags + formatter output.Formatter + writer io.Writer + console input.Console } func newVersionAction( @@ -47,14 +45,12 @@ func newVersionAction( formatter output.Formatter, writer io.Writer, console input.Console, - alphaFeatureManager *alpha.FeatureManager, ) actions.Action { return &versionAction{ - flags: flags, - formatter: formatter, - writer: writer, - console: console, - alphaFeatureManager: alphaFeatureManager, + flags: flags, + formatter: formatter, + writer: writer, + console: console, } } @@ -81,12 +77,7 @@ func (v *versionAction) Run(ctx context.Context) (*actions.ActionResult, error) // channelSuffix returns a display suffix like " (stable)" or " (daily)". // Based on the running binary's version string, not the configured channel. -// Only shown when the update alpha feature is enabled. func (v *versionAction) channelSuffix() string { - if !v.alphaFeatureManager.IsEnabled(update.FeatureUpdate) { - return "" - } - // Detect from the binary itself: if the version contains "daily.", it's a daily build. if _, err := update.ParseDailyBuildNumber(internal.Version); err == nil { return " (daily)" diff --git a/cli/azd/cmd/version_test.go b/cli/azd/cmd/version_test.go index ff15f0bf0af..3c37e627844 100644 --- a/cli/azd/cmd/version_test.go +++ b/cli/azd/cmd/version_test.go @@ -10,8 +10,6 @@ import ( "testing" "github.com/azure/azure-dev/cli/azd/internal" - "github.com/azure/azure-dev/cli/azd/pkg/alpha" - "github.com/azure/azure-dev/cli/azd/pkg/config" "github.com/azure/azure-dev/cli/azd/pkg/contracts" "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/azure/azure-dev/cli/azd/test/mocks" @@ -27,7 +25,6 @@ func TestVersionAction_NoneFormat(t *testing.T) { &output.NoneFormatter{}, &bytes.Buffer{}, mockContext.Console, - mockContext.AlphaFeaturesManager, ) result, err := action.Run(t.Context()) @@ -45,7 +42,6 @@ func TestVersionAction_JsonFormat(t *testing.T) { &output.JsonFormatter{}, buf, mockContext.Console, - mockContext.AlphaFeaturesManager, ) result, err := action.Run(t.Context()) @@ -64,40 +60,14 @@ func TestVersionAction_JsonFormat(t *testing.T) { func TestVersionAction_ChannelSuffix(t *testing.T) { t.Parallel() - t.Run("update_feature_disabled", func(t *testing.T) { - t.Parallel() - mockContext := mocks.NewMockContext(context.Background()) + va := &versionAction{ + flags: &versionFlags{}, + formatter: &output.NoneFormatter{}, + writer: &bytes.Buffer{}, + } - va := &versionAction{ - flags: &versionFlags{}, - formatter: &output.NoneFormatter{}, - writer: &bytes.Buffer{}, - console: mockContext.Console, - alphaFeatureManager: mockContext.AlphaFeaturesManager, - } - - suffix := va.channelSuffix() - require.Equal(t, "", suffix) - }) - - t.Run("update_feature_enabled_stable", func(t *testing.T) { - t.Parallel() - - cfg := config.NewEmptyConfig() - _ = cfg.Set("alpha.update", "on") - fm := alpha.NewFeaturesManagerWithConfig(cfg) - - va := &versionAction{ - flags: &versionFlags{}, - formatter: &output.NoneFormatter{}, - writer: &bytes.Buffer{}, - console: nil, // not needed for channelSuffix - alphaFeatureManager: fm, - } - - suffix := va.channelSuffix() - // In test builds, internal.Version is "0.0.0-dev.0" (not daily format) - // so it will either return " (stable)" or " (daily)" depending on version - require.NotEqual(t, "", suffix) - }) + suffix := va.channelSuffix() + // In test builds, internal.Version is "0.0.0-dev.0" (not daily format) + // so it will return " (stable)" + require.Equal(t, " (stable)", suffix) } diff --git a/cli/azd/docs/design/azd-update.md b/cli/azd/docs/design/azd-update.md index fd014732d15..c69b741aba8 100644 --- a/cli/azd/docs/design/azd-update.md +++ b/cli/azd/docs/design/azd-update.md @@ -13,7 +13,7 @@ Today, when a new version of `azd` is available, users see a warning message wit 1. **`azd update`** — a command that performs the update for the user 2. **Channel management** — ability to switch between `stable` and `daily` builds -The feature ships as a hidden command behind an alpha feature toggle (`alpha.update`) for safe rollout. When the toggle is off, there are zero changes to existing behavior — `azd version`, update notifications, everything stays exactly as it is today. +The feature ships as a command currently in Beta. On first use, a notice is displayed. The `azd update` command is always available. --- @@ -23,7 +23,7 @@ The feature ships as a hidden command behind an alpha feature toggle (`alpha.upd - Preserve user control (channel selection, check interval) - Avoid disruption to CI/CD pipelines - Respect platform install methods (MSI, Install Scripts, Homebrew, winget, choco) -- Ship safely behind an alpha feature flag with zero impact when off +- Ship safely as a beta feature while gathering feedback --- @@ -122,8 +122,6 @@ azd config set updates.checkIntervalHours 4 Channel is set via `azd update --channel ` (which persists the choice to `updates.channel` config). Default channel is `stable`. -These follow the existing convention of `"on"/"off"` for boolean-like config values (consistent with alpha features). - ### 2. Daily Build Version Tracking **Problem**: Daily builds share a base semver (e.g., `1.24.0-beta.1`), so version comparison alone can't tell if a newer daily exists. @@ -315,8 +313,6 @@ azd version 1.24.0-beta.1-daily.5935787 (commit abc1234) (daily) The channel suffix is derived from the running binary's version string (presence of `daily.` pattern), not the configured channel. This means the output always reflects what the binary actually is. -When the feature toggle is off, `azd version` output stays unchanged — no suffix, no channel info. - ### 7. Telemetry Uses the existing azd telemetry infrastructure (OpenTelemetry). New telemetry fields tracked on every update operation: @@ -346,17 +342,18 @@ Uses the existing azd telemetry infrastructure (OpenTelemetry). New telemetry fi | `update.unsupportedInstallMethod` | Unknown or unsupported install method | | `update.channelSwitchDowngrade` | User declined when switching channels | | `update.skippedCI` | Skipped due to CI/non-interactive environment | +| `update.nonStandardInstall` | Non-standard install location detected | +| `update.configFailed` | Failed to read or persist user config | +| `update.invalidInput` | Invalid flag value (e.g., unrecognized channel) | These codes are integrated into azd's `MapError` pipeline, so update failures show up properly in telemetry dashboards alongside other command errors. -### 8. Feature Toggle (Alpha Gate) - -The entire update feature ships behind `alpha.update` (default: off). This means: +### 8. Feature Stage (Beta) -- **Toggle off** (default): Zero behavior changes. `azd version` output is the same. Update notification shows the existing platform-specific install instructions. Running `azd update` auto-enables the feature. -- **Toggle on** (`azd config set alpha.update on`): All update features are active — `azd update` works, `azd version` shows the channel suffix, notifications say "run `azd update`." +The update command is listed as **Beta** in [`feature-stages.md`](../feature-stages.md). -This lets us roll out to internal users first, gather feedback, and fix issues before broader availability. Once stable, the toggle can be removed and the feature enabled by default. +- `azd update` works without needing any config toggle. +- On first use, when no `updates.*` configuration exists, a notice is displayed and a default channel is persisted so the notice only appears once. ### 9. Update Banner Suppression diff --git a/cli/azd/docs/feature-stages.md b/cli/azd/docs/feature-stages.md index 965c979b7b9..c0601db3780 100644 --- a/cli/azd/docs/feature-stages.md +++ b/cli/azd/docs/feature-stages.md @@ -22,6 +22,7 @@ As of `1.21.1`, each Azure Developer CLI feature has been evaluated and assigned | Command | package | Beta | | Command | add | Beta | | Command | infra generate | Beta | +| Command | update | Beta | | Language | Python | Stable | | Language | JavaScript/TypeScript | Stable | | Language | Java | Stable | diff --git a/cli/azd/main.go b/cli/azd/main.go index 88baff5a054..38bdacb1820 100644 --- a/cli/azd/main.go +++ b/cli/azd/main.go @@ -22,7 +22,6 @@ import ( "github.com/azure/azure-dev/cli/azd/internal" "github.com/azure/azure-dev/cli/azd/internal/telemetry" "github.com/azure/azure-dev/cli/azd/internal/tracing" - "github.com/azure/azure-dev/cli/azd/pkg/alpha" "github.com/azure/azure-dev/cli/azd/pkg/config" "github.com/azure/azure-dev/cli/azd/pkg/installer" "github.com/azure/azure-dev/cli/azd/pkg/ioc" @@ -110,15 +109,14 @@ func main() { currentVersionStr, versionInfo.Channel, latestVersionStr)) fmt.Fprintln(os.Stderr) - // Show "azd update" hint only if the update feature is enabled, + // Show "azd update" hint if the user has update config set, // otherwise show the original platform-specific upgrade instructions. configMgr := config.NewUserConfigManager(config.NewFileConfigManager(config.NewManager())) userCfg, cfgErr := configMgr.Load() if cfgErr != nil { userCfg = config.NewEmptyConfig() } - featureManager := alpha.NewFeaturesManagerWithConfig(userCfg) - if featureManager.IsEnabled(update.FeatureUpdate) { + if update.HasUpdateConfig(userCfg) { fmt.Fprintln( os.Stderr, output.WithWarningFormat("To update to the latest version, run: azd update")) diff --git a/cli/azd/pkg/update/config.go b/cli/azd/pkg/update/config.go index 79703d3505f..681e691bb77 100644 --- a/cli/azd/pkg/update/config.go +++ b/cli/azd/pkg/update/config.go @@ -16,14 +16,10 @@ import ( "time" "github.com/azure/azure-dev/cli/azd/internal" - "github.com/azure/azure-dev/cli/azd/pkg/alpha" "github.com/azure/azure-dev/cli/azd/pkg/config" "github.com/azure/azure-dev/cli/azd/pkg/osutil" ) -// FeatureUpdate is the alpha feature key for the azd update command. -var FeatureUpdate = alpha.MustFeatureKey("update") - // Channel represents the update channel for azd builds. type Channel string @@ -151,6 +147,18 @@ func SaveCheckIntervalHours(cfg config.Config, hours int) error { return cfg.Set(configKeyCheckIntervalHours, hours) } +// HasUpdateConfig returns true if the user has any update configuration set. +// Also returns true for the legacy alpha.update key so that users who previously +// enabled the alpha feature are treated as having update config (skipping the +// first-use notice and showing the "azd update" hint). +func HasUpdateConfig(cfg config.Config) bool { + _, hasChannel := cfg.Get(configKeyChannel) + _, hasAutoUpdate := cfg.Get(configKeyAutoUpdate) + _, hasInterval := cfg.Get(configKeyCheckIntervalHours) + _, hasLegacyAlpha := cfg.Get("alpha.update") + return hasChannel || hasAutoUpdate || hasInterval || hasLegacyAlpha +} + // CacheFile represents the cached version check result. type CacheFile struct { // Channel is the update channel this cache entry is for. diff --git a/cli/azd/pkg/update/config_test.go b/cli/azd/pkg/update/config_test.go index ff3b2310ed3..551def337a2 100644 --- a/cli/azd/pkg/update/config_test.go +++ b/cli/azd/pkg/update/config_test.go @@ -162,6 +162,119 @@ func TestSaveAndLoadConfig(t *testing.T) { require.Equal(t, 6, loaded.CheckIntervalHours) } +func TestHasUpdateConfig(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config map[string]any + want bool + }{ + { + name: "empty config", + config: map[string]any{}, + want: false, + }, + { + name: "channel only", + config: map[string]any{ + "updates": map[string]any{ + "channel": "stable", + }, + }, + want: true, + }, + { + name: "autoUpdate only", + config: map[string]any{ + "updates": map[string]any{ + "autoUpdate": "on", + }, + }, + want: true, + }, + { + name: "checkIntervalHours only", + config: map[string]any{ + "updates": map[string]any{ + "checkIntervalHours": float64(12), + }, + }, + want: true, + }, + { + name: "all keys set", + config: map[string]any{ + "updates": map[string]any{ + "channel": "daily", + "autoUpdate": "on", + "checkIntervalHours": float64(8), + }, + }, + want: true, + }, + { + name: "legacy alpha.update only", + config: map[string]any{ + "alpha": map[string]any{ + "update": "on", + }, + }, + want: true, + }, + { + name: "unrelated config", + config: map[string]any{ + "some": map[string]any{ + "other": "value", + }, + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cfg := config.NewConfig(tt.config) + require.Equal(t, tt.want, HasUpdateConfig(cfg)) + }) + } +} + +func TestFirstUsePersistenceLogic(t *testing.T) { + t.Parallel() + + t.Run("empty config triggers first-use notice", func(t *testing.T) { + t.Parallel() + + cfg := config.NewEmptyConfig() + require.False(t, HasUpdateConfig(cfg), "empty config should not have update config") + + // Simulate first-use: persist default channel + defaultChannel := LoadUpdateConfig(cfg).Channel + require.NoError(t, SaveChannel(cfg, defaultChannel)) + + // After persisting, HasUpdateConfig should return true + require.True(t, HasUpdateConfig(cfg), "config should have update config after SaveChannel") + + // Subsequent runs should skip the notice + require.True(t, HasUpdateConfig(cfg)) + }) + + t.Run("existing config skips first-use notice", func(t *testing.T) { + t.Parallel() + + cfg := config.NewConfig(map[string]any{ + "updates": map[string]any{ + "channel": "stable", + }, + }) + require.True(t, HasUpdateConfig(cfg)) + }) +} + func TestIsCacheValid(t *testing.T) { future := time.Now().UTC().Add(1 * time.Hour).Format(time.RFC3339) past := time.Now().UTC().Add(-1 * time.Hour).Format(time.RFC3339) diff --git a/cli/azd/pkg/update/errors.go b/cli/azd/pkg/update/errors.go index 8a17ae21b51..898820e3c19 100644 --- a/cli/azd/pkg/update/errors.go +++ b/cli/azd/pkg/update/errors.go @@ -36,6 +36,8 @@ const ( CodeElevationRequired = "update.elevationRequired" CodeUnsupportedInstallMethod = "update.unsupportedInstallMethod" CodeNonStandardInstall = "update.nonStandardInstall" + CodeConfigFailed = "update.configFailed" + CodeInvalidInput = "update.invalidInput" ) func newUpdateError(code string, err error) *UpdateError { diff --git a/cli/azd/resources/alpha_features.yaml b/cli/azd/resources/alpha_features.yaml index d6141b01ebd..d872e7efa3e 100644 --- a/cli/azd/resources/alpha_features.yaml +++ b/cli/azd/resources/alpha_features.yaml @@ -14,5 +14,3 @@ description: "Enables the use of LLMs in the CLI with support for intelligent azd init assistance and error handling workflows." - id: language.custom description: "Enables support for services to use custom language." -- id: update - description: "Enables the azd update command for channel management."