diff --git a/cli/azd/.vscode/cspell-azd-dictionary.txt b/cli/azd/.vscode/cspell-azd-dictionary.txt index 8880468a88f..20a319c4bbe 100644 --- a/cli/azd/.vscode/cspell-azd-dictionary.txt +++ b/cli/azd/.vscode/cspell-azd-dictionary.txt @@ -18,6 +18,7 @@ GOCOVERDIR Ghostty LASTEXITCODE MCPJSON +PSHOME PYTHONDONTWRITEBYTECODE PYTHONUNBUFFERED RPCJSONRPC diff --git a/cli/azd/cmd/final_coverage3_test.go b/cli/azd/cmd/final_coverage3_test.go index 87d0b578e7f..3d2c60b8e1a 100644 --- a/cli/azd/cmd/final_coverage3_test.go +++ b/cli/azd/cmd/final_coverage3_test.go @@ -283,18 +283,19 @@ func Test_UpdateAction_PersistNonChannelFlags(t *testing.T) { t.Parallel() // Test with positive check interval - a := &updateAction{flags: &updateFlags{checkIntervalHours: 24}} + a := &updateAction{ + flags: &updateFlags{checkIntervalHours: 24}, + configManager: &simpleConfigMgr{}, + } cfg := config.NewEmptyConfig() - changed, err := a.persistNonChannelFlags(cfg) + err := a.persistNonChannelFlags(cfg) require.NoError(t, err) - require.True(t, changed) // Test with zero check interval a2 := &updateAction{flags: &updateFlags{checkIntervalHours: 0}} cfg2 := config.NewEmptyConfig() - changed2, err := a2.persistNonChannelFlags(cfg2) + err = a2.persistNonChannelFlags(cfg2) require.NoError(t, err) - require.False(t, changed2) } // =========================================================================== diff --git a/cli/azd/cmd/update.go b/cli/azd/cmd/update.go index 1b2151968df..2ad3902781d 100644 --- a/cli/azd/cmd/update.go +++ b/cli/azd/cmd/update.go @@ -119,7 +119,7 @@ func (a *updateAction) Run(ctx context.Context) (*actions.ActionResult, error) { }) // Write a default channel so HasUpdateConfig returns true next time. - if err := update.SaveChannel(userConfig, update.LoadUpdateConfig(userConfig).Channel); err != nil { + if err := update.SetChannel(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) @@ -131,7 +131,7 @@ func (a *updateAction) Run(ctx context.Context) (*actions.ActionResult, error) { switchingChannels := a.flags.channel != "" && update.Channel(a.flags.channel) != currentCfg.Channel // Persist non-channel config flags immediately (check-interval) - configChanged, err := a.persistNonChannelFlags(userConfig) + err = a.persistNonChannelFlags(userConfig) if err != nil { tracing.SetUsageAttributes(fields.UpdateResult.String(update.CodeConfigFailed)) return nil, err @@ -139,21 +139,22 @@ func (a *updateAction) Run(ctx context.Context) (*actions.ActionResult, error) { // If switching channels, persist channel to a temporary config for the version check // but don't save to disk until after confirmation + channelChanged := false 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) - configChanged = true + _ = update.SetChannel(userConfig, newChannel) + channelChanged = true } else if a.flags.channel != "" { // Same channel explicitly set — just persist it - if err := update.SaveChannel(userConfig, update.Channel(a.flags.channel)); err != nil { + if err := update.SetChannel(userConfig, update.Channel(a.flags.channel)); err != nil { tracing.SetUsageAttributes(fields.UpdateResult.String(update.CodeConfigFailed)) return nil, err } - configChanged = true + channelChanged = true } cfg := update.LoadUpdateConfig(userConfig) @@ -164,14 +165,9 @@ 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 only config flags were set (no channel change, no update needed), just confirm. + // Non-channel config was already saved to disk inside persistNonChannelFlags. 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{ @@ -266,14 +262,6 @@ 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) - } - } - // Perform the update a.console.MessageUxItem(ctx, &ux.MessageTitle{ Title: fmt.Sprintf("Updating azd to %s (%s)", versionInfo.Version, cfg.Channel), @@ -290,6 +278,21 @@ func (a *updateAction) Run(ctx context.Context) (*actions.ActionResult, error) { return nil, err } + // Persist channel config changes only after a successful update. + // Non-channel preferences were already saved before the update attempt. + // Treat save failures as non-fatal since the binary was already updated successfully. + // Guide the user to manually persist the channel if saving fails. + if channelChanged { + if err := a.configManager.Save(userConfig); err != nil { + log.Printf("warning: update succeeded but failed to save channel config: %v", err) + a.console.Message(ctx, output.WithWarningFormat( + "WARNING: failed to save channel preference. "+ + "Run 'azd config set updates.channel %s' to persist it.", + cfg.Channel, + )) + } + } + tracing.SetUsageAttributes(fields.UpdateResult.String(update.CodeSuccess)) // Clean up any staged binary now that a manual update succeeded @@ -307,17 +310,19 @@ func (a *updateAction) Run(ctx context.Context) (*actions.ActionResult, error) { // persistNonChannelFlags saves check-interval flags to config. // Channel is handled separately to allow confirmation before persisting. -func (a *updateAction) persistNonChannelFlags(cfg config.Config) (bool, error) { - changed := false - +func (a *updateAction) persistNonChannelFlags(cfg config.Config) error { if a.flags.checkIntervalHours > 0 { - if err := update.SaveCheckIntervalHours(cfg, a.flags.checkIntervalHours); err != nil { - return false, err + if err := update.SetCheckIntervalHours(cfg, a.flags.checkIntervalHours); err != nil { + return err + } + + if err := a.configManager.Save(cfg); err != nil { + tracing.SetUsageAttributes(fields.UpdateResult.String(update.CodeConfigFailed)) + return fmt.Errorf("failed to save config: %w", err) } - changed = true } - return changed, nil + return nil } // onlyConfigFlagsSet returns true if only config flags were provided (no channel that requires an update). diff --git a/cli/azd/cmd/update_test.go b/cli/azd/cmd/update_test.go index 7d1fe0e56b0..3f555ff2cad 100644 --- a/cli/azd/cmd/update_test.go +++ b/cli/azd/cmd/update_test.go @@ -78,9 +78,8 @@ func TestPersistNonChannelFlags(t *testing.T) { } cfg := config.NewEmptyConfig() - changed, err := action.persistNonChannelFlags(cfg) + err := action.persistNonChannelFlags(cfg) require.NoError(t, err) - assert.False(t, changed) }) t.Run("interval_set", func(t *testing.T) { @@ -90,12 +89,12 @@ func TestPersistNonChannelFlags(t *testing.T) { flags: &updateFlags{ checkIntervalHours: 12, }, + configManager: &simpleConfigMgr{}, } cfg := config.NewEmptyConfig() - changed, err := action.persistNonChannelFlags(cfg) + err := action.persistNonChannelFlags(cfg) require.NoError(t, err) - assert.True(t, changed) // Verify the interval was saved updateCfg := update.LoadUpdateConfig(cfg) diff --git a/cli/azd/docs/design/azd-update.md b/cli/azd/docs/design/azd-update.md index c69b741aba8..fb50c37eab0 100644 --- a/cli/azd/docs/design/azd-update.md +++ b/cli/azd/docs/design/azd-update.md @@ -282,7 +282,7 @@ azd update --channel daily ? Switch from daily channel (1.24.0-beta.1-daily.5935787) to stable channel (1.23.6)? [Y/n] ``` -If the user declines, the command prints "Channel switch cancelled." (no SUCCESS banner) and exits without modifying config or downloading anything. The channel config is only persisted after confirmation. +If the user declines, the command prints "Channel switch cancelled." (no SUCCESS banner) and exits without modifying config or downloading anything. The channel config is only persisted after a successful update. If the update succeeds but the channel config save fails, a warning is shown with a remediation command (`azd config set updates.channel `) — the update itself is not rolled back. #### Cross Install Method @@ -290,7 +290,7 @@ Switching between a package manager and direct installs is **not supported** via | Scenario | Guidance | |----------|----------| -| Package manager → daily | Show: "Daily builds aren't available via {brew/winget/choco}. Uninstall with `{uninstall command}`, then install daily with the platform-appropriate daily install command (`install-azd.ps1` on Windows, `install-azd.sh` on Linux/macOS)" | +| Package manager → daily | Show: "Daily builds aren't available via {winget/choco}. Uninstall with `{uninstall command}`, then install daily with the platform-appropriate daily install command (`install-azd.ps1` on Windows, `install-azd.sh` on Linux/macOS)" | | Script/daily → package manager | Show: "To switch to {brew/winget/choco}, first uninstall the current version, then install via your package manager." | This avoids the silent symlink overwrite problem that exists today with conflicting install methods. diff --git a/cli/azd/pkg/update/config.go b/cli/azd/pkg/update/config.go index 681e691bb77..ecee6cb2c6e 100644 --- a/cli/azd/pkg/update/config.go +++ b/cli/azd/pkg/update/config.go @@ -128,13 +128,13 @@ func LoadUpdateConfig(cfg config.Config) *UpdateConfig { return uc } -// SaveChannel persists the channel to user config. -func SaveChannel(cfg config.Config, channel Channel) error { +// SetChannel persists the channel to user config. +func SetChannel(cfg config.Config, channel Channel) error { return cfg.Set(configKeyChannel, string(channel)) } -// SaveAutoUpdate persists the auto-update setting to user config. -func SaveAutoUpdate(cfg config.Config, enabled bool) error { +// SetAutoUpdate persists the auto-update setting to user config. +func SetAutoUpdate(cfg config.Config, enabled bool) error { value := "off" if enabled { value = "on" @@ -142,8 +142,8 @@ func SaveAutoUpdate(cfg config.Config, enabled bool) error { return cfg.Set(configKeyAutoUpdate, value) } -// SaveCheckIntervalHours persists the check interval to user config. -func SaveCheckIntervalHours(cfg config.Config, hours int) error { +// SetCheckIntervalHours persists the check interval to user config. +func SetCheckIntervalHours(cfg config.Config, hours int) error { return cfg.Set(configKeyCheckIntervalHours, hours) } diff --git a/cli/azd/pkg/update/config_test.go b/cli/azd/pkg/update/config_test.go index 551def337a2..80ff51f6d69 100644 --- a/cli/azd/pkg/update/config_test.go +++ b/cli/azd/pkg/update/config_test.go @@ -152,9 +152,9 @@ func TestUpdateConfigDefaultCheckInterval(t *testing.T) { func TestSaveAndLoadConfig(t *testing.T) { cfg := config.NewConfig(map[string]any{}) - require.NoError(t, SaveChannel(cfg, ChannelDaily)) - require.NoError(t, SaveAutoUpdate(cfg, true)) - require.NoError(t, SaveCheckIntervalHours(cfg, 6)) + require.NoError(t, SetChannel(cfg, ChannelDaily)) + require.NoError(t, SetAutoUpdate(cfg, true)) + require.NoError(t, SetCheckIntervalHours(cfg, 6)) loaded := LoadUpdateConfig(cfg) require.Equal(t, ChannelDaily, loaded.Channel) @@ -254,10 +254,10 @@ func TestFirstUsePersistenceLogic(t *testing.T) { // Simulate first-use: persist default channel defaultChannel := LoadUpdateConfig(cfg).Channel - require.NoError(t, SaveChannel(cfg, defaultChannel)) + require.NoError(t, SetChannel(cfg, defaultChannel)) // After persisting, HasUpdateConfig should return true - require.True(t, HasUpdateConfig(cfg), "config should have update config after SaveChannel") + require.True(t, HasUpdateConfig(cfg), "config should have update config after SetChannel") // Subsequent runs should skip the notice require.True(t, HasUpdateConfig(cfg)) diff --git a/cli/azd/pkg/update/msi_windows.go b/cli/azd/pkg/update/msi_windows.go index 87004d0983c..672126a3f6d 100644 --- a/cli/azd/pkg/update/msi_windows.go +++ b/cli/azd/pkg/update/msi_windows.go @@ -171,8 +171,16 @@ func buildInstallScriptArgs(channel Channel) []string { scriptArgs = " -Version 'stable'" } + // Reset PSModulePath to the Windows PowerShell 5.1 system modules directory. + // When launched via cmd.exe from a PowerShell 7 parent (e.g. VSCode or Windows Terminal), + // PS5.1 inherits PS7's PSModulePath which includes Core-edition modules that fail to load + // in the Desktop edition runtime, causing Get-AuthenticodeSignature to fail with + // "CouldNotAutoloadMatchingModule". + resetPSModulePath := "$env:PSModulePath = Join-Path $PSHOME 'Modules'; " + script := fmt.Sprintf( - "$tmpScript = Join-Path $env:TEMP 'azd-install.ps1'; "+ + resetPSModulePath+ + "$tmpScript = Join-Path $env:TEMP 'azd-install.ps1'; "+ "Invoke-RestMethod '%s' -OutFile $tmpScript; "+ "& $tmpScript%s; "+ "Remove-Item $tmpScript -Force -ErrorAction SilentlyContinue", diff --git a/cli/azd/pkg/update/msi_windows_test.go b/cli/azd/pkg/update/msi_windows_test.go index 85268e5826d..9955fb80a0b 100644 --- a/cli/azd/pkg/update/msi_windows_test.go +++ b/cli/azd/pkg/update/msi_windows_test.go @@ -70,6 +70,7 @@ func TestBuildInstallScriptArgs(t *testing.T) { installScriptURL, "-Version 'stable'", "Remove-Item", + "$env:PSModulePath", }, wantNotContains: []string{ "-InstallFolder", @@ -88,6 +89,7 @@ func TestBuildInstallScriptArgs(t *testing.T) { "-InstallFolder", expectedDir, "Remove-Item", + "$env:PSModulePath", }, wantNotContains: []string{ "-SkipVerify", @@ -157,6 +159,7 @@ func TestBuildInstallScriptArgs_Structure(t *testing.T) { // Stable downloads to temp file — passes -Version 'stable' explicitly script := args[4] + require.True(t, strings.HasPrefix(script, "$env:PSModulePath"), "script should start with PSModulePath reset") require.Contains(t, script, "Invoke-RestMethod") require.Contains(t, script, installScriptURL) require.Contains(t, script, "Remove-Item") @@ -168,6 +171,7 @@ func TestBuildInstallScriptArgs_Structure(t *testing.T) { require.Equal(t, 5, len(argsDaily)) require.Equal(t, "Bypass", argsDaily[2]) scriptDaily := argsDaily[4] + require.True(t, strings.HasPrefix(scriptDaily, "$env:PSModulePath"), "daily script should start with PSModulePath reset") require.Contains(t, scriptDaily, "Invoke-RestMethod") require.Contains(t, scriptDaily, installScriptURL) require.Contains(t, scriptDaily, "-Version 'daily'")