Detailed changes
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"os"
+ "runtime"
"time"
"charm.land/lipgloss/v2"
@@ -164,7 +165,11 @@ crush update apply --force
} else {
fmt.Fprintf(os.Stderr, "Successfully updated to v%s!\n", info.Latest)
}
- fmt.Fprintf(os.Stderr, "Run 'crush -v' to verify the new version.\n")
+ if runtime.GOOS == "windows" {
+ fmt.Fprintf(os.Stderr, "Restart Crush to use the new version.\n")
+ } else {
+ fmt.Fprintf(os.Stderr, "Run 'crush -v' to verify the new version.\n")
+ }
return nil
},
@@ -30,6 +30,14 @@ const (
maxChecksumsSize = 1 * 1024 * 1024 // 1MB max for checksums.txt
)
+// binaryName returns the expected binary name for the current platform.
+func binaryName() string {
+ if runtime.GOOS == "windows" {
+ return "crush.exe"
+ }
+ return "crush"
+}
+
// userAgent returns the user agent string for HTTP requests.
func userAgent() string {
return "crush/" + version.Version
@@ -378,15 +386,12 @@ func Download(ctx context.Context, asset *Asset, release *Release) (string, erro
binaryPath, err = extractTarGz(tmpFile.Name())
}
if err != nil {
+ if binaryPath != "" {
+ os.Remove(binaryPath)
+ }
return "", fmt.Errorf("failed to extract: %w", err)
}
- // Validate the extracted binary before returning.
- if err := validateBinary(binaryPath); err != nil {
- os.Remove(binaryPath)
- return "", fmt.Errorf("invalid binary: %w", err)
- }
-
return binaryPath, nil
}
@@ -462,7 +467,7 @@ func extractZip(archivePath string) (string, error) {
}
// Use exact name matching to avoid matching unintended files.
- if filepath.Base(f.Name) == binaryName {
+ if filepath.Base(f.Name) == binaryName() {
binaryPath, err := extractZipFile(f)
if err != nil {
return "", err
@@ -546,7 +551,7 @@ func extractTarGz(archivePath string) (string, error) {
}
// Use exact name matching to avoid matching unintended files.
- if filepath.Base(header.Name) == binaryName {
+ if filepath.Base(header.Name) == binaryName() {
binaryPath, err := extractTarFile(tr)
if err != nil {
return "", err
@@ -592,56 +597,6 @@ func extractTarFile(tr *tar.Reader) (string, error) {
return tmpBinary.Name(), nil
}
-// Apply replaces the current executable with the downloaded binary.
-func Apply(binaryPath string) error {
- // Get path to current executable.
- exe, err := os.Executable()
- if err != nil {
- return fmt.Errorf("failed to get executable path: %w", err)
- }
-
- // Resolve symlinks.
- exe, err = filepath.EvalSymlinks(exe)
- if err != nil {
- return fmt.Errorf("failed to resolve symlinks: %w", err)
- }
-
- // Get the directory of the executable.
- exeDir := filepath.Dir(exe)
-
- // Check if we have write permissions to the directory.
- if err := checkWritePermission(exeDir); err != nil {
- return fmt.Errorf("cannot write to %s: %w (you may need to run with elevated privileges)", exeDir, err)
- }
-
- // Copy binary to exe directory first to ensure same filesystem.
- // os.Rename fails across filesystems, and binaryPath may be in /tmp.
- localBinary := filepath.Join(exeDir, ".crush-update-new")
- if err := copyFile(binaryPath, localBinary); err != nil {
- return fmt.Errorf("failed to copy new binary: %w", err)
- }
-
- // Create a backup of the current executable.
- backupPath := filepath.Join(exeDir, filepath.Base(exe)+".old")
- if err := os.Rename(exe, backupPath); err != nil {
- os.Remove(localBinary)
- return fmt.Errorf("failed to backup current executable: %w", err)
- }
-
- // Move new binary to executable location.
- if err := os.Rename(localBinary, exe); err != nil {
- // Try to restore backup on failure.
- _ = os.Rename(backupPath, exe)
- os.Remove(localBinary)
- return fmt.Errorf("failed to install new version: %w", err)
- }
-
- // Remove backup on success.
- _ = os.Remove(backupPath)
-
- return nil
-}
-
// checkWritePermission checks if we can write to the given directory.
func checkWritePermission(dir string) error {
testFile := filepath.Join(dir, ".crush-update-test")
@@ -0,0 +1,69 @@
+//go:build !windows
+
+package update
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+)
+
+// Apply replaces the current executable with the downloaded binary.
+func Apply(binaryPath string) error {
+ // Get path to current executable.
+ exe, err := os.Executable()
+ if err != nil {
+ return fmt.Errorf("failed to get executable path: %w", err)
+ }
+
+ // Resolve symlinks.
+ exe, err = filepath.EvalSymlinks(exe)
+ if err != nil {
+ return fmt.Errorf("failed to resolve symlinks: %w", err)
+ }
+
+ // Get the directory of the executable.
+ exeDir := filepath.Dir(exe)
+
+ // Check if we have write permissions to the directory.
+ if err := checkWritePermission(exeDir); err != nil {
+ return fmt.Errorf("cannot write to %s: %w (you may need to run with elevated privileges)", exeDir, err)
+ }
+
+ // Copy binary to exe directory first to ensure same filesystem.
+ // os.Rename fails across filesystems, and binaryPath may be in /tmp.
+ localBinary := filepath.Join(exeDir, ".crush-update-new")
+ if err := copyFile(binaryPath, localBinary); err != nil {
+ return fmt.Errorf("failed to copy new binary: %w", err)
+ }
+
+ // Create a backup of the current executable.
+ backupPath := filepath.Join(exeDir, filepath.Base(exe)+".old")
+ if err := os.Rename(exe, backupPath); err != nil {
+ os.Remove(localBinary)
+ return fmt.Errorf("failed to backup current executable: %w", err)
+ }
+
+ // Move new binary to executable location.
+ if err := os.Rename(localBinary, exe); err != nil {
+ // Try to restore backup on failure.
+ _ = os.Rename(backupPath, exe)
+ os.Remove(localBinary)
+ return fmt.Errorf("failed to install new version: %w", err)
+ }
+
+ // Remove backup on success.
+ _ = os.Remove(backupPath)
+
+ return nil
+}
+
+// HasPendingUpdate returns false on Unix since updates are applied immediately.
+func HasPendingUpdate() bool {
+ return false
+}
+
+// ApplyPendingUpdate is a no-op on Unix since updates are applied immediately.
+func ApplyPendingUpdate() error {
+ return nil
+}
@@ -3,28 +3,11 @@
package update
import (
- "debug/macho"
- "fmt"
"os"
"path/filepath"
"strings"
)
-const binaryName = "crush"
-
-// validateBinary checks that the file at path is a valid Mach-O executable.
-func validateBinary(path string) error {
- f, err := macho.Open(path)
- if err != nil {
- return fmt.Errorf("not a valid Mach-O binary: %w", err)
- }
- defer f.Close()
- if f.Type != macho.TypeExec {
- return fmt.Errorf("Mach-O file is not an executable (type: %v)", f.Type)
- }
- return nil
-}
-
// detectInstallMethod determines how Crush was installed on macOS.
func detectInstallMethod(exePath string) InstallMethod {
// Check for Homebrew installation.
@@ -3,28 +3,11 @@
package update
import (
- "debug/elf"
- "fmt"
"os"
"path/filepath"
"strings"
)
-const binaryName = "crush"
-
-// validateBinary checks that the file at path is a valid ELF executable.
-func validateBinary(path string) error {
- f, err := elf.Open(path)
- if err != nil {
- return fmt.Errorf("not a valid ELF binary: %w", err)
- }
- defer f.Close()
- if f.Type != elf.ET_EXEC && f.Type != elf.ET_DYN {
- return fmt.Errorf("ELF file is not an executable (type: %v)", f.Type)
- }
- return nil
-}
-
// detectInstallMethod determines how Crush was installed on Linux/BSD.
func detectInstallMethod(exePath string) InstallMethod {
// Check for Nix installation first (works on any platform with Nix).
@@ -72,8 +55,7 @@ func detectInstallMethod(exePath string) InstallMethod {
// Check for Homebrew on Linux.
if strings.Contains(exePath, "/Cellar/") ||
- strings.HasPrefix(exePath, "/home/linuxbrew/") ||
- strings.Contains(exePath, "/.linuxbrew/") {
+ strings.Contains(exePath, "linuxbrew") {
return InstallMethodHomebrew
}
@@ -3,26 +3,11 @@
package update
import (
- "debug/pe"
- "fmt"
"os"
"path/filepath"
"strings"
)
-const binaryName = "crush.exe"
-
-// validateBinary checks that the file at path is a valid PE executable.
-func validateBinary(path string) error {
- f, err := pe.Open(path)
- if err != nil {
- return fmt.Errorf("not a valid PE binary: %w", err)
- }
- defer f.Close()
- // PE files opened successfully are valid executables.
- return nil
-}
-
// detectInstallMethod determines how Crush was installed on Windows.
func detectInstallMethod(exePath string) InstallMethod {
// Normalize path separators for comparison.
@@ -81,3 +66,73 @@ func detectInstallMethod(exePath string) InstallMethod {
return InstallMethodUnknown
}
+
+// Apply stages the new binary for installation on next startup.
+// On Windows, we cannot replace a running executable, so we copy the new
+// binary to crush.exe.new and apply it on the next startup.
+func Apply(binaryPath string) error {
+ pendingPath, err := pendingUpdatePath()
+ if err != nil {
+ return err
+ }
+
+ // Copy the new binary to the pending location.
+ if err := copyFile(binaryPath, pendingPath); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// pendingUpdatePath returns the path where a pending update binary is stored.
+func pendingUpdatePath() (string, error) {
+ exe, err := os.Executable()
+ if err != nil {
+ return "", err
+ }
+ return exe + ".new", nil
+}
+
+// HasPendingUpdate checks if there's a pending update waiting to be applied.
+func HasPendingUpdate() bool {
+ path, err := pendingUpdatePath()
+ if err != nil {
+ return false
+ }
+ _, err = os.Stat(path)
+ return err == nil
+}
+
+// ApplyPendingUpdate applies a pending update that was staged previously.
+// This should be called early in startup, before the main executable is locked.
+func ApplyPendingUpdate() error {
+ pendingPath, err := pendingUpdatePath()
+ if err != nil {
+ return err
+ }
+
+ if _, err := os.Stat(pendingPath); os.IsNotExist(err) {
+ return nil // No pending update.
+ }
+
+ exe, err := os.Executable()
+ if err != nil {
+ return err
+ }
+
+ // Rename current to .old, new to current.
+ oldPath := exe + ".old"
+ if err := os.Rename(exe, oldPath); err != nil {
+ return err
+ }
+
+ if err := os.Rename(pendingPath, exe); err != nil {
+ // Try to restore.
+ _ = os.Rename(oldPath, exe)
+ return err
+ }
+
+ // Clean up old binary.
+ _ = os.Remove(oldPath)
+ return nil
+}
@@ -7,10 +7,16 @@ import (
"os"
"github.com/charmbracelet/crush/internal/cmd"
+ "github.com/charmbracelet/crush/internal/update"
_ "github.com/joho/godotenv/autoload"
)
func main() {
+ // Apply any pending update from previous run (Windows-specific).
+ if err := update.ApplyPendingUpdate(); err != nil {
+ slog.Warn("Failed to apply pending update", "error", err)
+ }
+
if os.Getenv("CRUSH_PROFILE") != "" {
go func() {
slog.Info("Serving pprof at localhost:6060")