// Package update provides self-update functionality for claude-mnemonic. package update import ( "archive/tar" "compress/gzip" "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "runtime" "strconv" "strings" "sync" "time" "github.com/rs/zerolog/log" ) const ( GitHubRepo = "lukaszraczylo/claude-mnemonic" ReleasesAPI = "https://api.github.com/repos/" + GitHubRepo + "/releases/latest" CheckInterval = 24 * time.Hour MaxExtractedSize = 250 * 1024 * 1024 // 250MB max per extracted file RestartDelay = 500 * time.Millisecond ) // Release represents a GitHub release. type Release struct { PublishedAt time.Time `json:"published_at"` TagName string `json:"tag_name"` Name string `json:"name"` Body string `json:"body"` Assets []Asset `json:"assets"` } // Asset represents a release asset. type Asset struct { Name string `json:"name"` BrowserDownloadURL string `json:"browser_download_url"` Size int64 `json:"size"` } // UpdateInfo contains information about an available update. type UpdateInfo struct { PublishedAt time.Time `json:"published_at,omitempty"` CurrentVersion string `json:"current_version"` LatestVersion string `json:"latest_version"` ReleaseNotes string `json:"release_notes,omitempty"` DownloadURL string `json:"download_url,omitempty"` ChecksumsURL string `json:"checksums_url,omitempty"` BundleURL string `json:"bundle_url,omitempty"` ManualUpdateCommand string `json:"manual_update_command,omitempty"` Available bool `json:"available"` } // InstallScriptURL is the URL to the remote installation script. const InstallScriptURL = "https://raw.githubusercontent.com/" + GitHubRepo + "/main/scripts/install.sh" // GetManualUpdateCommand returns the curl command for manual update. // If version is empty, it installs the latest version. func GetManualUpdateCommand(version string) string { if version == "" { return fmt.Sprintf("curl -sSL %s | bash", InstallScriptURL) } return fmt.Sprintf("curl -sSL %s | bash -s -- %s", InstallScriptURL, version) } // UpdateStatus represents the current update status. type UpdateStatus struct { State string `json:"state"` Message string `json:"message"` Error string `json:"error,omitempty"` ManualUpdateCommand string `json:"manual_update_command,omitempty"` Progress float64 `json:"progress"` } // Updater handles self-updates. type Updater struct { lastCheck time.Time httpClient *http.Client cachedUpdate *UpdateInfo currentVersion string installDir string status UpdateStatus mu sync.RWMutex } // New creates a new Updater. func New(currentVersion, installDir string) *Updater { return &Updater{ currentVersion: currentVersion, installDir: installDir, httpClient: &http.Client{ Timeout: 30 * time.Second, }, status: UpdateStatus{State: "idle"}, } } // GetStatus returns the current update status. func (u *Updater) GetStatus() UpdateStatus { u.mu.RLock() defer u.mu.RUnlock() return u.status } func (u *Updater) setStatus(state string, progress float64, message string) { u.mu.Lock() defer u.mu.Unlock() u.status = UpdateStatus{ State: state, Progress: progress, Message: message, } } func (u *Updater) setError(err error) { u.mu.Lock() defer u.mu.Unlock() u.status = UpdateStatus{ State: "error", Message: "Update failed", Error: err.Error(), ManualUpdateCommand: GetManualUpdateCommand(""), // Always provide fallback command } } // CheckForUpdate checks if a new version is available. func (u *Updater) CheckForUpdate(ctx context.Context) (*UpdateInfo, error) { u.setStatus("checking", 0, "Checking for updates...") // Check cache first (within last hour) u.mu.RLock() if time.Since(u.lastCheck) < time.Hour && u.cachedUpdate != nil { cached := u.cachedUpdate u.mu.RUnlock() u.setStatus("idle", 0, "") return cached, nil } u.mu.RUnlock() req, err := http.NewRequestWithContext(ctx, "GET", ReleasesAPI, nil) if err != nil { u.setError(err) return nil, err } req.Header.Set("Accept", "application/vnd.github.v3+json") req.Header.Set("User-Agent", "claude-mnemonic/"+u.currentVersion) resp, err := u.httpClient.Do(req) if err != nil { u.setError(err) return nil, fmt.Errorf("failed to check for updates: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { err := fmt.Errorf("GitHub API returned status %d", resp.StatusCode) u.setError(err) return nil, err } var release Release if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { u.setError(err) return nil, fmt.Errorf("failed to parse release info: %w", err) } info := &UpdateInfo{ CurrentVersion: u.currentVersion, LatestVersion: strings.TrimPrefix(release.TagName, "v"), ReleaseNotes: release.Body, PublishedAt: release.PublishedAt, } // Compare versions info.Available = isNewerVersion(info.LatestVersion, u.currentVersion) // Always include manual update command as an alternative option info.ManualUpdateCommand = GetManualUpdateCommand("v" + info.LatestVersion) if info.Available { // Find download URLs for current platform platform := getPlatform() archiveName := fmt.Sprintf("claude-mnemonic_%s_%s.tar.gz", info.LatestVersion, platform) for _, asset := range release.Assets { switch { case asset.Name == archiveName: info.DownloadURL = asset.BrowserDownloadURL case asset.Name == "checksums.txt": info.ChecksumsURL = asset.BrowserDownloadURL case asset.Name == "checksums.txt.sigstore.json": info.BundleURL = asset.BrowserDownloadURL } } if info.DownloadURL == "" { log.Warn().Str("platform", platform).Msg("No release asset found for current platform") } } // Cache the result u.mu.Lock() u.lastCheck = time.Now() u.cachedUpdate = info u.mu.Unlock() u.setStatus("idle", 0, "") return info, nil } // ApplyUpdate downloads and applies the update. func (u *Updater) ApplyUpdate(ctx context.Context, info *UpdateInfo) error { if !info.Available || info.DownloadURL == "" { return fmt.Errorf("no update available or download URL missing") } tmpDir, err := os.MkdirTemp("", "claude-mnemonic-update-*") if err != nil { u.setError(err) return fmt.Errorf("failed to create temp directory: %w", err) } defer os.RemoveAll(tmpDir) // Step 1: Download checksums and sigstore bundle u.setStatus("downloading", 0.1, "Downloading checksums...") checksumsPath := filepath.Join(tmpDir, "checksums.txt") bundlePath := filepath.Join(tmpDir, "checksums.txt.sigstore.json") if info.ChecksumsURL != "" { if err := u.downloadFile(ctx, info.ChecksumsURL, checksumsPath); err != nil { u.setError(err) return fmt.Errorf("failed to download checksums: %w", err) } } if info.BundleURL != "" { if err := u.downloadFile(ctx, info.BundleURL, bundlePath); err != nil { u.setError(err) return fmt.Errorf("failed to download sigstore bundle: %w", err) } } // Step 2: Verify sigstore bundle with cosign (if available) u.setStatus("verifying", 0.2, "Verifying signature...") if info.ChecksumsURL != "" && info.BundleURL != "" { if err := u.verifySigstoreBundle(ctx, checksumsPath, bundlePath); err != nil { // Log warning but continue - signature verification is optional if cosign isn't installed log.Warn().Err(err).Msg("Signature verification failed or skipped") } else { log.Info().Msg("Sigstore signature verification passed") } } // Step 3: Download the archive u.setStatus("downloading", 0.3, "Downloading update...") archivePath := filepath.Join(tmpDir, "release.tar.gz") if err := u.downloadFile(ctx, info.DownloadURL, archivePath); err != nil { u.setError(err) return fmt.Errorf("failed to download release: %w", err) } // Step 4: Verify checksum u.setStatus("verifying", 0.6, "Verifying checksum...") if info.ChecksumsURL != "" { if err := u.verifyChecksum(archivePath, checksumsPath, info.LatestVersion); err != nil { u.setError(err) return fmt.Errorf("checksum verification failed: %w", err) } log.Info().Msg("Checksum verification passed") } // Step 5: Extract archive u.setStatus("applying", 0.7, "Extracting files...") extractDir := filepath.Join(tmpDir, "extracted") if err := u.extractTarGz(archivePath, extractDir); err != nil { u.setError(err) return fmt.Errorf("failed to extract archive: %w", err) } // Step 6: Replace binaries u.setStatus("applying", 0.85, "Installing update...") if err := u.replaceBinaries(extractDir); err != nil { u.setError(err) return fmt.Errorf("failed to replace binaries: %w", err) } // Clear cache so next check shows no update available u.mu.Lock() u.cachedUpdate = nil u.lastCheck = time.Time{} u.mu.Unlock() u.setStatus("done", 1.0, fmt.Sprintf("Updated to v%s. Restart required.", info.LatestVersion)) log.Info().Str("version", info.LatestVersion).Msg("Update applied successfully") return nil } func (u *Updater) downloadFile(ctx context.Context, url, destPath string) error { req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return err } req.Header.Set("User-Agent", "claude-mnemonic/"+u.currentVersion) resp, err := u.httpClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("download failed with status %d", resp.StatusCode) } f, err := os.Create(destPath) // #nosec G304 -- destPath is constructed internally from temp directory if err != nil { return err } defer f.Close() _, err = io.Copy(f, resp.Body) return err } func (u *Updater) verifySigstoreBundle(ctx context.Context, checksumsPath, bundlePath string) error { // Check if cosign is available if _, err := exec.LookPath("cosign"); err != nil { return fmt.Errorf("cosign not installed: %w", err) } // Verify sigstore bundle - uses keyless verification with certificate identity // The bundle contains the signature, certificate, and transparency log entry // Certificate identity matches GitHub Actions workflow for this repo cmd := exec.CommandContext(ctx, "cosign", "verify-blob", "--bundle", bundlePath, "--certificate-identity-regexp", "https://github.com/lukaszraczylo/claude-mnemonic/.*", "--certificate-oidc-issuer", "https://token.actions.githubusercontent.com", checksumsPath, ) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("cosign verification failed: %w, output: %s", err, string(output)) } return nil } func (u *Updater) verifyChecksum(archivePath, checksumsPath, version string) error { // Read checksums file data, err := os.ReadFile(checksumsPath) // #nosec G304 -- checksumsPath is constructed internally from temp directory if err != nil { return fmt.Errorf("failed to read checksums: %w", err) } // Calculate archive checksum f, err := os.Open(archivePath) // #nosec G304 -- archivePath is constructed internally from temp directory if err != nil { return err } defer f.Close() h := sha256.New() if _, err := io.Copy(h, f); err != nil { return err } actualChecksum := hex.EncodeToString(h.Sum(nil)) // Find expected checksum for our archive platform := getPlatform() expectedName := fmt.Sprintf("claude-mnemonic_%s_%s.tar.gz", version, platform) for _, line := range strings.Split(string(data), "\n") { parts := strings.Fields(line) if len(parts) >= 2 && strings.HasSuffix(parts[1], expectedName) { if parts[0] == actualChecksum { return nil } return fmt.Errorf("checksum mismatch: expected %s, got %s", parts[0], actualChecksum) } } return fmt.Errorf("no checksum found for %s", expectedName) } func (u *Updater) extractTarGz(archivePath, destDir string) error { if err := os.MkdirAll(destDir, 0750); err != nil { return err } f, err := os.Open(archivePath) // #nosec G304 -- archivePath is constructed internally from temp directory if err != nil { return err } defer f.Close() gzr, err := gzip.NewReader(f) 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 } // #nosec G305 -- path traversal is prevented by the check below target := filepath.Join(destDir, header.Name) // Prevent path traversal if !strings.HasPrefix(target, filepath.Clean(destDir)+string(os.PathSeparator)) { return fmt.Errorf("invalid tar path: %s", header.Name) } switch header.Typeflag { case tar.TypeDir: if err := os.MkdirAll(target, 0750); err != nil { return err } case tar.TypeReg: if err := os.MkdirAll(filepath.Dir(target), 0750); err != nil { return err } // #nosec G304,G115 -- target is validated above; mode from trusted tar header outFile, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode)&0755) if err != nil { return err } // Limit extraction size to prevent decompression bombs written, err := io.Copy(outFile, io.LimitReader(tr, MaxExtractedSize)) if err != nil { _ = outFile.Close() return err } if written == MaxExtractedSize { _ = outFile.Close() return fmt.Errorf("file %s exceeds maximum allowed size", header.Name) } if err := outFile.Close(); err != nil { return err } } } return nil } func (u *Updater) replaceBinaries(extractDir string) error { // Binary files to replace binaryFiles := []string{ "worker", "mcp-server", "hooks/session-start", "hooks/user-prompt", "hooks/post-tool-use", "hooks/stop", "hooks/subagent-stop", "hooks/statusline", } // Get all install directories (marketplaces and cache directories) installDirs := u.getInstallDirectories() for _, installDir := range installDirs { log.Debug().Str("dir", installDir).Msg("Installing binaries to directory") for _, binaryFile := range binaryFiles { src := filepath.Join(extractDir, binaryFile) if _, err := os.Stat(src); os.IsNotExist(err) { continue // Skip if not in archive } dest := filepath.Join(installDir, binaryFile) // Backup existing binary if _, err := os.Stat(dest); err == nil { backup := dest + ".bak" if err := os.Rename(dest, backup); err != nil { log.Warn().Err(err).Str("file", dest).Msg("Failed to backup, continuing anyway") } else { defer func(backup, dest string) { // Clean up backup on success, restore on failure if _, err := os.Stat(dest); err == nil { _ = os.Remove(backup) } else { _ = os.Rename(backup, dest) } }(backup, dest) } } // Copy new binary if err := copyFile(src, dest); err != nil { return fmt.Errorf("failed to install %s: %w", dest, err) } // Make executable // #nosec G302 -- executables require 0755 permissions if err := os.Chmod(dest, 0755); err != nil { return fmt.Errorf("failed to chmod %s: %w", dest, err) } } } return nil } // getInstallDirectories returns all directories where binaries should be installed. // This includes the marketplaces directory and any cache directories. func (u *Updater) getInstallDirectories() []string { dirs := []string{u.installDir} // Also check cache directories where Claude Code looks for plugins home, err := os.UserHomeDir() if err != nil { return dirs } // Look for cache directories under ~/.claude/plugins/cache/claude-mnemonic/claude-mnemonic/ cacheBase := filepath.Join(home, ".claude/plugins/cache/claude-mnemonic/claude-mnemonic") entries, err := os.ReadDir(cacheBase) if err != nil { return dirs } for _, entry := range entries { if entry.IsDir() { cacheDir := filepath.Join(cacheBase, entry.Name()) // Only add if it's different from installDir and contains a worker binary if cacheDir != u.installDir { workerPath := filepath.Join(cacheDir, "worker") if _, err := os.Stat(workerPath); err == nil { dirs = append(dirs, cacheDir) log.Debug().Str("dir", cacheDir).Msg("Found cache directory to update") } } } } return dirs } func copyFile(src, dst string) error { // Ensure parent directory exists if err := os.MkdirAll(filepath.Dir(dst), 0750); err != nil { return err } in, err := os.Open(src) // #nosec G304 -- src is constructed internally from extracted temp directory if err != nil { return err } defer in.Close() out, err := os.Create(dst) // #nosec G304 -- dst is constructed internally from install directory if err != nil { return err } defer out.Close() _, err = io.Copy(out, in) return err } func getPlatform() string { os := runtime.GOOS arch := runtime.GOARCH // Map to release naming convention return fmt.Sprintf("%s_%s", os, arch) } func isNewerVersion(latest, current string) bool { // Strip 'v' prefix if present latest = strings.TrimPrefix(latest, "v") current = strings.TrimPrefix(current, "v") // For dev/dirty builds, extract the base version for comparison // e.g., "0.3.5-2-gca711a8-dirty" -> "0.3.5" currentBase := current if idx := strings.Index(current, "-"); idx > 0 { currentBase = current[:idx] } // Simple semver comparison using base version latestParts := strings.Split(latest, ".") currentParts := strings.Split(currentBase, ".") for i := 0; i < len(latestParts) && i < len(currentParts); i++ { latestNum, _ := strconv.Atoi(latestParts[i]) currentNum, _ := strconv.Atoi(currentParts[i]) if latestNum > currentNum { return true } if latestNum < currentNum { return false } } return len(latestParts) > len(currentParts) } // Restart spawns the new worker binary and exits the current process. // This should be called after a successful update to apply the new version. func (u *Updater) Restart() error { workerPath := filepath.Join(u.installDir, "worker") // Verify the new binary exists if _, err := os.Stat(workerPath); err != nil { return fmt.Errorf("new worker binary not found: %w", err) } log.Info().Str("path", workerPath).Msg("Restarting worker with new binary") // Use nohup to start a detached process that survives parent exit // The new worker will retry binding to the port after the old process exits cmd := exec.Command("nohup", workerPath) // #nosec G204 -- workerPath is from internal installDir cmd.Stdout = nil // Detach stdout cmd.Stderr = nil // Detach stderr cmd.Stdin = nil // Detach stdin cmd.Env = append(os.Environ(), "CLAUDE_MNEMONIC_RESTART=1") // Start in background - don't wait if err := cmd.Start(); err != nil { return fmt.Errorf("failed to start new worker: %w", err) } // Release the child process so it's not a zombie go func() { _ = cmd.Wait() }() log.Info().Int("new_pid", cmd.Process.Pid).Msg("New worker started, exiting old process") // Give a moment for the log to flush time.Sleep(100 * time.Millisecond) // Exit current process - the new one will bind to the port os.Exit(0) return nil // Never reached }