Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,6 @@ changelog:
- "^test:"

release:
draft: true
draft: false
prerelease: auto
name_template: "Flashduty CLI {{.Version}}"
40 changes: 40 additions & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ import (

flashduty "github.com/flashcatcloud/flashduty-sdk"
"github.com/spf13/cobra"
"golang.org/x/term"

"github.com/flashcatcloud/flashduty-cli/internal/config"
"github.com/flashcatcloud/flashduty-cli/internal/output"
"github.com/flashcatcloud/flashduty-cli/internal/update"
)

// flashdutyClient defines the SDK operations used by CLI commands.
Expand Down Expand Up @@ -86,12 +88,48 @@ var (
flagBaseURL string
)

var updateResultCh chan *update.CheckResult

var rootCmd = &cobra.Command{
Use: "flashduty",
Short: "Flashduty CLI - incident management from your terminal",
Long: "Flashduty CLI - incident management from your terminal.\n\nGet started by running 'flashduty login' to authenticate.",
SilenceUsage: true,
SilenceErrors: true,
PersistentPreRun: func(cmd *cobra.Command, _ []string) {
path := cmd.CommandPath()
if path == "flashduty update" || path == "flashduty version" {
return
}
if !update.ShouldCheck(versionStr) {
return
}
if !term.IsTerminal(int(os.Stderr.Fd())) {
return
}
updateResultCh = make(chan *update.CheckResult, 1)
go func() {
result, err := update.CheckForUpdate(versionStr)
if err != nil {
return
}
updateResultCh <- result
}()
},
PersistentPostRun: func(_ *cobra.Command, _ []string) {
if updateResultCh == nil {
return
}
select {
case result := <-updateResultCh:
if result != nil && result.UpdateAvailable {
fmt.Fprintf(os.Stderr, "\nA new version of flashduty is available: v%s -> %s\n",
update.StripV(result.CurrentVersion), result.LatestVersion)
fmt.Fprintf(os.Stderr, "To update, run: flashduty update\n")
}
default:
}
},
}

func init() {
Expand Down Expand Up @@ -125,6 +163,8 @@ func init() {
// Phase 3
rootCmd.AddCommand(newInsightCmd())
rootCmd.AddCommand(newAuditCmd())

rootCmd.AddCommand(newUpdateCmd())
}

// Execute runs the root command.
Expand Down
72 changes: 72 additions & 0 deletions internal/cli/update.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package cli

import (
"fmt"
"os"
"os/exec"
"runtime"

"github.com/spf13/cobra"

"github.com/flashcatcloud/flashduty-cli/internal/update"
)

func newUpdateCmd() *cobra.Command {
var flagCheck bool

cmd := &cobra.Command{
Use: "update",
Short: "Update flashduty to the latest version",
RunE: func(cmd *cobra.Command, _ []string) error {
w := cmd.OutOrStdout()
_, _ = fmt.Fprintf(w, "Current version: %s\n", versionStr)
_, _ = fmt.Fprintf(w, "Checking for updates...\n")

result, err := update.CheckForUpdate(versionStr)
if err != nil {
return fmt.Errorf("failed to check for updates: %w", err)
}

if !result.UpdateAvailable {
_, _ = fmt.Fprintf(w, "Already up to date (%s).\n", versionStr)
return nil
}

_, _ = fmt.Fprintf(w, "A new version is available: v%s -> %s\n",
update.StripV(versionStr), result.LatestVersion)
_, _ = fmt.Fprintf(w, "Release: %s\n", result.LatestURL)

if flagCheck {
return nil
}

_, _ = fmt.Fprintf(w, "\nUpdating...\n")
return runInstaller(cmd)
},
}

cmd.Flags().BoolVar(&flagCheck, "check", false, "Only check for updates, do not install")
return cmd
}

func runInstaller(cmd *cobra.Command) error {
var c *exec.Cmd
if runtime.GOOS == "windows" {
c = exec.Command("powershell", "-Command",
fmt.Sprintf("irm %s | iex", update.InstallPowerShellURL()))
} else {
c = exec.Command("sh", "-c",
fmt.Sprintf("curl -fsSL %s | sh", update.InstallShellURL()))
}

c.Stdout = cmd.OutOrStdout()
c.Stderr = cmd.ErrOrStderr()
c.Stdin = os.Stdin

if err := c.Run(); err != nil {
return fmt.Errorf("update failed: %w", err)
}

_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nUpdate complete. Run 'flashduty version' to verify.\n")
return nil
}
199 changes: 199 additions & 0 deletions internal/update/check.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
package update

import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"

"gopkg.in/yaml.v3"
)

