Skip to content

Commit 93261ff

Browse files
authored
Add auto-upgrade with settings toggle (#2)
Auto-upgrade runs after every command (1h cooldown), silently downloading and replacing the binary. Takes effect on next invocation. Skipped for local dev builds (dirty/dev versions) so developers can test without the binary being overwritten. Users can disable: vctl settings set auto-upgrade off View current: vctl settings Also refactored upgrade.go to share fetch/download helpers from the update package instead of duplicating them.
1 parent 8d44aa2 commit 93261ff

5 files changed

Lines changed: 257 additions & 75 deletions

File tree

cmd/root.go

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import (
77
"github.com/smallest-inc/velocity-cli/internal/api"
88
"github.com/smallest-inc/velocity-cli/internal/config"
99
"github.com/smallest-inc/velocity-cli/internal/update"
10-
"github.com/smallest-inc/velocity-cli/internal/ui"
1110
"github.com/spf13/cobra"
1211
)
1312

@@ -83,14 +82,14 @@ var rootCmd = &cobra.Command{
8382
return
8483
}
8584

86-
// Show cached update notification (local file read, zero latency)
87-
if msg := update.Notify(version); msg != "" {
88-
fmt.Println()
89-
ui.Info(msg)
85+
// Auto-upgrade: check for new version and silently upgrade after
86+
// the user's command completes. Respects a 1h cooldown between checks.
87+
// Skipped for dirty/dev builds and when disabled via `vctl settings set auto-upgrade off`.
88+
autoUpgradeEnabled := true
89+
if cfg != nil {
90+
autoUpgradeEnabled = cfg.IsAutoUpgradeEnabled()
9091
}
91-
92-
// Kick off background check for next time (non-blocking)
93-
update.CheckInBackground(version)
92+
update.AutoUpgrade(version, Quiet, autoUpgradeEnabled)
9493
},
9594
SilenceUsage: true,
9695
SilenceErrors: true,

cmd/settings.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/smallest-inc/velocity-cli/internal/config"
8+
"github.com/smallest-inc/velocity-cli/internal/ui"
9+
"github.com/spf13/cobra"
10+
)
11+
12+
var settingsCmd = &cobra.Command{
13+
Use: "settings",
14+
Short: "View and manage CLI settings",
15+
RunE: func(cmd *cobra.Command, args []string) error {
16+
c, err := config.Load()
17+
if err != nil {
18+
return err
19+
}
20+
21+
autoUpgrade := "on (default)"
22+
if c.AutoUpgrade != nil {
23+
if *c.AutoUpgrade {
24+
autoUpgrade = "on"
25+
} else {
26+
autoUpgrade = "off"
27+
}
28+
}
29+
30+
fmt.Printf(" auto-upgrade: %s\n", autoUpgrade)
31+
return nil
32+
},
33+
}
34+
35+
var settingsSetCmd = &cobra.Command{
36+
Use: "set <key> <value>",
37+
Short: "Set a CLI setting",
38+
Long: `Set a CLI setting. Available settings:
39+
40+
auto-upgrade on|off Auto-upgrade vctl after each command (default: on)`,
41+
Args: cobra.ExactArgs(2),
42+
RunE: func(cmd *cobra.Command, args []string) error {
43+
key := args[0]
44+
value := strings.ToLower(args[1])
45+
46+
c, err := config.Load()
47+
if err != nil {
48+
return err
49+
}
50+
51+
switch key {
52+
case "auto-upgrade":
53+
switch value {
54+
case "on", "true", "1":
55+
v := true
56+
c.AutoUpgrade = &v
57+
case "off", "false", "0":
58+
v := false
59+
c.AutoUpgrade = &v
60+
default:
61+
return fmt.Errorf("invalid value %q for auto-upgrade (use on/off)", value)
62+
}
63+
default:
64+
return fmt.Errorf("unknown setting %q (available: auto-upgrade)", key)
65+
}
66+
67+
if err := config.Save(c); err != nil {
68+
return fmt.Errorf("failed to save settings: %w", err)
69+
}
70+
71+
ui.Success(fmt.Sprintf("%s = %s", key, value))
72+
return nil
73+
},
74+
}
75+
76+
func init() {
77+
settingsCmd.AddCommand(settingsSetCmd)
78+
rootCmd.AddCommand(settingsCmd)
79+
}

cmd/upgrade.go

