@@ -315,43 +315,81 @@ func (m *Manager) updateViaPackageManager(
315315}
316316
317317func (m * Manager ) updateViaMSI (ctx context.Context , cfg * UpdateConfig , writer io.Writer ) error {
318- msiURL , err := m .buildMSIDownloadURL (cfg .Channel )
319- if err != nil {
318+ // Verify the install is the standard per-user MSI configuration.
319+ // install-azd.ps1 installs with ALLUSERS=2 to %LOCALAPPDATA%\Programs\Azure Dev CLI.
320+ // If the current install is non-standard, abort and advise the user.
321+ if err := isStandardMSIInstall (); err != nil {
320322 return err
321323 }
322324
323- fmt .Fprintf (writer , "Downloading MSI from %s...\n " , msiURL )
325+ // 1. Rename the running exe to temp (frees the path; process continues via the OS handle)
326+ // 2. Copy it back as an unlocked safety net (if killed at any point, azd.exe still exists)
327+ // 3. The MSI will overwrite the unlocked safety copy with the new version
328+ fmt .Fprintf (writer , "Backing up current azd executable...\n " )
329+ originalPath , backupPath , err := backupCurrentExe ()
330+ if err != nil {
331+ return newUpdateError (CodeReplaceFailed , fmt .Errorf ("failed to backup current executable: %w" , err ))
332+ }
324333
325- tempDir := os .TempDir ()
326- msiPath := filepath .Join (tempDir , "azd-windows-amd64.msi" )
334+ // Track whether the install succeeded so we know whether to restore or clean up.
335+ updateSucceeded := false
336+ defer func () {
337+ if updateSucceeded {
338+ // Remove the temp backup directory. If this fails, the OS
339+ // will clean it up eventually since it lives under %TEMP%.
340+ _ = os .RemoveAll (filepath .Dir (backupPath ))
341+ return
342+ }
343+ // Update failed — restore the backup so the user has the original binary.
344+ fmt .Fprintf (writer , "Restoring previous version...\n " )
345+ if restoreErr := restoreExeFromBackup (originalPath , backupPath ); restoreErr != nil {
346+ fmt .Fprintf (writer , "WARNING: failed to restore previous version: %v\n " , restoreErr )
347+ fmt .Fprintf (writer , "Your backup is at: %s\n " , backupPath )
348+ fmt .Fprintf (writer , "To recover manually, copy it to: %s\n " , originalPath )
349+ }
350+ }()
327351
328- if err := m .downloadFile (ctx , msiURL , msiPath , writer ); err != nil {
329- return newUpdateError (CodeDownloadFailed , err )
352+ // Run the install script synchronously. The MSI overwrites the unlocked
353+ // safety copy at the original path with the new version.
354+ psArgs := buildInstallScriptArgs (cfg .Channel )
355+
356+ // Snapshot the safety copy's mod time before the install so we can detect
357+ // whether the MSI actually replaced the file. A plain os.Stat after install
358+ // would always succeed because the safety copy already exists at originalPath.
359+ preInfo , statErr := os .Stat (originalPath )
360+ if statErr != nil {
361+ return newUpdateError (CodeReplaceFailed ,
362+ fmt .Errorf ("failed to stat safety copy before install: %w" , statErr ))
330363 }
331- // Don't defer os.Remove — the detached msiexec process needs this file after we exit.
332364
333- // Build msiexec args. Always write a verbose log so failures are diagnosable.
334- msiLogPath , logErr := msiLogFilePath ()
335- args := []string {"/i" , msiPath , "/qn" }
336- if logErr == nil {
337- args = append (args , "/l*v" , msiLogPath )
338- log .Printf ("MSI install log: %s" , msiLogPath )
365+ log .Printf ("Running install script: powershell %s" , strings .Join (psArgs , " " ))
366+ fmt .Fprintf (writer , "Installing azd %s channel...\n " , cfg .Channel )
367+
368+ runArgs := exec .NewRunArgs ("powershell" , psArgs ... ).
369+ WithStdOut (writer ).
370+ WithStdErr (writer )
371+
372+ if _ , err := m .commandRunner .Run (ctx , runArgs ); err != nil {
373+ return newUpdateError (CodeReplaceFailed , fmt .Errorf ("install script failed: %w" , err ))
339374 }
340375
341- log .Printf ("Spawning detached msiexec: msiexec %s" , strings .Join (args , " " ))
342- fmt .Fprintf (writer , "Installing update via MSI...\n " )
376+ // Verify the MSI actually replaced the binary by comparing mod time and
377+ // size against the pre-install safety copy. If both are identical the MSI
378+ // did not write a new file (silent failure).
379+ postInfo , statErr := os .Stat (originalPath )
380+ if statErr != nil {
381+ return newUpdateError (CodeReplaceFailed ,
382+ fmt .Errorf ("install script completed but %s was not found" , originalPath ))
383+ }
343384
344- // Spawn msiexec detached so it can replace the running azd binary.
345- // msiexec cannot overwrite a locked executable; by detaching, azd can exit
346- // and release the file lock before msiexec attempts the replacement.
347- //nolint:gosec // args are constructed from controlled constants, not user input
348- cmd := osexec .Command ("msiexec" , args ... )
349- cmd .SysProcAttr = newDetachedSysProcAttr ()
350- if err := cmd .Start (); err != nil {
351- return newUpdateError (CodeReplaceFailed , fmt .Errorf ("failed to start msiexec: %w" , err ))
385+ if postInfo .ModTime ().Equal (preInfo .ModTime ()) && postInfo .Size () == preInfo .Size () {
386+ return newUpdateError (CodeReplaceFailed ,
387+ fmt .Errorf ("install script completed but the binary at %s was not updated " +
388+ "(file unchanged); the MSI may have failed silently" , originalPath ))
352389 }
353390
354- log .Printf ("msiexec started with PID %d, azd will exit to release binary lock" , cmd .Process .Pid )
391+ updateSucceeded = true
392+ log .Printf ("Update completed successfully" )
355393 return nil
356394}
357395
@@ -430,20 +468,6 @@ func (m *Manager) buildDownloadURL(channel Channel) (string, error) {
430468 return fmt .Sprintf ("%s/%s/azd-%s-%s%s" , blobBaseURL , folder , platform , arch , ext ), nil
431469}
432470
433- func (m * Manager ) buildMSIDownloadURL (channel Channel ) (string , error ) {
434- var folder string
435- switch channel {
436- case ChannelStable :
437- folder = "stable"
438- case ChannelDaily :
439- folder = "daily"
440- default :
441- return "" , fmt .Errorf ("unsupported channel: %s" , channel )
442- }
443-
444- return fmt .Sprintf ("%s/%s/azd-windows-%s.msi" , blobBaseURL , folder , runtime .GOARCH ), nil
445- }
446-
447471func archiveExtension () string {
448472 if runtime .GOOS == "linux" {
449473 return ".tar.gz"
@@ -590,13 +614,17 @@ func (m *Manager) replaceBinary(ctx context.Context, newBinaryPath, currentBinar
590614 return fmt .Errorf ("failed to replace binary: %w" , err )
591615}
592616
593- // currentExePath returns the resolved path of the currently running azd binary .
617+ // currentExePath returns the resolved path of the currently running executable .
594618func currentExePath () (string , error ) {
595619 exePath , err := os .Executable ()
596620 if err != nil {
597- return "" , err
621+ return "" , fmt .Errorf ("failed to determine current executable path: %w" , err )
622+ }
623+ resolved , err := filepath .EvalSymlinks (exePath )
624+ if err != nil {
625+ return "" , fmt .Errorf ("failed to resolve executable path: %w" , err )
598626 }
599- return filepath . EvalSymlinks ( exePath )
627+ return resolved , nil
600628}
601629
602630func copyFile (src , dst string ) error {
0 commit comments