const (
repoOwner = "flashcatcloud"
repoName = "flashduty-cli"
checkInterval = 24 * time.Hour
httpTimeout = 5 * time.Second
stateFileName = "state.yaml"
installShURL = "https://raw.githubusercontent.com/" + repoOwner + "/" + repoName + "/main/install.sh"
installPs1URL = "https://raw.githubusercontent.com/" + repoOwner + "/" + repoName + "/main/install.ps1"
maxResponseBytes = 1 << 20 // 1MB
)

var apiURL = "https://api.github.com/repos/" + repoOwner + "/" + repoName + "/releases/latest"

type State struct {
CheckedAt time.Time `yaml:"checked_at"`
LatestVersion string `yaml:"latest_version"`
LatestURL string `yaml:"latest_url"`
}

type CheckResult struct {
CurrentVersion string
LatestVersion string
LatestURL string
UpdateAvailable bool
}

type githubRelease struct {
TagName string `json:"tag_name"`
HTMLURL string `json:"html_url"`
}

func InstallShellURL() string { return installShURL }
func InstallPowerShellURL() string { return installPs1URL }

func stateDir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to determine home directory: %w", err)
}
return filepath.Join(home, ".flashduty"), nil
}

func statePath() (string, error) {
dir, err := stateDir()
if err != nil {
return "", err
}
return filepath.Join(dir, stateFileName), nil
}

func loadState() *State {
path, err := statePath()
if err != nil {
return &State{}
}
data, err := os.ReadFile(path)
if err != nil {
return &State{}
}
var s State
if err := yaml.Unmarshal(data, &s); err != nil {
return &State{}
}
return &s
}

func saveState(s *State) error {
dir, err := stateDir()
if err != nil {
return err
}
if err := os.MkdirAll(dir, 0700); err != nil {
return fmt.Errorf("failed to create state directory: %w", err)
}
data, err := yaml.Marshal(s)
if err != nil {
return fmt.Errorf("failed to marshal state: %w", err)
}
path, err := statePath()
if err != nil {
return err
}
return os.WriteFile(path, data, 0600)
}

func fetchLatestVersion() (string, string, error) {
client := &http.Client{Timeout: httpTimeout}
resp, err := client.Get(apiURL)
if err != nil {
return "", "", fmt.Errorf("failed to fetch latest release: %w", err)
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusOK {
return "", "", fmt.Errorf("GitHub API returned %d", resp.StatusCode)
}

var rel githubRelease
if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBytes)).Decode(&rel); err != nil {
return "", "", fmt.Errorf("failed to parse release response: %w", err)
}
if rel.TagName == "" {
return "", "", fmt.Errorf("empty tag_name in response")
}
return rel.TagName, rel.HTMLURL, nil
}

func StripV(v string) string {
return strings.TrimPrefix(v, "v")
}

// stripPreRelease removes pre-release suffix (e.g. "1.0.0-rc1" -> "1.0.0").
func stripPreRelease(v string) string {
if base, _, ok := strings.Cut(v, "-"); ok {
return base
}
return v
}

func compareSemver(a, b string) int {
a = stripPreRelease(a)
b = stripPreRelease(b)
aParts := strings.Split(a, ".")
bParts := strings.Split(b, ".")
maxLen := max(len(aParts), len(bParts))
for i := range maxLen {
var ai, bi int
if i < len(aParts) {
ai, _ = strconv.Atoi(aParts[i])
}
if i < len(bParts) {
bi, _ = strconv.Atoi(bParts[i])
}
if ai != bi {
return ai - bi
}
}
return 0
}

func IsNewer(latestTag, currentVersion string) bool {
latest := StripV(latestTag)
current := StripV(currentVersion)
if latest == current {
return false
}
return compareSemver(latest, current) > 0
}

func ShouldCheck(currentVersion string) bool {
if currentVersion == "dev" || currentVersion == "(devel)" {
return false
}
if os.Getenv("FLASHDUTY_NO_UPDATE_CHECK") == "1" {
return false
}
if os.Getenv("CI") != "" || os.Getenv("GITHUB_ACTIONS") != "" ||
os.Getenv("JENKINS_URL") != "" || os.Getenv("GITLAB_CI") != "" {
return false
}
state := loadState()
return time.Since(state.CheckedAt) >= checkInterval
}

func CheckForUpdate(currentVersion string) (*CheckResult, error) {
tag, url, err := fetchLatestVersion()
if err != nil {
return nil, err
}

_ = saveState(&State{
CheckedAt: time.Now(),
LatestVersion: tag,
LatestURL: url,
})

return &CheckResult{
CurrentVersion: currentVersion,
LatestVersion: tag,
LatestURL: url,
UpdateAvailable: IsNewer(tag, currentVersion),
}, nil
}
Loading
Loading