Skip to content

feat: self-upgrade command#1623

Merged
yottahmd merged 9 commits intomainfrom
upgrade-command
Feb 2, 2026
Merged

feat: self-upgrade command#1623
yottahmd merged 9 commits intomainfrom
upgrade-command

Conversation

@yottahmd
Copy link
Copy Markdown
Collaborator

@yottahmd yottahmd commented Feb 2, 2026

Summary by CodeRabbit

Release Notes

  • New Features
    • Added new upgrade command to check for and install new versions with support for target version selection, dry-run mode, and backup creation
    • Added automatic update checking with caching to reduce repeated checks
    • Added UI notification banner when a new version is available

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Feb 2, 2026

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

  • 🔍 Trigger a full review
📝 Walkthrough

Walkthrough

This PR introduces a comprehensive self-upgrade system for the dagu CLI, including a new upgrade command with support for version checking, dry-run mode, backups, and interactive confirmation. The implementation spans version parsing with semantic versioning, cross-platform binary detection, GitHub release integration, atomic binary installation, persistent update caching, and a frontend notification banner.

Changes

Cohort / File(s) Summary
CLI Command Registration
cmd/main.go, internal/cmd/upgrade.go
Adds new upgrade subcommand with flags for check-only, target version, dry-run, backup creation, and skip confirmation. Includes interactive prompts and validation for self-upgrade capability.
Version Management
internal/upgrade/version.go
Implements version parsing, comparison, and normalization utilities using semver, including handling of dev versions, prereleases, and build-time suffixes.
Platform Detection
internal/upgrade/platform.go
Detects current OS/architecture, generates platform-specific asset names, validates platform support, and provides supported platform messaging.
GitHub Integration & Download
internal/upgrade/github.go, internal/upgrade/download.go
GitHub Releases API client for fetching releases, checksums, and assets; download utility with retries, progress callbacks, SHA256 verification, and atomic file replacement.
Installation & Upgrade Orchestration
internal/upgrade/install.go, internal/upgrade/upgrade.go, internal/upgrade/cache.go
Binary archive extraction with path-traversal protection, atomic binary replacement with OS-specific strategies, install method detection (binary/Homebrew/Snap/Docker/Go), and 24h-TTL update caching with XDG config paths.
Test Suite
internal/upgrade/upgrade_test.go
Comprehensive tests for version parsing, comparison, platform utilities, checksums, asset lookup, install method detection, and checksum verification.
Dependency Update
go.mod
Marks Masterminds/semver/v3 as direct requirement (removed // indirect annotation).
Frontend Server Integration
internal/service/frontend/server.go, internal/service/frontend/templates.go, internal/service/frontend/templates/base.gohtml
Server retrieves cached update info and injects UpdateAvailable and LatestVersion into template configuration; templates expose these as functions for frontend rendering.
Frontend UI Components
ui/index.html, ui/src/components/UpdateBanner.tsx, ui/src/contexts/ConfigContext.tsx, ui/src/layouts/Layout.tsx
Extended Config type with update fields; new UpdateBanner React component with localStorage-based dismissal; Layout updated to display banner at top of main content.

Sequence Diagrams

sequenceDiagram
    actor User
    participant CLI as CLI Command
    participant GitHub as GitHub API
    participant Download as Download Module
    participant Install as Install Module
    participant Cache as Cache
    participant Result as Result Formatter

    User->>CLI: dagu upgrade [flags]
    CLI->>CLI: Validate self-upgrade capability
    CLI->>GitHub: Fetch latest/target release
    GitHub-->>CLI: Release metadata + assets
    CLI->>GitHub: Fetch checksums
    GitHub-->>CLI: Checksum map
    CLI->>Download: Download binary archive
    Download->>Download: Verify checksum
    Download-->>CLI: Downloaded file
    CLI->>Install: Extract & install binary
    Install->>Install: Atomic replacement with backup
    Install-->>CLI: Install result
    CLI->>Cache: Update cache with results
    Cache-->>CLI: Cache saved
    CLI->>Result: Format upgrade result
    Result-->>User: Display success/details
Loading
sequenceDiagram
    participant Server as Server Init
    participant Cache as Update Cache
    participant Templates as Template Config
    participant Frontend as Frontend JS
    participant Banner as UpdateBanner Component

    Server->>Cache: GetCachedUpdateInfo()
    Cache-->>Server: UpdateAvailable, LatestVersion
    Server->>Templates: Inject into funcsConfig
    Templates->>Frontend: Render updateAvailable, latestVersion
    Frontend-->>Banner: Config with update info
    Banner->>Banner: Check localStorage (dismissed state)
    Banner->>Banner: Render banner if not dismissed
    Banner->>Banner: On dismiss: store to localStorage
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 69.49% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'feat: self-upgrade command' clearly summarizes the main change: adding a new self-upgrade command to the CLI tool, which is the primary objective across all modified files.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch upgrade-command

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🤖 Fix all issues with AI agents
In `@internal/cmd/upgrade.go`:
- Around line 14-21: The -y/yesFlag currently sets Options.Force (used as
skipConfirm), enabling downgrades/reinstalls and bypassing the "already latest"
check; add an explicit "--force" flag wired to Options.Force (e.g., add {name:
"force", usage: "Force reinstall/downgrade", isBool: true} to upgradeFlags) and
change yesFlag to map to a separate "assume yes" behavior (e.g.,
Options.AssumeYes or skipConfirm) so that skipConfirm/assume-yes no longer
bypasses the early-exit "already latest" check; update usages of skipConfirm,
Options.Force, and the early-exit logic in the upgrade flow so only
Options.Force allows reinstall/downgrade and bypasses the latest-version check.

In `@internal/upgrade/cache.go`:
- Around line 108-154: The comment in CheckAndUpdateCache says "Log but don't
fail" but the LoadCache error is discarded; update CheckAndUpdateCache to
actually log the LoadCache error instead of silently setting cache=nil (or
remove the misleading comment). Specifically, inside CheckAndUpdateCache where
it calls LoadCache(), capture the returned err and call the project logger (or
the standard log package) to emit a descriptive message including the error
(e.g., "failed to load upgrade cache: %v") before continuing; reference the
LoadCache, CheckAndUpdateCache, and SaveCache symbols when making the change.

In `@internal/upgrade/download.go`:
- Around line 46-56: Replace the disabled timeout on the Resty HTTP client in
the download logic: instead of SetTimeout(0) on the client created with
resty.New() (the variable named client in internal/upgrade/download.go), set a
reasonable timeout (e.g. 30 minutes) so long-running binary downloads can
complete but the client won’t hang indefinitely; update the call to
SetTimeout(...) accordingly and add the time package import if missing, keeping
the existing SetRetryCount and AddRetryCondition and still using request context
(SetContext(ctx)).

In `@internal/upgrade/install.go`:
- Around line 224-256: The replaceWindowsBinary function fails when the running
binary cannot be renamed on Windows; modify the upgrade flow to perform the
replacement from a separate helper process (or schedule a deferred swap) instead
of attempting an in-process os.Rename. Concretely, change replaceWindowsBinary
(or call sites invoking it) to: write the new binary to a temp target, spawn a
short-lived helper (e.g., "dagu-replacer" or re-invoke the same executable with
a special --replace-mode flag) that waits for the parent PID to exit, then
renames target -> target.old, moves temp -> target, sets perms, and removes
.old; ensure the helper reports errors back (exit code/log) and that the main
process exits after launching the helper when performing an upgrade. Update any
logic that currently assumes immediate in-process replacement to use this
helper/deferred-replace mechanism.
- Around line 78-140: The path-traversal check in extractArchive (using
filepath.Clean + strings.HasPrefix("..") on f.NameInArchive) misses absolute and
Windows drive paths which can escape destDir; update the validation for
f.NameInArchive so you (1) reject absolute paths (use filepath.IsAbs(name)
and/or check filepath.VolumeName(name) != ""), (2) after computing targetPath :=
filepath.Join(destDir, name) verify the final path stays inside destDir by using
filepath.Rel(destDir, targetPath) and ensuring the returned rel path does not
start with ".." or equal ".." (and that no error occurred), and (3) return a
clear error referencing f.NameInArchive on violation; apply these checks before
creating directories or opening files in extractArchive.

In `@internal/upgrade/platform.go`:
- Around line 21-32: The switch on runtime.GOARCH unconditionally sets
p.Arch="armv7" for GOARCH=="arm", causing armv6 devices to get an incompatible
binary; update the branch handling "arm" to inspect runtime.GOARM and set p.Arch
to "armv6" when runtime.GOARM == "6" and "armv7" otherwise (e.g., runtime.GOARM
== "7"), ensuring this logic sits where p.Arch is assigned (in the same switch
that references runtime.GOARCH) and remains consistent with IsSupported() which
lists "armv6" and "armv7".

In `@internal/upgrade/upgrade.go`:
- Around line 103-117: The final "return true, \"\"" after the switch in
CanSelfUpgrade is unreachable; fix by moving the fallback into the switch as a
default case that returns true, "" (or remove the trailing return and add a
default: return true, "" inside the switch), so that
DetectInstallMethod/InstallMethod handling remains exhaustive and there is no
unreachable statement; update the switch in CanSelfUpgrade accordingly
(reference: function CanSelfUpgrade, DetectInstallMethod, InstallMethod* cases).
- Around line 246-252: The UpgradeCheckCache instance saved by SaveCache omits
LastCheck, leaving it as the zero time so IsCacheValid will always fail; set
LastCheck: time.Now() when constructing the UpgradeCheckCache (where
CurrentVersion/LatestVersion/UpdateAvailable are set) and ensure the package
imports time if not already present so the timestamp compiles.

In `@ui/src/layouts/Layout.tsx`:
- Around line 177-180: The page scroll is caused by the outer <main
className="flex-1 overflow-auto"> plus the inner div using h-full; change the
layout to a column flex container and move scrolling into the content pane: make
the <main> a flex column with className including "flex flex-col flex-1 min-h-0"
(so it respects children' min heights), render <UpdateBanner /> first, then
change the inner content div (the one with children) to be the scrollable area
by replacing "p-4 md:p-6 w-full h-full" with "p-4 md:p-6 w-full flex-1 min-h-0
overflow-auto" so the banner remains fixed and only the content pane scrolls.
🧹 Nitpick comments (4)
ui/src/components/UpdateBanner.tsx (1)

33-38: Add compact sizing and explicit focus styles for the dismiss button.

This keeps button sizing consistent and ensures a clear keyboard focus indicator.

🎯 Suggested styling tweaks
-      <button
-        onClick={handleDismiss}
-        className="p-0.5 hover:bg-blue-100 dark:hover:bg-blue-900 rounded"
+      <button
+        type="button"
+        onClick={handleDismiss}
+        className="h-7 w-7 p-0.5 hover:bg-blue-100 dark:hover:bg-blue-900 rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400 dark:focus-visible:ring-blue-600"
As per coding guidelines: Use reduced heights for form elements such as select boxes (`h-7` or smaller), buttons (`h-7` or `h-8`), and inputs with compact padding (`py-0.5` or `py-1`); Provide clear focus indicators (but not intrusive ones) and ensure text remains readable at smaller sizes for accessibility.
internal/upgrade/cache.go (1)

28-46: Consider using XDG_CACHE_HOME instead of XDG_CONFIG_HOME for cache data.

Per the XDG Base Directory specification, cache files should use XDG_CACHE_HOME (defaults to ~/.cache), while XDG_CONFIG_HOME (defaults to ~/.config) is intended for configuration files. Since this is ephemeral cache data, XDG_CACHE_HOME would be more appropriate.

♻️ Suggested fix
 func GetCacheDir() (string, error) {
-	// Use XDG config directory or fallback to ~/.config/dagu
-	configDir := os.Getenv("XDG_CONFIG_HOME")
-	if configDir == "" {
+	// Use XDG cache directory or fallback to ~/.cache/dagu
+	cacheDir := os.Getenv("XDG_CACHE_HOME")
+	if cacheDir == "" {
 		homeDir, err := os.UserHomeDir()
 		if err != nil {
 			return "", fmt.Errorf("failed to get home directory: %w", err)
 		}
-		configDir = filepath.Join(homeDir, ".config")
+		cacheDir = filepath.Join(homeDir, ".cache")
 	}
 
-	cacheDir := filepath.Join(configDir, "dagu")
+	cacheDir = filepath.Join(cacheDir, "dagu")
 	if err := os.MkdirAll(cacheDir, 0750); err != nil {
 		return "", fmt.Errorf("failed to create cache directory: %w", err)
 	}
 
 	return cacheDir, nil
 }
internal/upgrade/github.go (1)

114-138: Tag value is interpolated into URL without encoding.

While NormalizeVersionTag sanitizes the prefix, the tag could theoretically contain URL-unsafe characters. Consider using url.PathEscape for robustness.

♻️ Suggested fix
+import "net/url"

 func (c *GitHubClient) GetRelease(ctx context.Context, tag string) (*Release, error) {
 	// Ensure tag has 'v' prefix
 	tag = NormalizeVersionTag(tag)

 	var release Release
 	resp, err := c.client.R().
 		SetContext(ctx).
 		SetResult(&release).
-		Get(fmt.Sprintf("%s/tags/%s", githubAPIURL, tag))
+		Get(fmt.Sprintf("%s/tags/%s", githubAPIURL, url.PathEscape(tag)))
internal/upgrade/upgrade.go (1)

68-101: Docker detection is limited and could produce false positives.

The /.dockerenv check (line 86) only detects Docker containers but doesn't account for podman (/run/.containerenv), containerd, or other container runtimes. Additionally, this check is independent of the executable path, so any container installation would be detected as Docker regardless of how dagu was actually installed inside the container.

The codebase already demonstrates awareness of these alternatives (e.g., detection constants in internal/runtime/builtin/docker/client.go), so consider adopting a more comprehensive approach.

Comment thread internal/cmd/upgrade.go
Comment thread internal/upgrade/cache.go
Comment thread internal/upgrade/download.go Outdated
Comment thread internal/upgrade/install.go
Comment thread internal/upgrade/install.go
Comment thread internal/upgrade/platform.go
Comment thread internal/upgrade/upgrade.go
Comment thread internal/upgrade/upgrade.go
Comment thread ui/src/layouts/Layout.tsx
@ghansham
Copy link
Copy Markdown

ghansham commented Feb 2, 2026

In airgapped systems, we should have a configuration to switch this off

@yottahmd
Copy link
Copy Markdown
Collaborator Author

yottahmd commented Feb 2, 2026

In airgapped systems, it just does not work silently.

@yottahmd yottahmd merged commit 2197e26 into main Feb 2, 2026
6 checks passed
@yottahmd yottahmd deleted the upgrade-command branch February 2, 2026 15:12
@codecov
Copy link
Copy Markdown

codecov Bot commented Feb 2, 2026

Codecov Report

❌ Patch coverage is 39.84772% with 474 lines in your changes missing coverage. Please review.
✅ Project coverage is 69.21%. Comparing base (46743a4) to head (ace650c).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
internal/upgrade/upgrade.go 18.96% 121 Missing and 20 partials ⚠️
internal/upgrade/install.go 39.10% 42 Missing and 53 partials ⚠️
internal/cmd/upgrade.go 19.81% 88 Missing and 1 partial ⚠️
internal/upgrade/github.go 55.67% 24 Missing and 19 partials ⚠️
internal/upgrade/cache.go 44.00% 26 Missing and 16 partials ⚠️
internal/upgrade/download.go 69.14% 12 Missing and 17 partials ⚠️
internal/upgrade/platform.go 43.47% 17 Missing and 9 partials ⚠️
internal/upgrade/version.go 72.72% 0 Missing and 9 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #1623      +/-   ##
==========================================
- Coverage   69.95%   69.21%   -0.75%     
==========================================
  Files         313      321       +8     
  Lines       36274    37090     +816     
==========================================
+ Hits        25376    25671     +295     
- Misses       8883     9230     +347     
- Partials     2015     2189     +174     
Files with missing lines Coverage Δ
cmd/main.go 88.46% <100.00%> (+0.46%) ⬆️
internal/cmd/version.go 81.81% <100.00%> (-18.19%) ⬇️
internal/upgrade/version.go 55.81% <72.72%> (ø)
internal/upgrade/platform.go 43.47% <43.47%> (ø)
internal/upgrade/download.go 69.14% <69.14%> (ø)
internal/upgrade/cache.go 44.00% <44.00%> (ø)
internal/upgrade/github.go 52.94% <55.67%> (ø)
internal/cmd/upgrade.go 19.81% <19.81%> (ø)
internal/upgrade/install.go 39.10% <39.10%> (ø)
internal/upgrade/upgrade.go 18.96% <18.96%> (ø)

... and 12 files with indirect coverage changes


Continue to review full report in Codecov by Sentry.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 46743a4...ace650c. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@ghansham
Copy link
Copy Markdown

ghansham commented Feb 2, 2026

Will it attempt and then timeout?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants