Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cli/azd/.vscode/cspell-azd-dictionary.txt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ GOCOVERDIR
Ghostty
LASTEXITCODE
MCPJSON
PSHOME
PYTHONDONTWRITEBYTECODE
PYTHONUNBUFFERED
RPCJSONRPC
Expand Down
11 changes: 6 additions & 5 deletions cli/azd/cmd/final_coverage3_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

// ===========================================================================
Expand Down
61 changes: 33 additions & 28 deletions cli/azd/cmd/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -131,29 +131,30 @@ 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
}

// 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)
Expand All @@ -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{
Expand Down Expand Up @@ -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),
Expand All @@ -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
Expand All @@ -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).
Expand Down
7 changes: 3 additions & 4 deletions cli/azd/cmd/update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions cli/azd/docs/design/azd-update.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,15 +282,15 @@ 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 <channel>`) — the update itself is not rolled back.

#### Cross Install Method

Switching between a package manager and direct installs is **not supported** via `azd update`. Users must manually uninstall and reinstall:

| 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.
Expand Down
12 changes: 6 additions & 6 deletions cli/azd/pkg/update/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,22 +128,22 @@ 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"
}
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)
}

Expand Down
10 changes: 5 additions & 5 deletions cli/azd/pkg/update/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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))
Expand Down
10 changes: 9 additions & 1 deletion cli/azd/pkg/update/msi_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions cli/azd/pkg/update/msi_windows_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ func TestBuildInstallScriptArgs(t *testing.T) {
installScriptURL,
"-Version 'stable'",
"Remove-Item",
"$env:PSModulePath",
},
wantNotContains: []string{
"-InstallFolder",
Expand All @@ -88,6 +89,7 @@ func TestBuildInstallScriptArgs(t *testing.T) {
"-InstallFolder",
expectedDir,
"Remove-Item",
"$env:PSModulePath",
},
wantNotContains: []string{
"-SkipVerify",
Expand Down Expand Up @@ -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")
Expand All @@ -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'")
Expand Down
Loading