Skip to content
Closed
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
45 changes: 42 additions & 3 deletions internal/cli/update.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package cli

import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"

"github.com/spf13/cobra"

Expand Down Expand Up @@ -41,28 +44,64 @@ 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)
},
}

cmd.Flags().BoolVar(&flagCheck, "check", false, "Only check for updates, do not install")
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
}

Expand Down
98 changes: 98 additions & 0 deletions internal/cli/update_test.go
Original file line number Diff line number Diff line change
@@ -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`
Expand Down
Loading