Lines changed: 6 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package cmd
33
import (
44
"archive/tar"
55
"compress/gzip"
6-
"encoding/json"
76
"fmt"
87
"io"
98
"net/http"
@@ -12,45 +11,22 @@ import (
1211
"strings"
1312

1413
"github.com/smallest-inc/velocity-cli/internal/ui"
14+
"github.com/smallest-inc/velocity-cli/internal/update"
1515
"github.com/spf13/cobra"
1616
)
1717

18-
const (
19-
releaseAPI = "https://api.github.com/repos/smallest-inc/velocity-cli/releases/latest"
20-
)
21-
22-
type githubRelease struct {
23-
TagName string `json:"tag_name"`
24-
Assets []githubAsset `json:"assets"`
25-
}
26-
27-
type githubAsset struct {
28-
Name string `json:"name"`
29-
BrowserDownloadURL string `json:"browser_download_url"`
30-
}
31-
3218
var upgradeCmd = &cobra.Command{
3319
Use: "upgrade",
3420
Short: "Upgrade vctl to the latest version",
3521
RunE: func(cmd *cobra.Command, args []string) error {
3622
ui.Step(Verbose, "Checking for latest release")
3723
stop := ui.Spinner("Checking for updates")
3824

39-
resp, err := http.Get(releaseAPI)
25+
release, err := update.FetchLatestRelease()
4026
stop()
4127
if err != nil {
4228
return fmt.Errorf("failed to check for updates: %w", err)
4329
}
44-
defer resp.Body.Close()
45-
46-
if resp.StatusCode != 200 {
47-
return fmt.Errorf("failed to check for updates (HTTP %d)", resp.StatusCode)
48-
}
49-
50-
var release githubRelease
51-
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
52-
return fmt.Errorf("failed to parse release info: %w", err)
53-
}
5430

5531
latestVersion := strings.TrimPrefix(release.TagName, "v")
5632
if latestVersion == version {
@@ -60,16 +36,15 @@ var upgradeCmd = &cobra.Command{
6036

6137
ui.Info(fmt.Sprintf("Current: %s → Latest: %s", version, latestVersion))
6238

63-
// Find matching asset
64-
targetAsset := findAsset(release.Assets)
65-
if targetAsset == nil {
39+
asset := update.FindAsset(release.Assets)
40+
if asset == nil {
6641
return fmt.Errorf("no release binary for %s/%s", runtime.GOOS, runtime.GOARCH)
6742
}
6843

69-
ui.Step(Verbose, fmt.Sprintf("Downloading %s", targetAsset.Name))
44+
ui.Step(Verbose, fmt.Sprintf("Downloading %s", asset.Name))
7045
stop = ui.Spinner(fmt.Sprintf("Downloading %s", latestVersion))
7146

72-
binResp, err := http.Get(targetAsset.BrowserDownloadURL)
47+
binResp, err := http.Get(asset.BrowserDownloadURL)
7348
stop()
7449
if err != nil {
7550
return fmt.Errorf("failed to download: %w", err)
@@ -80,21 +55,18 @@ var upgradeCmd = &cobra.Command{
8055
return fmt.Errorf("download failed (HTTP %d)", binResp.StatusCode)
8156
}
8257

83-
// Extract binary from tar.gz
8458
newBinary, err := extractBinary(binResp.Body)
8559
if err != nil {
8660
return fmt.Errorf("failed to extract binary: %w", err)
8761
}
8862

89-
// Replace current binary
9063
execPath, err := os.Executable()
9164
if err != nil {
9265
return fmt.Errorf("failed to find current binary: %w", err)
9366
}
9467

9568
ui.Step(Verbose, fmt.Sprintf("Replacing %s", execPath))
9669

97-
// Write to temp file, then rename (atomic on same filesystem)
9870
tmpPath := execPath + ".new"
9971
if err := os.WriteFile(tmpPath, newBinary, 0755); err != nil {
10072
return fmt.Errorf("failed to write new binary: %w", err)
@@ -110,18 +82,6 @@ var upgradeCmd = &cobra.Command{
11082
},
11183
}
11284

113-
func findAsset(assets []githubAsset) *githubAsset {
114-
// Match by OS and arch anywhere in the filename
115-
// goreleaser names: vctl_0.1.0_darwin_amd64.tar.gz
116-
osArch := fmt.Sprintf("%s_%s", runtime.GOOS, runtime.GOARCH)
117-
for i := range assets {
118-
if strings.Contains(assets[i].Name, osArch) && strings.HasSuffix(assets[i].Name, ".tar.gz") {
119-
return &assets[i]
120-
}
121-
}
122-
return nil
123-
}
124-
12585
func extractBinary(r io.Reader) ([]byte, error) {
12686
gz, err := gzip.NewReader(r)
12787
if err != nil {

internal/config/config.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,15 @@ type Config struct {
2121
ProjectDisplayName string `yaml:"project_display_name,omitempty"`
2222
InstanceID string `yaml:"instance_id,omitempty"`
2323
InstanceName string `yaml:"instance_name,omitempty"`
24+
AutoUpgrade *bool `yaml:"auto_upgrade,omitempty"` // nil = default (on), true = on, false = off
25+
}
26+
27+
// IsAutoUpgradeEnabled returns true if auto-upgrade is enabled (default: true).
28+
func (c *Config) IsAutoUpgradeEnabled() bool {
29+
if c.AutoUpgrade == nil {
30+
return true // default on
31+
}
32+
return *c.AutoUpgrade
2433
}
2534

2635
// Credentials stores authentication tokens.

0 commit comments

Comments
 (0)