package cmd import ( "archive/tar" "compress/gzip" "encoding/json" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "regexp" "runtime" "strconv" "strings" "github.com/fatih/color" "github.com/spf13/cobra" "snitch/internal/tui" ) const ( repoOwner = "karol-broda" repoName = "snitch" githubAPI = "https://api.github.com" firstUpgradeVersion = "0.1.8" ) var ( upgradeYes bool upgradeVersion string ) var upgradeCmd = &cobra.Command{ Use: "upgrade", Short: "Check for updates and optionally upgrade snitch", Long: `Check for available updates and show upgrade instructions. Use --yes to perform an in-place upgrade automatically. Use --version to install a specific version.`, RunE: runUpgrade, } func init() { upgradeCmd.Flags().BoolVarP(&upgradeYes, "yes", "y", false, "Perform the upgrade automatically") upgradeCmd.Flags().StringVarP(&upgradeVersion, "version", "v", "", "Install a specific version (e.g., v0.1.7)") rootCmd.AddCommand(upgradeCmd) } type githubRelease struct { TagName string `json:"tag_name"` HTMLURL string `json:"html_url"` } type githubCommit struct { SHA string `json:"sha"` } type githubCompare struct { Status string `json:"status"` AheadBy int `json:"ahead_by"` BehindBy int `json:"behind_by"` TotalCommits int `json:"total_commits"` } func runUpgrade(cmd *cobra.Command, args []string) error { current := Version nixInstall := isNixInstall() nixVersion := isNixVersion(current) if upgradeVersion != "" { if nixInstall || nixVersion { return handleNixSpecificVersion(current, upgradeVersion) } return handleSpecificVersion(current, upgradeVersion) } latest, err := fetchLatestVersion() if err != nil { return fmt.Errorf("failed to check for updates: %w", err) } if nixInstall || nixVersion { return handleNixUpgrade(current, latest) } currentClean := strings.TrimPrefix(current, "v") latestClean := strings.TrimPrefix(latest, "v") printVersionComparison(current, latest) if currentClean == latestClean { green := color.New(color.FgGreen) green.Println(tui.SymbolSuccess + " you are running the latest version") return nil } if current == "dev" { yellow := color.New(color.FgYellow) yellow.Println(tui.SymbolWarning + " you are running a development build") fmt.Println() fmt.Println("use one of the methods below to install a release version:") fmt.Println() printUpgradeInstructions() return nil } green := color.New(color.FgGreen, color.Bold) green.Printf(tui.SymbolSuccess+" update available: %s "+tui.SymbolArrowRight+" %s\n", current, latest) fmt.Println() if !upgradeYes { printUpgradeInstructions() fmt.Println() faint := color.New(color.Faint) cmdStyle := color.New(color.FgCyan) faint.Print(" in-place ") cmdStyle.Println("snitch upgrade --yes") return nil } return performUpgrade(latest) } func handleSpecificVersion(current, target string) error { if !strings.HasPrefix(target, "v") { target = "v" + target } targetClean := strings.TrimPrefix(target, "v") printVersionComparisonTarget(current, target) if isVersionLower(targetClean, firstUpgradeVersion) { yellow := color.New(color.FgYellow) yellow.Printf(tui.SymbolWarning+" warning: the upgrade command was introduced in v%s\n", firstUpgradeVersion) faint := color.New(color.Faint) faint.Printf(" version %s does not include this command\n", target) faint.Println(" you will need to use other methods to upgrade from that version") fmt.Println() } currentClean := strings.TrimPrefix(current, "v") if currentClean == targetClean { green := color.New(color.FgGreen) green.Println(tui.SymbolSuccess + " you are already running this version") return nil } if !upgradeYes { faint := color.New(color.Faint) cmdStyle := color.New(color.FgCyan) if isVersionLower(targetClean, currentClean) { yellow := color.New(color.FgYellow) yellow.Printf(tui.SymbolArrowDown+" this will downgrade from %s to %s\n", current, target) } else { green := color.New(color.FgGreen) green.Printf(tui.SymbolArrowUp+" this will upgrade from %s to %s\n", current, target) } fmt.Println() faint.Print("run ") cmdStyle.Printf("snitch upgrade --version %s --yes", target) faint.Println(" to proceed") return nil } return performUpgrade(target) } func handleNixUpgrade(current, latest string) error { faint := color.New(color.Faint) version := color.New(color.FgCyan) currentCommit := extractCommitFromVersion(current) dirty := isNixDirty(current) faint.Print("current ") version.Print(current) if currentCommit != "" { faint.Printf(" (commit %s)", currentCommit) } fmt.Println() faint.Print("latest ") version.Println(latest) fmt.Println() if dirty { yellow := color.New(color.FgYellow) yellow.Println(tui.SymbolWarning + " you are running a dirty nix build (uncommitted changes)") fmt.Println() printNixUpgradeInstructions() return nil } if currentCommit == "" { yellow := color.New(color.FgYellow) yellow.Println(tui.SymbolWarning + " this is a nix installation") faint.Println(" nix store is immutable; use nix commands to upgrade") fmt.Println() printNixUpgradeInstructions() return nil } releaseCommit, err := fetchCommitForTag(latest) if err != nil { faint.Printf(" (could not fetch release commit: %v)\n", err) fmt.Println() yellow := color.New(color.FgYellow) yellow.Println(tui.SymbolWarning + " this is a nix installation") faint.Println(" nix store is immutable; use nix commands to upgrade") fmt.Println() printNixUpgradeInstructions() return nil } releaseShort := releaseCommit if len(releaseShort) > 7 { releaseShort = releaseShort[:7] } if strings.HasPrefix(releaseCommit, currentCommit) || strings.HasPrefix(currentCommit, releaseShort) { green := color.New(color.FgGreen) green.Printf(tui.SymbolSuccess+" you are running %s (commit %s)\n", latest, releaseShort) return nil } comparison, err := compareCommits(latest, currentCommit) if err != nil { green := color.New(color.FgGreen, color.Bold) green.Printf(tui.SymbolSuccess+" update available: %s "+tui.SymbolArrowRight+" %s\n", currentCommit, latest) faint.Printf(" your commit: %s\n", currentCommit) faint.Printf(" release: %s (%s)\n", releaseShort, latest) fmt.Println() yellow := color.New(color.FgYellow) yellow.Println(tui.SymbolWarning + " this is a nix installation") faint.Println(" nix store is immutable; use nix commands to upgrade") fmt.Println() printNixUpgradeInstructions() return nil } if comparison.AheadBy > 0 { cyan := color.New(color.FgCyan) cyan.Printf(tui.SymbolArrowUp+" you are %d commit(s) ahead of %s\n", comparison.AheadBy, latest) faint.Printf(" your commit: %s\n", currentCommit) faint.Printf(" release: %s (%s)\n", releaseShort, latest) fmt.Println() faint.Println("you are running a newer build than the latest release") return nil } if comparison.BehindBy > 0 { green := color.New(color.FgGreen, color.Bold) green.Printf(tui.SymbolSuccess+" update available: %d commit(s) behind %s\n", comparison.BehindBy, latest) faint.Printf(" your commit: %s\n", currentCommit) faint.Printf(" release: %s (%s)\n", releaseShort, latest) fmt.Println() yellow := color.New(color.FgYellow) yellow.Println(tui.SymbolWarning + " this is a nix installation") faint.Println(" nix store is immutable; use nix commands to upgrade") fmt.Println() printNixUpgradeInstructions() return nil } green := color.New(color.FgGreen) green.Printf(tui.SymbolSuccess+" you are running %s (commit %s)\n", latest, releaseShort) return nil } func handleNixSpecificVersion(current, target string) error { if !strings.HasPrefix(target, "v") { target = "v" + target } printVersionComparisonTarget(current, target) yellow := color.New(color.FgYellow) yellow.Println(tui.SymbolWarning + " this is a nix installation") faint := color.New(color.Faint) faint.Println(" nix store is immutable; in-place upgrades are not supported") fmt.Println() bold := color.New(color.Bold) cmd := color.New(color.FgCyan) bold.Println("to install a specific version with nix:") fmt.Println() faint.Print(" specific ref ") cmd.Printf("nix profile install github:%s/%s/%s\n", repoOwner, repoName, target) faint.Print(" latest ") cmd.Printf("nix profile install github:%s/%s\n", repoOwner, repoName) return nil } func isVersionLower(v1, v2 string) bool { parts1 := parseVersion(v1) parts2 := parseVersion(v2) for i := 0; i < 3; i++ { if parts1[i] < parts2[i] { return true } if parts1[i] > parts2[i] { return false } } return false } func parseVersion(v string) [3]int { var parts [3]int segments := strings.Split(v, ".") for i := 0; i < len(segments) && i < 3; i++ { n, err := strconv.Atoi(segments[i]) if err == nil { parts[i] = n } } return parts } func fetchLatestVersion() (string, error) { url := fmt.Sprintf("%s/repos/%s/%s/releases/latest", githubAPI, repoOwner, repoName) resp, err := http.Get(url) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("github api returned status %d", resp.StatusCode) } var release githubRelease if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { return "", err } if release.TagName == "" { return "", fmt.Errorf("no releases found") } return release.TagName, nil } func printVersionComparison(current, latest string) { faint := color.New(color.Faint) version := color.New(color.FgCyan) faint.Print("current ") version.Println(current) faint.Print("latest ") version.Println(latest) fmt.Println() } func printVersionComparisonTarget(current, target string) { faint := color.New(color.Faint) version := color.New(color.FgCyan) faint.Print("current ") version.Println(current) faint.Print("target ") version.Println(target) fmt.Println() } func printUpgradeInstructions() { bold := color.New(color.Bold) faint := color.New(color.Faint) cmd := color.New(color.FgCyan) bold.Println("upgrade options:") fmt.Println() faint.Print(" go install ") cmd.Printf("go install github.com/%s/%s@latest\n", repoOwner, repoName) faint.Print(" shell script ") cmd.Printf("curl -sSL https://raw.githubusercontent.com/%s/%s/master/install.sh | sh\n", repoOwner, repoName) faint.Print(" arch (aur) ") cmd.Println("yay -S snitch-bin") faint.Print(" nix ") cmd.Printf("nix profile upgrade --inputs-from github:%s/%s\n", repoOwner, repoName) } func performUpgrade(version string) error { execPath, err := os.Executable() if err != nil { return fmt.Errorf("failed to get executable path: %w", err) } execPath, err = filepath.EvalSymlinks(execPath) if err != nil { return fmt.Errorf("failed to resolve executable path: %w", err) } if strings.HasPrefix(execPath, "/nix/store/") { yellow := color.New(color.FgYellow) yellow.Println(tui.SymbolWarning + " cannot perform in-place upgrade for nix installation") fmt.Println() printNixUpgradeInstructions() return nil } goos := runtime.GOOS goarch := runtime.GOARCH versionClean := strings.TrimPrefix(version, "v") archiveName := fmt.Sprintf("%s_%s_%s_%s.tar.gz", repoName, versionClean, goos, goarch) downloadURL := fmt.Sprintf("https://github.com/%s/%s/releases/download/%s/%s", repoOwner, repoName, version, archiveName) faint := color.New(color.Faint) cyan := color.New(color.FgCyan) faint.Print(tui.SymbolDownload + " downloading ") cyan.Printf("%s", archiveName) faint.Println("...") resp, err := http.Get(downloadURL) if err != nil { return fmt.Errorf("failed to download: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("download failed with status %d", resp.StatusCode) } tmpDir, err := os.MkdirTemp("", "snitch-upgrade-*") if err != nil { return fmt.Errorf("failed to create temp directory: %w", err) } defer os.RemoveAll(tmpDir) binaryPath, err := extractBinaryFromTarGz(resp.Body, tmpDir) if err != nil { return fmt.Errorf("failed to extract binary: %w", err) } if goos == "darwin" { removeQuarantine(binaryPath) } // check if we can write to the target location targetDir := filepath.Dir(execPath) if !isWritable(targetDir) { yellow := color.New(color.FgYellow) cmdStyle := color.New(color.FgCyan) yellow.Printf(tui.SymbolWarning+" elevated permissions required to install to %s\n", targetDir) fmt.Println() faint.Println("run with sudo or install to a user-writable location:") fmt.Println() faint.Print(" sudo ") cmdStyle.Println("sudo snitch upgrade --yes") faint.Print(" custom dir ") cmdStyle.Printf("curl -sSL https://raw.githubusercontent.com/%s/%s/master/install.sh | INSTALL_DIR=~/.local/bin sh\n", repoOwner, repoName) return nil } // replace the binary backupPath := execPath + ".bak" if err := os.Rename(execPath, backupPath); err != nil { return fmt.Errorf("failed to backup current binary: %w", err) } if err := copyFile(binaryPath, execPath); err != nil { // try to restore backup if restoreErr := os.Rename(backupPath, execPath); restoreErr != nil { return fmt.Errorf("failed to install new binary and restore backup: %w (restore error: %v)", err, restoreErr) } return fmt.Errorf("failed to install new binary: %w", err) } if err := os.Chmod(execPath, 0755); err != nil { return fmt.Errorf("failed to set executable permissions: %w", err) } if err := os.Remove(backupPath); err != nil { // non-fatal, just warn yellow := color.New(color.FgYellow) yellow.Fprintf(os.Stderr, tui.SymbolWarning + " warning: failed to remove backup file %s: %v\n", backupPath, err) } green := color.New(color.FgGreen, color.Bold) green.Printf(tui.SymbolSuccess + " successfully upgraded to %s\n", version) return nil } func extractBinaryFromTarGz(r io.Reader, destDir string) (string, error) { gzr, err := gzip.NewReader(r) if err != nil { return "", err } defer gzr.Close() tr := tar.NewReader(gzr) for { header, err := tr.Next() if err == io.EOF { break } if err != nil { return "", err } if header.Typeflag != tar.TypeReg { continue } // look for the snitch binary name := filepath.Base(header.Name) if name != repoName { continue } destPath := filepath.Join(destDir, name) outFile, err := os.Create(destPath) if err != nil { return "", err } if _, err := io.Copy(outFile, tr); err != nil { outFile.Close() return "", err } outFile.Close() return destPath, nil } return "", fmt.Errorf("binary not found in archive") } func isWritable(path string) bool { testFile := filepath.Join(path, ".snitch-write-test") f, err := os.Create(testFile) if err != nil { return false } f.Close() os.Remove(testFile) return true } func copyFile(src, dst string) error { srcFile, err := os.Open(src) if err != nil { return err } defer srcFile.Close() dstFile, err := os.Create(dst) if err != nil { return err } defer dstFile.Close() if _, err := io.Copy(dstFile, srcFile); err != nil { return err } return dstFile.Sync() } func removeQuarantine(path string) { cmd := exec.Command("xattr", "-d", "com.apple.quarantine", path) if err := cmd.Run(); err == nil { faint := color.New(color.Faint) faint.Println(" removed macOS quarantine attribute") } } func isNixInstall() bool { execPath, err := os.Executable() if err != nil { return false } resolved, err := filepath.EvalSymlinks(execPath) if err != nil { return false } return strings.HasPrefix(resolved, "/nix/store/") } var nixVersionPattern = regexp.MustCompile(`^nix-([a-f0-9]+)(-dirty)?$`) var commitHashPattern = regexp.MustCompile(`^[a-f0-9]{7,40}$`) func isNixVersion(version string) bool { if nixVersionPattern.MatchString(version) { return true } if commitHashPattern.MatchString(version) { return true } return false } func extractCommitFromVersion(version string) string { matches := nixVersionPattern.FindStringSubmatch(version) if len(matches) >= 2 { return matches[1] } if commitHashPattern.MatchString(version) { return version } return "" } func isNixDirty(version string) bool { return strings.HasSuffix(version, "-dirty") } func fetchCommitForTag(tag string) (string, error) { url := fmt.Sprintf("%s/repos/%s/%s/commits/%s", githubAPI, repoOwner, repoName, tag) resp, err := http.Get(url) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("github api returned status %d", resp.StatusCode) } var commit githubCommit if err := json.NewDecoder(resp.Body).Decode(&commit); err != nil { return "", err } return commit.SHA, nil } func compareCommits(base, head string) (*githubCompare, error) { url := fmt.Sprintf("%s/repos/%s/%s/compare/%s...%s", githubAPI, repoOwner, repoName, base, head) resp, err := http.Get(url) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("github api returned status %d", resp.StatusCode) } var compare githubCompare if err := json.NewDecoder(resp.Body).Decode(&compare); err != nil { return nil, err } return &compare, nil } func printNixUpgradeInstructions() { bold := color.New(color.Bold) faint := color.New(color.Faint) cmd := color.New(color.FgCyan) bold.Println("nix upgrade options:") fmt.Println() faint.Print(" flake profile ") cmd.Printf("nix profile install github:%s/%s\n", repoOwner, repoName) faint.Print(" flake update ") cmd.Println("nix flake update snitch (in your system/home-manager config)") faint.Print(" rebuild ") cmd.Println("nixos-rebuild switch or home-manager switch") }