mirror of
https://github.com/lukaszraczylo/claude-mnemonic.git
synced 2026-06-05 23:03:55 +00:00
667 lines
19 KiB
Go
667 lines
19 KiB
Go
// 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 {
|
|
TagName string `json:"tag_name"`
|
|
Name string `json:"name"`
|
|
PublishedAt time.Time `json:"published_at"`
|
|
Assets []Asset `json:"assets"`
|
|
Body string `json:"body"`
|
|
}
|
|
|
|
// 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 {
|
|
Available bool `json:"available"`
|
|
CurrentVersion string `json:"current_version"`
|
|
LatestVersion string `json:"latest_version"`
|
|
ReleaseNotes string `json:"release_notes,omitempty"`
|
|
PublishedAt time.Time `json:"published_at,omitempty"`
|
|
DownloadURL string `json:"download_url,omitempty"`
|
|
ChecksumsURL string `json:"checksums_url,omitempty"`
|
|
BundleURL string `json:"bundle_url,omitempty"` // Sigstore bundle (.sigstore.json)
|
|
ManualUpdateCommand string `json:"manual_update_command,omitempty"`
|
|
}
|
|
|
|
// 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"` // "idle", "checking", "downloading", "verifying", "applying", "done", "error"
|
|
Progress float64 `json:"progress"`
|
|
Message string `json:"message"`
|
|
Error string `json:"error,omitempty"`
|
|
ManualUpdateCommand string `json:"manual_update_command,omitempty"` // Shown when update fails
|
|
}
|
|
|
|
// Updater handles self-updates.
|
|
type Updater struct {
|
|
currentVersion string
|
|
installDir string
|
|
httpClient *http.Client
|
|
|
|
mu sync.RWMutex
|
|
status UpdateStatus
|
|
lastCheck time.Time
|
|
cachedUpdate *UpdateInfo
|
|
}
|
|
|
|
// 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
|
|
}
|