Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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...).
Comment thread
hemarina marked this conversation as resolved.
Outdated
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.
Comment thread
hemarina marked this conversation as resolved.
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:
Comment thread
hemarina marked this conversation as resolved.
return "stable"
script := fmt.Sprintf(
"Invoke-RestMethod '%s' | Invoke-Expression",
installScriptURL,
)
return []string{"-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script}
Comment thread
hemarina marked this conversation as resolved.
Outdated
}
}

// 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",
Comment thread
hemarina marked this conversation as resolved.
"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