From 5f24d48dcba95bde682f95f1f641620eb7e1a423 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Tue, 14 Jul 2026 01:37:39 -0700 Subject: [PATCH] fix(update): replace active binary during self-update --- internal/cli/update.go | 45 +++++++++++++++-- internal/cli/update_test.go | 98 +++++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 3 deletions(-) diff --git a/internal/cli/update.go b/internal/cli/update.go index 921aaef..e32ccc3 100644 --- a/internal/cli/update.go +++ b/internal/cli/update.go @@ -1,10 +1,13 @@ package cli import ( + "encoding/json" "fmt" "os" "os/exec" + "path/filepath" "runtime" + "strings" "github.com/spf13/cobra" @@ -41,7 +44,11 @@ func newUpdateCmd() *cobra.Command { } _, _ = fmt.Fprintf(w, "\nUpdating...\n") - return runInstaller(cmd) + executable, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to locate active executable: %w", err) + } + return runInstaller(cmd, executable, result.LatestVersion) }, } @@ -49,20 +56,52 @@ func newUpdateCmd() *cobra.Command { return cmd } -func runInstaller(cmd *cobra.Command) error { +func runInstaller(cmd *cobra.Command, executable, expectedVersion string) error { + isWindows := runtime.GOOS == "windows" name, args := installerCommandSpec(runtime.GOOS, update.InstallShellURL(), update.InstallPowerShellURL()) c := exec.Command(name, args...) c.Stdout = cmd.OutOrStdout() c.Stderr = cmd.ErrOrStderr() c.Stdin = os.Stdin + installedName := "flashduty" c.Env = update.InstallerEnv(os.Environ()) + if !isWindows { + installedName = filepath.Base(executable) + env := c.Env[:0] + for _, item := range c.Env { + if strings.HasPrefix(item, "FLASHDUTY_INSTALL_DIR=") || strings.HasPrefix(item, "INSTALLED_NAME=") { + continue + } + env = append(env, item) + } + c.Env = append(env, + "FLASHDUTY_INSTALL_DIR="+filepath.Dir(executable), + "INSTALLED_NAME="+installedName, + ) + } if err := c.Run(); err != nil { return fmt.Errorf("update failed: %w", err) } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nUpdate complete. Run 'flashduty version' to verify.\n") + if !isWindows { + out, err := exec.Command(executable, "version", "--json").Output() + if err != nil { + return fmt.Errorf("update installed but active executable %s could not be verified: %w", executable, err) + } + var info struct { + Version string `json:"version"` + } + if err := json.Unmarshal(out, &info); err != nil { + return fmt.Errorf("update installed but active executable %s returned unparseable version output: %w", executable, err) + } + if update.StripV(info.Version) != update.StripV(expectedVersion) { + return fmt.Errorf("update installed but active executable %s is still stale: expected %s, got %s", executable, expectedVersion, info.Version) + } + } + + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nUpdate complete. Run '%s version' to verify.\n", installedName) return nil } diff --git a/internal/cli/update_test.go b/internal/cli/update_test.go index acf05e6..807f8f3 100644 --- a/internal/cli/update_test.go +++ b/internal/cli/update_test.go @@ -1,11 +1,109 @@ package cli import ( + "bytes" + "os" + "path/filepath" + "runtime" "slices" "strings" "testing" + + "github.com/spf13/cobra" ) +func writeExecutable(t *testing.T, path, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o755); err != nil { + t.Fatal(err) + } +} + +func TestRunInstallerUpdatesInvokedBinary(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell installer test") + } + + dir := t.TempDir() + executable := filepath.Join(dir, ".flashduty", "bin", "fduty") + writeExecutable(t, executable, `#!/bin/sh +if [ "$1" = "version" ] && [ "$2" = "--json" ]; then + echo '{"version":"1.3.6"}' +else + echo 'flashduty version 1.3.6 (old) built old' +fi +`) + + fakeBin := filepath.Join(dir, "fake-bin") + writeExecutable(t, filepath.Join(fakeBin, "sh"), `#!/bin/sh +cat > "$FLASHDUTY_INSTALL_DIR/$INSTALLED_NAME" <<'EOF' +#!/bin/sh +if [ "$1" = "version" ] && [ "$2" = "--json" ]; then + echo '{"version":"1.3.24"}' +else + echo 'flashduty version 1.3.24 (new) built now' +fi +EOF +chmod +x "$FLASHDUTY_INSTALL_DIR/$INSTALLED_NAME" +`) + t.Setenv("PATH", fakeBin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("FLASHDUTY_INSTALL_DIR", filepath.Join(dir, "wrong-dir")) + t.Setenv("INSTALLED_NAME", "wrong-name") + + var stdout, stderr bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + if err := runInstaller(cmd, executable, "v1.3.24"); err != nil { + t.Fatalf("runInstaller: %v; stderr=%s", err, stderr.String()) + } + + out, err := os.ReadFile(executable) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(out), "version 1.3.24") { + t.Fatalf("active executable was not replaced: %s", out) + } + if !strings.Contains(stdout.String(), "Update complete") { + t.Fatalf("stdout = %q, want update success", stdout.String()) + } +} + +func TestRunInstallerRejectsShadowedInstall(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell installer test") + } + + dir := t.TempDir() + executable := filepath.Join(dir, ".flashduty", "bin", "fduty") + writeExecutable(t, executable, `#!/bin/sh +if [ "$1" = "version" ] && [ "$2" = "--json" ]; then + echo '{"version":"1.3.6"}' +else + echo 'flashduty version 1.3.6 (old) built old' +fi +`) + + fakeBin := filepath.Join(dir, "fake-bin") + writeExecutable(t, filepath.Join(fakeBin, "sh"), "#!/bin/sh\nexit 0\n") + t.Setenv("PATH", fakeBin+string(os.PathListSeparator)+os.Getenv("PATH")) + + var stdout bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&stdout) + err := runInstaller(cmd, executable, "v1.3.24") + if err == nil || !strings.Contains(err.Error(), executable) || !strings.Contains(err.Error(), "1.3.6") { + t.Fatalf("runInstaller error = %v, want active path and stale version", err) + } + if strings.Contains(stdout.String(), "Update complete") { + t.Fatalf("reported success for stale executable: %q", stdout.String()) + } +} + func TestInstallerCommandSpecPassesInstallerURLAsArgument(t *testing.T) { shellURL := `https://mirror.example.com/fduty/install.sh; echo injected` psURL := `https://mirror.example.com/fduty/install.ps1; Write-Host injected`