From abc58feb8932167055b81d83371a2988f9d36552 Mon Sep 17 00:00:00 2001 From: Steven17D <22868816+Steven17D@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:25:31 -0500 Subject: [PATCH 1/4] feat(update): add source-based update flow from local git checkout Introduce source-based self-updates as an alternative to release binary updates. Adds source_dir/source_ref config for tracking a local checkout, compares HEAD vs source_ref to compute update availability, runs git pull and builds directly to the install path during source-mode updates. Skips update prompt on diverged branches, validates source_dir is a git repository, and shows build commit hash in version output via ldflags. Builds directly to the executable path and re-signs with codesign on macOS to preserve the ad-hoc code signature. --- Makefile | 3 +- cmd/agent-deck/main.go | 22 ++++- internal/session/userconfig.go | 12 +++ internal/ui/help.go | 2 +- internal/ui/home.go | 18 +++- internal/update/source.go | 158 +++++++++++++++++++++++++++++++++ internal/update/update.go | 29 +++++- 7 files changed, 233 insertions(+), 11 deletions(-) create mode 100644 internal/update/source.go diff --git a/Makefile b/Makefile index 3cf0c7c3f..471cf7ae2 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,8 @@ BINARY_NAME=agent-deck BUILD_DIR=./build VERSION=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") -LDFLAGS=-ldflags "-X main.Version=$(VERSION)" +COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null) +LDFLAGS=-ldflags "-X main.Version=$(VERSION) -X main.Commit=$(COMMIT)" # Build the binary build: diff --git a/cmd/agent-deck/main.go b/cmd/agent-deck/main.go index 4defdcac2..df039bab3 100644 --- a/cmd/agent-deck/main.go +++ b/cmd/agent-deck/main.go @@ -33,6 +33,17 @@ import ( const Version = "0.27.5" +// Commit is the git commit hash, injected at build time via ldflags. +var Commit string + +// VersionString returns the version with commit hash if available. +func VersionString() string { + if Commit != "" { + return Version + " (" + Commit + ")" + } + return Version +} + // Table column widths for list command output const ( tableColTitle = 20 @@ -198,7 +209,7 @@ func main() { if len(args) > 0 { switch args[0] { case "version", "--version", "-v": - fmt.Printf("Agent Deck v%s\n", Version) + fmt.Printf("Agent Deck v%s\n", VersionString()) return case "help", "--help", "-h": printHelp() @@ -313,6 +324,7 @@ func main() { // Set version for UI update checking ui.SetVersion(Version) + ui.SetCommit(Commit) // Initialize theme from config (resolves "system" to actual dark/light) theme := session.ResolveTheme() @@ -2101,7 +2113,7 @@ func handleUpdate(args []string) { os.Exit(1) } - fmt.Printf("Agent Deck v%s\n", Version) + fmt.Printf("Agent Deck v%s\n", VersionString()) fmt.Println("Checking for updates...") // Always force check when user explicitly runs 'update' command @@ -2118,7 +2130,9 @@ func handleUpdate(args []string) { } fmt.Printf("\n⬆ Update available: v%s → v%s\n", info.CurrentVersion, info.LatestVersion) - fmt.Printf(" Release: %s\n", info.ReleaseURL) + if info.ReleaseURL != "" { + fmt.Printf(" %s\n", info.ReleaseURL) + } // Fetch and display changelog displayChangelog(info.CurrentVersion, info.LatestVersion) @@ -2261,7 +2275,7 @@ func drainStdin() { } func printHelp() { - fmt.Printf("Agent Deck v%s\n", Version) + fmt.Printf("Agent Deck v%s\n", VersionString()) fmt.Println("Terminal session manager for AI coding agents") fmt.Println() fmt.Println("Usage: agent-deck [-p profile] [command]") diff --git a/internal/session/userconfig.go b/internal/session/userconfig.go index 6da112555..8c375be2e 100644 --- a/internal/session/userconfig.go +++ b/internal/session/userconfig.go @@ -293,6 +293,15 @@ type UpdateSettings struct { // NotifyInCLI shows update notification in CLI commands (not just TUI) // Default: true NotifyInCLI bool `toml:"notify_in_cli"` + + // SourceDir is the absolute path to a local git checkout. When set, + // updates pull and build from source instead of downloading release + // binaries. Leave empty to use the default release-based updates. + SourceDir string `toml:"source_dir"` + + // SourceRef is the remote tracking ref to follow (e.g. "origin/main"). + // Default: "origin/main" + SourceRef string `toml:"source_ref"` } // PreviewSettings defines preview pane configuration @@ -1423,6 +1432,9 @@ func GetUpdateSettings() UpdateSettings { if settings.CheckIntervalHours <= 0 { settings.CheckIntervalHours = 24 } + if settings.SourceRef == "" { + settings.SourceRef = "origin/main" + } return settings } diff --git a/internal/ui/help.go b/internal/ui/help.go index c8dedc8ac..cb67437fa 100644 --- a/internal/ui/help.go +++ b/internal/ui/help.go @@ -308,7 +308,7 @@ func (h *HelpOverlay) View() string { } lines = append(lines, "") lines = append(lines, separatorStyle.Render(strings.Repeat("─", separatorWidth))) - lines = append(lines, versionStyle.Render("Agent Deck v"+Version)) + lines = append(lines, versionStyle.Render("Agent Deck v"+DisplayVersion())) totalLines := len(lines) diff --git a/internal/ui/home.go b/internal/ui/home.go index 4caa513f7..efeb9a904 100644 --- a/internal/ui/home.go +++ b/internal/ui/home.go @@ -39,11 +39,27 @@ import ( // Version is set by main.go for update checking var Version = "0.0.0" +// Commit is the git commit hash, set by main.go at startup. +var Commit string + // SetVersion sets the current version for update checking func SetVersion(v string) { Version = v } +// SetCommit sets the git commit hash for display in the UI. +func SetCommit(c string) { + Commit = c +} + +// DisplayVersion returns the version string with commit hash if available. +func DisplayVersion() string { + if Commit != "" { + return Version + " (" + Commit + ")" + } + return Version +} + // Structured loggers for UI components var ( uiLog = logging.ForComponent(logging.CompUI) @@ -7540,7 +7556,7 @@ func (h *Home) View() string { versionStyle := lipgloss.NewStyle(). Foreground(ColorComment). Faint(true) - versionBadge := versionStyle.Render("v" + Version) + versionBadge := versionStyle.Render("v" + DisplayVersion()) // Fill remaining header space headerLeft := lipgloss.JoinHorizontal(lipgloss.Left, logo, " ", title, " ", stats) diff --git a/internal/update/source.go b/internal/update/source.go new file mode 100644 index 000000000..a3e5a69da --- /dev/null +++ b/internal/update/source.go @@ -0,0 +1,158 @@ +package update + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/asheshgoplani/agent-deck/internal/session" +) + +// sourceGitCmd runs a git command in the configured source directory. +func sourceGitCmd(sourceDir string, args ...string) (string, error) { + cmd := exec.Command("git", args...) + cmd.Dir = sourceDir + out, err := cmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + return "", fmt.Errorf("git %s: %s", strings.Join(args, " "), strings.TrimSpace(string(exitErr.Stderr))) + } + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +// splitRef splits "origin/main" into ("origin", "main"). +func splitRef(ref string) (remote, branch string) { + if i := strings.IndexByte(ref, '/'); i >= 0 { + return ref[:i], ref[i+1:] + } + return ref, "main" +} + +// checkForSourceUpdate compares local HEAD vs remote HEAD in a local git checkout. +func checkForSourceUpdate(currentVersion string, forceCheck bool, settings session.UpdateSettings) (*UpdateInfo, error) { + info := &UpdateInfo{ + Available: false, + CurrentVersion: currentVersion, + } + + // Validate source directory is a git repo + if _, err := os.Stat(filepath.Join(settings.SourceDir, ".git")); err != nil { + return info, fmt.Errorf("source_dir %q is not a git repository", settings.SourceDir) + } + + // Try to use cache first (unless force check) + if !forceCheck { + cache, err := loadCache() + if err == nil && time.Since(cache.CheckedAt) < checkInterval { + info.LatestVersion = cache.LatestVersion + info.Available = cache.LatestVersion != cache.CurrentVersion + return info, nil + } + } + + remote, branch := splitRef(settings.SourceRef) + + // Fetch latest from remote + if _, err := sourceGitCmd(settings.SourceDir, "fetch", remote, branch); err != nil { + return info, fmt.Errorf("git fetch failed: %w", err) + } + + // Extract version from remote main.go + remoteVersion := currentVersion + remoteFileContent, err := sourceGitCmd(settings.SourceDir, "show", settings.SourceRef+":cmd/agent-deck/main.go") + if err == nil { + for _, line := range strings.Split(remoteFileContent, "\n") { + if strings.Contains(line, "const Version") { + parts := strings.Split(line, `"`) + if len(parts) >= 2 { + remoteVersion = parts[1] + } + break + } + } + } + + // Only offer update if HEAD is strictly behind the remote (no local-only commits). + // This avoids prompting when on a feature branch that has diverged. + behindCount, _ := sourceGitCmd(settings.SourceDir, "rev-list", "--count", "HEAD.."+settings.SourceRef) + aheadCount, _ := sourceGitCmd(settings.SourceDir, "rev-list", "--count", settings.SourceRef+"..HEAD") + behind := behindCount != "" && behindCount != "0" + ahead := aheadCount != "" && aheadCount != "0" + available := behind && !ahead + + cache := &UpdateCache{ + CheckedAt: time.Now(), + LatestVersion: remoteVersion, + CurrentVersion: currentVersion, + } + _ = saveCache(cache) + + info.LatestVersion = remoteVersion + if available { + info.ReleaseURL = fmt.Sprintf("%s new commit(s) on %s", behindCount, settings.SourceRef) + } + info.Available = available + + return info, nil +} + +// performSourceUpdate pulls the latest source and builds from a local git checkout. +func performSourceUpdate(settings session.UpdateSettings) error { + if _, err := os.Stat(filepath.Join(settings.SourceDir, ".git")); err != nil { + return fmt.Errorf("source_dir %q is not a git repository", settings.SourceDir) + } + + remote, branch := splitRef(settings.SourceRef) + + execPath, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to find current executable: %w", err) + } + execPath, err = filepath.EvalSymlinks(execPath) + if err != nil { + return fmt.Errorf("failed to resolve executable path: %w", err) + } + + // Pull latest from remote + fmt.Printf("Pulling latest from %s...\n", settings.SourceRef) + pullOut, err := sourceGitCmd(settings.SourceDir, "pull", remote, branch) + if err != nil { + return fmt.Errorf("git pull failed: %w", err) + } + fmt.Println(pullOut) + + // Build directly to the install path. + // On macOS, go build applies an ad-hoc code signature. Copying the binary + // afterward (ReadFile+WriteFile or cp) creates a new file whose signature + // doesn't match, causing the kernel to SIGKILL the process on launch. + // Building in-place avoids this entirely. + fmt.Println("Building from source...") + + version, _ := sourceGitCmd(settings.SourceDir, "describe", "--tags", "--always", "--dirty") + commit, _ := sourceGitCmd(settings.SourceDir, "rev-parse", "--short", "HEAD") + ldflags := fmt.Sprintf("-X main.Version=%s -X main.Commit=%s", version, commit) + + buildCmd := exec.Command("go", "build", "-ldflags", ldflags, "-o", execPath, "./cmd/agent-deck/") + buildCmd.Dir = settings.SourceDir + buildCmd.Stdout = os.Stdout + buildCmd.Stderr = os.Stderr + if err := buildCmd.Run(); err != nil { + return fmt.Errorf("build failed: %w", err) + } + + // Re-sign on macOS to ensure a valid ad-hoc signature + if codesign, err := exec.LookPath("codesign"); err == nil { + signCmd := exec.Command(codesign, "--force", "--sign", "-", execPath) + signCmd.Stdout = os.Stdout + signCmd.Stderr = os.Stderr + _ = signCmd.Run() + } + + fmt.Println("Update complete (built from source)!") + return nil +} diff --git a/internal/update/update.go b/internal/update/update.go index 67d3f674c..060981af7 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -234,9 +234,19 @@ func CompareVersions(v1, v2 string) int { return 0 } -// CheckForUpdate checks if a new version is available -// Uses cache to avoid hitting GitHub API too frequently +// CheckForUpdate checks if a new version is available. +// When source_dir is set in config, compares local vs remote git HEAD; +// otherwise checks GitHub releases. func CheckForUpdate(currentVersion string, forceCheck bool) (*UpdateInfo, error) { + settings := session.GetUpdateSettings() + if settings.SourceDir != "" { + return checkForSourceUpdate(currentVersion, forceCheck, settings) + } + return checkForReleaseUpdate(currentVersion, forceCheck) +} + +// checkForReleaseUpdate checks GitHub releases for a newer version. +func checkForReleaseUpdate(currentVersion string, forceCheck bool) (*UpdateInfo, error) { info := &UpdateInfo{ Available: false, CurrentVersion: currentVersion, @@ -301,8 +311,19 @@ func CheckForUpdateAsync(currentVersion string) <-chan *UpdateInfo { return ch } -// PerformUpdate downloads and installs the latest version +// PerformUpdate installs the latest version. When source_dir is set in config, +// it pulls and builds from a local git checkout; otherwise it downloads a +// pre-built release binary. func PerformUpdate(downloadURL string) error { + settings := session.GetUpdateSettings() + if settings.SourceDir != "" { + return performSourceUpdate(settings) + } + return performReleaseUpdate(downloadURL) +} + +// performReleaseUpdate downloads and installs a pre-built release binary. +func performReleaseUpdate(downloadURL string) error { if downloadURL == "" { return fmt.Errorf("no download URL available for %s/%s", runtime.GOOS, runtime.GOARCH) } @@ -374,7 +395,7 @@ func PerformUpdate(downloadURL string) error { // Remove old binary os.Remove(oldBinaryPath) - fmt.Println("āœ“ Update complete!") + fmt.Println("Update complete!") return nil } From 54aec4a29227c02734d68e19985f5665dd6ae319 Mon Sep 17 00:00:00 2001 From: Steven17D <22868816+Steven17D@users.noreply.github.com> Date: Wed, 1 Apr 2026 22:31:53 -0500 Subject: [PATCH 2/4] fix(update): make source-mode checks commit-aware --- internal/update/source.go | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/internal/update/source.go b/internal/update/source.go index a3e5a69da..d14518758 100644 --- a/internal/update/source.go +++ b/internal/update/source.go @@ -45,15 +45,10 @@ func checkForSourceUpdate(currentVersion string, forceCheck bool, settings sessi return info, fmt.Errorf("source_dir %q is not a git repository", settings.SourceDir) } - // Try to use cache first (unless force check) - if !forceCheck { - cache, err := loadCache() - if err == nil && time.Since(cache.CheckedAt) < checkInterval { - info.LatestVersion = cache.LatestVersion - info.Available = cache.LatestVersion != cache.CurrentVersion - return info, nil - } - } + // Source-mode updates must be commit-aware. Version strings can remain the same + // across multiple commits on a branch (e.g. origin/dev), so cache-only checks + // can incorrectly suppress updates. Always evaluate git state directly. + _ = forceCheck remote, branch := splitRef(settings.SourceRef) From 808eff63f48009479995de56d8c91f06eb96905c Mon Sep 17 00:00:00 2001 From: Steven17D <22868816+Steven17D@users.noreply.github.com> Date: Wed, 1 Apr 2026 22:38:12 -0500 Subject: [PATCH 3/4] fix(update): prompt source rebuild when binary commit lags source --- cmd/agent-deck/main.go | 57 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 49 insertions(+), 8 deletions(-) diff --git a/cmd/agent-deck/main.go b/cmd/agent-deck/main.go index df039bab3..290c7db88 100644 --- a/cmd/agent-deck/main.go +++ b/cmd/agent-deck/main.go @@ -90,19 +90,32 @@ func promptForUpdate() bool { } info, err := update.CheckForUpdate(Version, false) - if err != nil || info == nil || !info.Available { + if err != nil || info == nil { + return false + } + + sourceRebuild, sourceHead := sourceRebuildNeeded() + if !info.Available && !sourceRebuild { return false } // If auto_update is disabled, just show notification (don't prompt) if !settings.AutoUpdate { - fmt.Fprintf(os.Stderr, "\nšŸ’” Update available: v%s → v%s (run: agent-deck update)\n", - info.CurrentVersion, info.LatestVersion) + if sourceRebuild { + fmt.Fprintf(os.Stderr, "\nšŸ’” Rebuild available: binary %s vs source %s (run: agent-deck update)\n", Commit, sourceHead) + } else { + fmt.Fprintf(os.Stderr, "\nšŸ’” Update available: v%s → v%s (run: agent-deck update)\n", + info.CurrentVersion, info.LatestVersion) + } return false } // auto_update is enabled - prompt user - fmt.Printf("\n⬆ Update available: v%s → v%s\n", info.CurrentVersion, info.LatestVersion) + if sourceRebuild { + fmt.Printf("\n⬆ Rebuild available: binary %s vs source %s\n", Commit, sourceHead) + } else { + fmt.Printf("\n⬆ Update available: v%s → v%s\n", info.CurrentVersion, info.LatestVersion) + } fmt.Print("Update now? [Y/n]: ") var response string @@ -2123,15 +2136,20 @@ func handleUpdate(args []string) { fmt.Printf("Error checking for updates: %v\n", err) os.Exit(1) } + sourceRebuild, sourceHead := sourceRebuildNeeded() - if !info.Available { + if !info.Available && !sourceRebuild { fmt.Println("āœ“ You're running the latest version!") return } - fmt.Printf("\n⬆ Update available: v%s → v%s\n", info.CurrentVersion, info.LatestVersion) - if info.ReleaseURL != "" { - fmt.Printf(" %s\n", info.ReleaseURL) + if sourceRebuild && !info.Available { + fmt.Printf("\n⬆ Rebuild available: binary %s vs source %s\n", Commit, sourceHead) + } else { + fmt.Printf("\n⬆ Update available: v%s → v%s\n", info.CurrentVersion, info.LatestVersion) + if info.ReleaseURL != "" { + fmt.Printf(" %s\n", info.ReleaseURL) + } } // Fetch and display changelog @@ -2204,6 +2222,29 @@ func handleUpdate(args []string) { updateRemotesAfterLocalUpdate(info.LatestVersion) } +// sourceRebuildNeeded reports whether source-mode should rebuild the binary even +// when the source checkout is not behind source_ref (e.g. binary commit differs). +func sourceRebuildNeeded() (bool, string) { + settings := session.GetUpdateSettings() + if settings.SourceDir == "" || Commit == "" { + return false, "" + } + + cmd := exec.Command("git", "rev-parse", "--short", "HEAD") + cmd.Dir = settings.SourceDir + out, err := cmd.Output() + if err != nil { + return false, "" + } + + head := strings.TrimSpace(string(out)) + if head == "" || head == Commit { + return false, head + } + + return true, head +} + func runHomebrewUpgradeWithRefresh(homebrewUpgradeCmd string) error { cmdParts := strings.Fields(homebrewUpgradeCmd) if len(cmdParts) == 0 { From 02e9025618d74d80083cfa990d369ed9f573cd65 Mon Sep 17 00:00:00 2001 From: Steven17D <22868816+Steven17D@users.noreply.github.com> Date: Wed, 1 Apr 2026 22:42:12 -0500 Subject: [PATCH 4/4] fix(update): handle force-push divergence in source mode --- internal/update/source.go | 55 ++++++++++++++++++++++++++++++++------- 1 file changed, 46 insertions(+), 9 deletions(-) diff --git a/internal/update/source.go b/internal/update/source.go index d14518758..27f916a27 100644 --- a/internal/update/source.go +++ b/internal/update/source.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" "time" @@ -72,13 +73,13 @@ func checkForSourceUpdate(currentVersion string, forceCheck bool, settings sessi } } - // Only offer update if HEAD is strictly behind the remote (no local-only commits). - // This avoids prompting when on a feature branch that has diverged. + // Offer update whenever local HEAD is behind source_ref, including divergence. + // Divergence can happen after force-push rewrites; update flow will reconcile. behindCount, _ := sourceGitCmd(settings.SourceDir, "rev-list", "--count", "HEAD.."+settings.SourceRef) aheadCount, _ := sourceGitCmd(settings.SourceDir, "rev-list", "--count", settings.SourceRef+"..HEAD") behind := behindCount != "" && behindCount != "0" ahead := aheadCount != "" && aheadCount != "0" - available := behind && !ahead + available := behind cache := &UpdateCache{ CheckedAt: time.Now(), @@ -89,7 +90,11 @@ func checkForSourceUpdate(currentVersion string, forceCheck bool, settings sessi info.LatestVersion = remoteVersion if available { - info.ReleaseURL = fmt.Sprintf("%s new commit(s) on %s", behindCount, settings.SourceRef) + if ahead { + info.ReleaseURL = fmt.Sprintf("%s new commit(s) on %s (diverged: %s local-only commit(s))", behindCount, settings.SourceRef, aheadCount) + } else { + info.ReleaseURL = fmt.Sprintf("%s new commit(s) on %s", behindCount, settings.SourceRef) + } } info.Available = available @@ -113,13 +118,45 @@ func performSourceUpdate(settings session.UpdateSettings) error { return fmt.Errorf("failed to resolve executable path: %w", err) } - // Pull latest from remote - fmt.Printf("Pulling latest from %s...\n", settings.SourceRef) - pullOut, err := sourceGitCmd(settings.SourceDir, "pull", remote, branch) + // Fetch latest from remote. + if _, err := sourceGitCmd(settings.SourceDir, "fetch", remote, branch); err != nil { + return fmt.Errorf("git fetch failed: %w", err) + } + + // Never rewrite a dirty source checkout. + porcelain, err := sourceGitCmd(settings.SourceDir, "status", "--porcelain") if err != nil { - return fmt.Errorf("git pull failed: %w", err) + return fmt.Errorf("git status failed: %w", err) + } + if strings.TrimSpace(porcelain) != "" { + return fmt.Errorf("source_dir has uncommitted changes; commit/stash before update") + } + + behindCount, _ := sourceGitCmd(settings.SourceDir, "rev-list", "--count", "HEAD.."+settings.SourceRef) + aheadCount, _ := sourceGitCmd(settings.SourceDir, "rev-list", "--count", settings.SourceRef+"..HEAD") + behind, _ := strconv.Atoi(strings.TrimSpace(behindCount)) + ahead, _ := strconv.Atoi(strings.TrimSpace(aheadCount)) + + // Reconcile source checkout state. + // - diverged (ahead+behind): hard-reset to source_ref (force-push safe) + // - behind only: fast-forward pull + // - up-to-date or ahead-only: keep local HEAD and rebuild + fmt.Printf("Pulling latest from %s...\n", settings.SourceRef) + switch { + case behind > 0 && ahead > 0: + fmt.Printf("Source checkout diverged (%d behind, %d ahead), resetting to %s...\n", behind, ahead, settings.SourceRef) + if _, err := sourceGitCmd(settings.SourceDir, "reset", "--hard", settings.SourceRef); err != nil { + return fmt.Errorf("git reset failed: %w", err) + } + case behind > 0: + pullOut, err := sourceGitCmd(settings.SourceDir, "pull", "--ff-only", remote, branch) + if err != nil { + return fmt.Errorf("git pull failed: %w", err) + } + fmt.Println(pullOut) + default: + fmt.Println("Already up to date.") } - fmt.Println(pullOut) // Build directly to the install path. // On macOS, go build applies an ad-hoc code signature. Copying the binary