Skip to content
Open
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
4 changes: 2 additions & 2 deletions cli/azd/pkg/update/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -507,10 +507,10 @@ func (m *Manager) updateViaMSI(ctx context.Context, cfg *UpdateConfig, writer io
fmt.Errorf("failed to hash safety copy before install: %w", hashErr))
}

log.Printf("Running install script: powershell %s", strings.Join(psArgs, " "))
log.Printf("Running install script: pwsh %s", strings.Join(psArgs, " "))
fmt.Fprintf(writer, "Installing azd %s channel...\n", cfg.Channel)

runArgs := exec.NewRunArgs("powershell", psArgs...).
runArgs := exec.NewRunArgs("pwsh", psArgs...).
WithStdOut(writer).
WithStdErr(writer)

Expand Down
51 changes: 24 additions & 27 deletions cli/azd/pkg/update/msi_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,35 +150,32 @@ func isStandardMSIInstall() error {
return nil
}

// versionFlag returns the install script parameter value for the given channel.
func versionFlag(channel Channel) string {
// buildInstallScriptArgs constructs the PowerShell arguments to run install-azd.ps1.
//
// For the stable channel the script is piped directly through Invoke-Expression.
// No named parameters are needed because the default MSI install location is correct.
//
// For other channels (e.g. daily) the script must be downloaded to a temp directory
// first, because Invoke-Expression from a pipe does not support passing named
// parameters such as -Version or -InstallFolder to the script.
//
// Returns the arguments to pass to the "powershell" command.
func buildInstallScriptArgs(channel Channel) []string {
switch channel {
case ChannelDaily:
return "daily"
case ChannelStable:
return "stable"
script := fmt.Sprintf(
"$tmpScript = Join-Path $env:TEMP 'azd-install.ps1'; "+
"Invoke-RestMethod '%s' -OutFile $tmpScript; "+
"& $tmpScript -Version 'daily' -InstallFolder '%s'; "+
"Remove-Item $tmpScript -Force -ErrorAction SilentlyContinue",
installScriptURL, expectedPerUserInstallDir(),
)
return []string{"-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script}
default:
return "stable"
script := fmt.Sprintf(
"Invoke-RestMethod '%s' | Invoke-Expression",
installScriptURL,
)
return []string{"-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script}
Comment on lines 174 to +179
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the stable channel, this executes a remote script via Invoke-RestMethod ... | Invoke-Expression. The repository’s own install-azd.ps1 docs note that piping into Invoke-Expression prevents Authenticode validation of the script’s signature, and recommend downloading to a file to validate. Consider using the same “download to temp file then execute” flow for stable (even with no parameters) so the script signature can be validated before running.

Copilot uses AI. Check for mistakes.
}
}

// buildInstallScriptArgs constructs the PowerShell arguments to download and run
// install-azd.ps1 with the appropriate -Version flag.
// The -SkipVerify flag is passed because Authenticode verification via
// Get-AuthenticodeSignature failed.
// The MSI is already downloaded over HTTPS from a Microsoft-controlled domain,
// so the transport-level integrity is sufficient.
// Returns the arguments to pass to the "powershell" command.
func buildInstallScriptArgs(channel Channel) []string {
version := versionFlag(channel)
// Download the script to a temp file, then invoke it with the appropriate -Version flag.
// Using -ExecutionPolicy Bypass ensures the script runs even if the system policy is restrictive.
script := fmt.Sprintf(
`$script = Join-Path $env:TEMP 'install-azd.ps1'; `+
`Invoke-RestMethod '%s' -OutFile $script; `+
`& $script -Version '%s' -SkipVerify; `+
`Remove-Item $script -Force -ErrorAction SilentlyContinue`,
installScriptURL, version,
)
return []string{"-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script}
}
64 changes: 37 additions & 27 deletions cli/azd/pkg/update/msi_windows_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,31 +42,16 @@ func TestExpectedPerUserInstallDir(t *testing.T) {
}
}

func TestVersionFlag(t *testing.T) {
tests := []struct {
name string
channel Channel
want string
}{
{"stable channel", ChannelStable, "stable"},
{"daily channel", ChannelDaily, "daily"},
{"unknown defaults to stable", Channel("nightly"), "stable"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := versionFlag(tt.channel)
require.Equal(t, tt.want, got)
})
}
}

func TestBuildInstallScriptArgs(t *testing.T) {
t.Setenv("LOCALAPPDATA", `C:\Users\testuser\AppData\Local`)
expectedDir := expectedPerUserInstallDir()

tests := []struct {
name string
channel Channel
// We check that certain substrings appear in the constructed args
wantContains []string
wantContains []string
wantNotContains []string
}{
{
name: "stable",
Expand All @@ -76,8 +61,12 @@ func TestBuildInstallScriptArgs(t *testing.T) {
"-ExecutionPolicy", "Bypass",
"-Command",
installScriptURL,
"-Version 'stable'",
"-SkipVerify",
"Invoke-Expression",
},
wantNotContains: []string{
"-Version",
"-InstallFolder",
"Remove-Item",
},
},
{
Expand All @@ -89,7 +78,9 @@ func TestBuildInstallScriptArgs(t *testing.T) {
"-Command",
installScriptURL,
"-Version 'daily'",
"-SkipVerify",
"-InstallFolder",
expectedDir,
"Remove-Item",
},
},
}
Expand All @@ -105,26 +96,45 @@ func TestBuildInstallScriptArgs(t *testing.T) {
for _, s := range tt.wantContains {
require.Contains(t, joined, s, "expected args to contain %q", s)
}
for _, s := range tt.wantNotContains {
require.NotContains(t, joined, s, "expected args NOT to contain %q", s)
}
})
}
}

func TestBuildInstallScriptArgs_Structure(t *testing.T) {
t.Setenv("LOCALAPPDATA", `C:\Users\testuser\AppData\Local`)
expectedDir := expectedPerUserInstallDir()

args := buildInstallScriptArgs(ChannelStable)

// The args should be: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", <script>]
require.Equal(t, 5, len(args), "expected exactly 5 args")
require.Equal(t, "-NoProfile", args[0])
require.Equal(t, "-ExecutionPolicy", args[1])
require.Equal(t, "Bypass", args[2])
require.Equal(t, "-Command", args[3])

// The script (args[4]) should be a single string containing the full PowerShell pipeline
// Stable pipes directly — no temp file download
script := args[4]
require.Contains(t, script, "Invoke-RestMethod")
require.Contains(t, script, installScriptURL)
require.Contains(t, script, "-SkipVerify")
require.Contains(t, script, "Remove-Item")
require.Contains(t, script, "Invoke-Expression")
require.NotContains(t, script, "-InstallFolder")
require.NotContains(t, script, "Remove-Item")
require.NotContains(t, script, "-Version")

// Daily downloads to temp file with -Version 'daily'
argsDaily := buildInstallScriptArgs(ChannelDaily)
require.Equal(t, 5, len(argsDaily))
require.Equal(t, "Bypass", argsDaily[2])
scriptDaily := argsDaily[4]
require.Contains(t, scriptDaily, "Invoke-RestMethod")
require.Contains(t, scriptDaily, installScriptURL)
require.Contains(t, scriptDaily, "-Version 'daily'")
require.Contains(t, scriptDaily, "-InstallFolder")
require.Contains(t, scriptDaily, expectedDir)
require.Contains(t, scriptDaily, "Remove-Item")
}

func TestIsStandardMSIInstall_StandardPath(t *testing.T) {
Expand Down
Loading