Skip to content

Commit 5f24d48

Browse files
committed
fix(update): replace active binary during self-update
1 parent 45677ff commit 5f24d48

2 files changed

Lines changed: 140 additions & 3 deletions

File tree

‎internal/cli/update.go‎

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
package cli
22

33
import (
4+
"encoding/json"
45
"fmt"
56
"os"
67
"os/exec"
8+
"path/filepath"
79
"runtime"
10+
"strings"
811

912
"github.com/spf13/cobra"
1013

@@ -41,28 +44,64 @@ func newUpdateCmd() *cobra.Command {
4144
}
4245

4346
_, _ = fmt.Fprintf(w, "\nUpdating...\n")
44-
return runInstaller(cmd)
47+
executable, err := os.Executable()
48+
if err != nil {
49+
return fmt.Errorf("failed to locate active executable: %w", err)
50+
}
51+
return runInstaller(cmd, executable, result.LatestVersion)
4552
},
4653
}
4754

4855
cmd.Flags().BoolVar(&flagCheck, "check", false, "Only check for updates, do not install")
4956
return cmd
5057
}
5158

52-
func runInstaller(cmd *cobra.Command) error {
59+
func runInstaller(cmd *cobra.Command, executable, expectedVersion string) error {
60+
isWindows := runtime.GOOS == "windows"
5361
name, args := installerCommandSpec(runtime.GOOS, update.InstallShellURL(), update.InstallPowerShellURL())
5462
c := exec.Command(name, args...)
5563

5664
c.Stdout = cmd.OutOrStdout()
5765
c.Stderr = cmd.ErrOrStderr()
5866
c.Stdin = os.Stdin
67+
installedName := "flashduty"
5968
c.Env = update.InstallerEnv(os.Environ())
69+
if !isWindows {
70+
installedName = filepath.Base(executable)
71+
env := c.Env[:0]
72+
for _, item := range c.Env {
73+
if strings.HasPrefix(item, "FLASHDUTY_INSTALL_DIR=") || strings.HasPrefix(item, "INSTALLED_NAME=") {
74+
continue
75+
}
76+
env = append(env, item)
77+
}
78+
c.Env = append(env,
79+
"FLASHDUTY_INSTALL_DIR="+filepath.Dir(executable),
80+
"INSTALLED_NAME="+installedName,
81+
)
82+
}
6083

6184
if err := c.Run(); err != nil {
6285
return fmt.Errorf("update failed: %w", err)
6386
}
6487

65-
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nUpdate complete. Run 'flashduty version' to verify.\n")
88+
if !isWindows {
89+
out, err := exec.Command(executable, "version", "--json").Output()
90+
if err != nil {
91+
return fmt.Errorf("update installed but active executable %s could not be verified: %w", executable, err)
92+
}
93+
var info struct {
94+
Version string `json:"version"`
95+
}
96+
if err := json.Unmarshal(out, &info); err != nil {
97+
return fmt.Errorf("update installed but active executable %s returned unparseable version output: %w", executable, err)
98+
}
99+
if update.StripV(info.Version) != update.StripV(expectedVersion) {
100+
return fmt.Errorf("update installed but active executable %s is still stale: expected %s, got %s", executable, expectedVersion, info.Version)
101+
}
102+
}
103+
104+
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nUpdate complete. Run '%s version' to verify.\n", installedName)
66105
return nil
67106
}
68107

‎internal/cli/update_test.go‎

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,109 @@
11
package cli
22

33
import (
4+
"bytes"
5+
"os"
6+
"path/filepath"
7+
"runtime"
48
"slices"
59
"strings"
610
"testing"
11+
12+
"github.com/spf13/cobra"
713
)
814

15+
func writeExecutable(t *testing.T, path, body string) {
16+
t.Helper()
17+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
18+
t.Fatal(err)
19+
}
20+
if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
21+
t.Fatal(err)
22+
}
23+
}
24+
25+
func TestRunInstallerUpdatesInvokedBinary(t *testing.T) {
26+
if runtime.GOOS == "windows" {
27+
t.Skip("shell installer test")
28+
}
29+
30+
dir := t.TempDir()
31+
executable := filepath.Join(dir, ".flashduty", "bin", "fduty")
32+
writeExecutable(t, executable, `#!/bin/sh
33+
if [ "$1" = "version" ] && [ "$2" = "--json" ]; then
34+
echo '{"version":"1.3.6"}'
35+
else
36+
echo 'flashduty version 1.3.6 (old) built old'
37+
fi
38+
`)
39+
40+
fakeBin := filepath.Join(dir, "fake-bin")
41+
writeExecutable(t, filepath.Join(fakeBin, "sh"), `#!/bin/sh
42+
cat > "$FLASHDUTY_INSTALL_DIR/$INSTALLED_NAME" <<'EOF'
43+
#!/bin/sh
44+
if [ "$1" = "version" ] && [ "$2" = "--json" ]; then
45+
echo '{"version":"1.3.24"}'
46+
else
47+
echo 'flashduty version 1.3.24 (new) built now'
48+
fi
49+
EOF
50+
chmod +x "$FLASHDUTY_INSTALL_DIR/$INSTALLED_NAME"
51+
`)
52+
t.Setenv("PATH", fakeBin+string(os.PathListSeparator)+os.Getenv("PATH"))
53+
t.Setenv("FLASHDUTY_INSTALL_DIR", filepath.Join(dir, "wrong-dir"))
54+
t.Setenv("INSTALLED_NAME", "wrong-name")
55+
56+
var stdout, stderr bytes.Buffer
57+
cmd := &cobra.Command{}
58+
cmd.SetOut(&stdout)
59+
cmd.SetErr(&stderr)
60+
if err := runInstaller(cmd, executable, "v1.3.24"); err != nil {
61+
t.Fatalf("runInstaller: %v; stderr=%s", err, stderr.String())
62+
}
63+
64+
out, err := os.ReadFile(executable)
65+
if err != nil {
66+
t.Fatal(err)
67+
}
68+
if !strings.Contains(string(out), "version 1.3.24") {
69+
t.Fatalf("active executable was not replaced: %s", out)
70+
}
71+
if !strings.Contains(stdout.String(), "Update complete") {
72+
t.Fatalf("stdout = %q, want update success", stdout.String())
73+
}
74+
}
75+
76+
func TestRunInstallerRejectsShadowedInstall(t *testing.T) {
77+
if runtime.GOOS == "windows" {
78+
t.Skip("shell installer test")
79+
}
80+
81+
dir := t.TempDir()
82+
executable := filepath.Join(dir, ".flashduty", "bin", "fduty")
83+
writeExecutable(t, executable, `#!/bin/sh
84+
if [ "$1" = "version" ] && [ "$2" = "--json" ]; then
85+
echo '{"version":"1.3.6"}'
86+
else
87+
echo 'flashduty version 1.3.6 (old) built old'
88+
fi
89+
`)
90+
91+
fakeBin := filepath.Join(dir, "fake-bin")
92+
writeExecutable(t, filepath.Join(fakeBin, "sh"), "#!/bin/sh\nexit 0\n")
93+
t.Setenv("PATH", fakeBin+string(os.PathListSeparator)+os.Getenv("PATH"))
94+
95+
var stdout bytes.Buffer
96+
cmd := &cobra.Command{}
97+
cmd.SetOut(&stdout)
98+
err := runInstaller(cmd, executable, "v1.3.24")
99+
if err == nil || !strings.Contains(err.Error(), executable) || !strings.Contains(err.Error(), "1.3.6") {
100+
t.Fatalf("runInstaller error = %v, want active path and stale version", err)
101+
}
102+
if strings.Contains(stdout.String(), "Update complete") {
103+
t.Fatalf("reported success for stale executable: %q", stdout.String())
104+
}
105+
}
106+
9107
func TestInstallerCommandSpecPassesInstallerURLAsArgument(t *testing.T) {
10108
shellURL := `https://mirror.example.com/fduty/install.sh; echo injected`
11109
psURL := `https://mirror.example.com/fduty/install.ps1; Write-Host injected`

0 commit comments

Comments
 (0)