Skip to content

Commit bc4cd81

Browse files
authored
feat: add update command and passive version-check notification (#6)
* feat: add update command and passive version-check notification Add `flashduty update` to check for and install the latest release, and a background version check that reminds users to update after a 24-hour interval. The update command shells out to the existing install scripts rather than replacing the binary in-process. * fix: resolve golangci-lint errcheck and cross-platform test failures - Add explicit error ignoring for fmt.Fprintf and resp.Body.Close - Clear CI env vars in tests so ShouldCheck doesn't short-circuit on GitHub Actions runners - Set USERPROFILE alongside HOME for Windows test compatibility - Use unambiguously invalid YAML for corrupt state file test
1 parent 78bac36 commit bc4cd81

5 files changed

Lines changed: 620 additions & 1 deletion

File tree

.goreleaser.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,6 @@ changelog:
4040
- "^test:"
4141

4242
release:
43-
draft: true
43+
draft: false
4444
prerelease: auto
4545
name_template: "Flashduty CLI {{.Version}}"

internal/cli/root.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@ import (
99

1010
flashduty "github.com/flashcatcloud/flashduty-sdk"
1111
"github.com/spf13/cobra"
12+
"golang.org/x/term"
1213

1314
"github.com/flashcatcloud/flashduty-cli/internal/config"
1415
"github.com/flashcatcloud/flashduty-cli/internal/output"
16+
"github.com/flashcatcloud/flashduty-cli/internal/update"
1517
)
1618

1719
// flashdutyClient defines the SDK operations used by CLI commands.
@@ -86,12 +88,48 @@ var (
8688
flagBaseURL string
8789
)
8890

91+
var updateResultCh chan *update.CheckResult
92+
8993
var rootCmd = &cobra.Command{
9094
Use: "flashduty",
9195
Short: "Flashduty CLI - incident management from your terminal",
9296
Long: "Flashduty CLI - incident management from your terminal.\n\nGet started by running 'flashduty login' to authenticate.",
9397
SilenceUsage: true,
9498
SilenceErrors: true,
99+
PersistentPreRun: func(cmd *cobra.Command, _ []string) {
100+
path := cmd.CommandPath()
101+
if path == "flashduty update" || path == "flashduty version" {
102+
return
103+
}
104+
if !update.ShouldCheck(versionStr) {
105+
return
106+
}
107+
if !term.IsTerminal(int(os.Stderr.Fd())) {
108+
return
109+
}
110+
updateResultCh = make(chan *update.CheckResult, 1)
111+
go func() {
112+
result, err := update.CheckForUpdate(versionStr)
113+
if err != nil {
114+
return
115+
}
116+
updateResultCh <- result
117+
}()
118+
},
119+
PersistentPostRun: func(_ *cobra.Command, _ []string) {
120+
if updateResultCh == nil {
121+
return
122+
}
123+
select {
124+
case result := <-updateResultCh:
125+
if result != nil && result.UpdateAvailable {
126+
fmt.Fprintf(os.Stderr, "\nA new version of flashduty is available: v%s -> %s\n",
127+
update.StripV(result.CurrentVersion), result.LatestVersion)
128+
fmt.Fprintf(os.Stderr, "To update, run: flashduty update\n")
129+
}
130+
default:
131+
}
132+
},
95133
}
96134

97135
func init() {
@@ -125,6 +163,8 @@ func init() {
125163
// Phase 3
126164
rootCmd.AddCommand(newInsightCmd())
127165
rootCmd.AddCommand(newAuditCmd())
166+
167+
rootCmd.AddCommand(newUpdateCmd())
128168
}
129169

130170
// Execute runs the root command.

internal/cli/update.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"os/exec"
7+
"runtime"
8+
9+
"github.com/spf13/cobra"
10+
11+
"github.com/flashcatcloud/flashduty-cli/internal/update"
12+
)
13+
14+
func newUpdateCmd() *cobra.Command {
15+
var flagCheck bool
16+
17+
cmd := &cobra.Command{
18+
Use: "update",
19+
Short: "Update flashduty to the latest version",
20+
RunE: func(cmd *cobra.Command, _ []string) error {
21+
w := cmd.OutOrStdout()
22+
_, _ = fmt.Fprintf(w, "Current version: %s\n", versionStr)
23+
_, _ = fmt.Fprintf(w, "Checking for updates...\n")
24+
25+
result, err := update.CheckForUpdate(versionStr)
26+
if err != nil {
27+
return fmt.Errorf("failed to check for updates: %w", err)
28+
}
29+
30+
if !result.UpdateAvailable {
31+
_, _ = fmt.Fprintf(w, "Already up to date (%s).\n", versionStr)
32+
return nil
33+
}
34+
35+
_, _ = fmt.Fprintf(w, "A new version is available: v%s -> %s\n",
36+
update.StripV(versionStr), result.LatestVersion)
37+
_, _ = fmt.Fprintf(w, "Release: %s\n", result.LatestURL)
38+
39+
if flagCheck {
40+
return nil
41+
}
42+
43+
_, _ = fmt.Fprintf(w, "\nUpdating...\n")
44+
return runInstaller(cmd)
45+
},
46+
}
47+
48+
cmd.Flags().BoolVar(&flagCheck, "check", false, "Only check for updates, do not install")
49+
return cmd
50+
}
51+
52+
func runInstaller(cmd *cobra.Command) error {
53+
var c *exec.Cmd
54+
if runtime.GOOS == "windows" {
55+
c = exec.Command("powershell", "-Command",
56+
fmt.Sprintf("irm %s | iex", update.InstallPowerShellURL()))
57+
} else {
58+
c = exec.Command("sh", "-c",
59+
fmt.Sprintf("curl -fsSL %s | sh", update.InstallShellURL()))
60+
}
61+
62+
c.Stdout = cmd.OutOrStdout()
63+
c.Stderr = cmd.ErrOrStderr()
64+
c.Stdin = os.Stdin
65+
66+
if err := c.Run(); err != nil {
67+
return fmt.Errorf("update failed: %w", err)
68+
}
69+
70+
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nUpdate complete. Run 'flashduty version' to verify.\n")
71+
return nil
72+
}

internal/update/check.go

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
package update
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
"os"
9+
"path/filepath"
10+
"strconv"
11+
"strings"
12+
"time"
13+
14+
"gopkg.in/yaml.v3"
15+
)
16+
17+
const (
18+
repoOwner = "flashcatcloud"
19+
repoName = "flashduty-cli"
20+
checkInterval = 24 * time.Hour
21+
httpTimeout = 5 * time.Second
22+
stateFileName = "state.yaml"
23+
installShURL = "https://raw.githubusercontent.com/" + repoOwner + "/" + repoName + "/main/install.sh"
24+
installPs1URL = "https://raw.githubusercontent.com/" + repoOwner + "/" + repoName + "/main/install.ps1"
25+
maxResponseBytes = 1 << 20 // 1MB
26+
)
27+
28+
var apiURL = "https://api.github.com/repos/" + repoOwner + "/" + repoName + "/releases/latest"
29+
30+
type State struct {
31+
CheckedAt time.Time `yaml:"checked_at"`
32+
LatestVersion string `yaml:"latest_version"`
33+
LatestURL string `yaml:"latest_url"`
34+
}
35+
36+
type CheckResult struct {
37+
CurrentVersion string
38+
LatestVersion string
39+
LatestURL string
40+
UpdateAvailable bool
41+
}
42+
43+
type githubRelease struct {
44+
TagName string `json:"tag_name"`
45+
HTMLURL string `json:"html_url"`
46+
}
47+
48+
func InstallShellURL() string { return installShURL }
49+
func InstallPowerShellURL() string { return installPs1URL }
50+
51+
func stateDir() (string, error) {
52+
home, err := os.UserHomeDir()
53+
if err != nil {
54+
return "", fmt.Errorf("failed to determine home directory: %w", err)
55+
}
56+
return filepath.Join(home, ".flashduty"), nil
57+
}
58+
59+
func statePath() (string, error) {
60+
dir, err := stateDir()
61+
if err != nil {
62+
return "", err
63+
}
64+
return filepath.Join(dir, stateFileName), nil
65+
}
66+
67+
func loadState() *State {
68+
path, err := statePath()
69+
if err != nil {
70+
return &State{}
71+
}
72+
data, err := os.ReadFile(path)
73+
if err != nil {
74+
return &State{}
75+
}
76+
var s State
77+
if err := yaml.Unmarshal(data, &s); err != nil {
78+
return &State{}
79+
}
80+
return &s
81+
}
82+
83+
func saveState(s *State) error {
84+
dir, err := stateDir()
85+
if err != nil {
86+
return err
87+
}
88+
if err := os.MkdirAll(dir, 0700); err != nil {
89+
return fmt.Errorf("failed to create state directory: %w", err)
90+
}
91+
data, err := yaml.Marshal(s)
92+
if err != nil {
93+
return fmt.Errorf("failed to marshal state: %w", err)
94+
}
95+
path, err := statePath()
96+
if err != nil {
97+
return err
98+
}
99+
return os.WriteFile(path, data, 0600)
100+
}
101+
102+
func fetchLatestVersion() (string, string, error) {
103+
client := &http.Client{Timeout: httpTimeout}
104+
resp, err := client.Get(apiURL)
105+
if err != nil {
106+
return "", "", fmt.Errorf("failed to fetch latest release: %w", err)
107+
}
108+
defer func() { _ = resp.Body.Close() }()
109+
110+
if resp.StatusCode != http.StatusOK {
111+
return "", "", fmt.Errorf("GitHub API returned %d", resp.StatusCode)
112+
}
113+
114+
var rel githubRelease
115+
if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBytes)).Decode(&rel); err != nil {
116+
return "", "", fmt.Errorf("failed to parse release response: %w", err)
117+
}
118+
if rel.TagName == "" {
119+
return "", "", fmt.Errorf("empty tag_name in response")
120+
}
121+
return rel.TagName, rel.HTMLURL, nil
122+
}
123+
124+
func StripV(v string) string {
125+
return strings.TrimPrefix(v, "v")
126+
}
127+
128+
// stripPreRelease removes pre-release suffix (e.g. "1.0.0-rc1" -> "1.0.0").
129+
func stripPreRelease(v string) string {
130+
if base, _, ok := strings.Cut(v, "-"); ok {
131+
return base
132+
}
133+
return v
134+
}
135+
136+
func compareSemver(a, b string) int {
137+
a = stripPreRelease(a)
138+
b = stripPreRelease(b)
139+
aParts := strings.Split(a, ".")
140+
bParts := strings.Split(b, ".")
141+
maxLen := max(len(aParts), len(bParts))
142+
for i := range maxLen {
143+
var ai, bi int
144+
if i < len(aParts) {
145+
ai, _ = strconv.Atoi(aParts[i])
146+
}
147+
if i < len(bParts) {
148+
bi, _ = strconv.Atoi(bParts[i])
149+
}
150+
if ai != bi {
151+
return ai - bi
152+
}
153+
}
154+
return 0
155+
}
156+
157+
func IsNewer(latestTag, currentVersion string) bool {
158+
latest := StripV(latestTag)
159+
current := StripV(currentVersion)
160+
if latest == current {
161+
return false
162+
}
163+
return compareSemver(latest, current) > 0
164+
}
165+
166+
func ShouldCheck(currentVersion string) bool {
167+
if currentVersion == "dev" || currentVersion == "(devel)" {
168+
return false
169+
}
170+
if os.Getenv("FLASHDUTY_NO_UPDATE_CHECK") == "1" {
171+
return false
172+
}
173+
if os.Getenv("CI") != "" || os.Getenv("GITHUB_ACTIONS") != "" ||
174+
os.Getenv("JENKINS_URL") != "" || os.Getenv("GITLAB_CI") != "" {
175+
return false
176+
}
177+
state := loadState()
178+
return time.Since(state.CheckedAt) >= checkInterval
179+
}
180+
181+
func CheckForUpdate(currentVersion string) (*CheckResult, error) {
182+
tag, url, err := fetchLatestVersion()
183+
if err != nil {
184+
return nil, err
185+
}
186+
187+
_ = saveState(&State{
188+
CheckedAt: time.Now(),
189+
LatestVersion: tag,
190+
LatestURL: url,
191+
})
192+
193+
return &CheckResult{
194+
CurrentVersion: currentVersion,
195+
LatestVersion: tag,
196+
LatestURL: url,
197+
UpdateAvailable: IsNewer(tag, currentVersion),
198+
}, nil
199+
}

0 commit comments

Comments
 (0)