Skip to content
Open
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
38 changes: 38 additions & 0 deletions doc/ovhcloud_upgrade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
## ovhcloud upgrade

Upgrade OVHcloud CLI to the latest version

```
ovhcloud upgrade [flags]
```

### Options

```
-h, --help help for upgrade
-y, --yes Skip confirmation prompt
```

### Options inherited from parent commands

```
-d, --debug Activate debug mode (will log all HTTP requests details)
-e, --ignore-errors Ignore errors in API calls when it is not fatal to the execution
-o, --output string Output format: json, yaml, interactive, or a custom format expression (using https://github.com/PaesslerAG/gval syntax)
Examples:
--output json
--output yaml
--output interactive
--output 'id' (to extract a single field)
--output 'nested.field.subfield' (to extract a nested field)
--output '[id, "name"]' (to extract multiple fields as an array)
--output '{"newKey": oldKey, "otherKey": nested.field}' (to extract and rename fields in an object)
--output 'name+","+type' (to extract and concatenate fields in a string)
--output '(nbFieldA + nbFieldB) * 10' (to compute values from numeric fields)
--profile string Use a specific profile from the configuration file
```

### SEE ALSO

* [ovhcloud](ovhcloud.md) - CLI to manage your OVHcloud services

26 changes: 9 additions & 17 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@
package cmd

import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"runtime"
"sync/atomic"
"time"

"github.com/spf13/cobra"
"github.com/spf13/pflag"
Expand All @@ -21,6 +22,7 @@ import (
"github.com/ovh/ovhcloud-cli/internal/display"
"github.com/ovh/ovhcloud-cli/internal/flags"
httplib "github.com/ovh/ovhcloud-cli/internal/http"
"github.com/ovh/ovhcloud-cli/internal/upgrade"
"github.com/ovh/ovhcloud-cli/internal/version"
)

Expand All @@ -45,6 +47,7 @@ var (
wasmHiddenCommands = []string{
"login",
"config",
"upgrade",
}
)

Expand Down Expand Up @@ -152,25 +155,14 @@ Examples:
return
}

const latestURL = "https://github.com/ovh/ovhcloud-cli/releases/latest"
req, err := http.NewRequest("GET", latestURL, nil)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
tag, err := upgrade.LatestTag(ctx)
if err != nil {
return
}
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
var data struct {
TagName string `json:"tag_name"`
}
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return
}
if data.TagName != "" && data.TagName != version.Version {
message := fmt.Sprintf("A new version of ovhcloud-cli is available: %s (current: %s)", data.TagName, version.Version)
if tag != "" && tag != version.Version {
message := fmt.Sprintf("A new version of ovhcloud-cli is available: %s (current: %s). Run `ovhcloud upgrade` to update.", tag, version.Version)
newVersionMessage.Store(&message)
}
}()
Expand Down
115 changes: 115 additions & 0 deletions internal/cmd/upgrade.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// SPDX-FileCopyrightText: 2025 OVH SAS <opensource@ovh.net>
//
// SPDX-License-Identifier: Apache-2.0

package cmd

import (
"bufio"
"context"
"errors"
"fmt"
"os"
"runtime"
"strings"
"time"

"github.com/spf13/cobra"

"github.com/ovh/ovhcloud-cli/internal/upgrade"
"github.com/ovh/ovhcloud-cli/internal/version"
)

const installDocURL = "https://github.com/ovh/ovhcloud-cli#installation"

// wrapPermissionError augments permission-denied errors with a suggestion to
// retry with elevated privileges or reinstall following the documentation.
func wrapPermissionError(err error) error {
if err == nil {
return nil
}
if errors.Is(err, os.ErrPermission) {
return fmt.Errorf("%w\n\nRetry with sudo, or reinstall following the documentation: %s", err, installDocURL)
}
return err
}

var upgradeAssumeYes bool

func init() {
if runtime.GOARCH == "wasm" && runtime.GOOS == "js" {
return
}

upgradeCmd := &cobra.Command{
Use: "upgrade",
Short: "Upgrade OVHcloud CLI to the latest version",
RunE: runUpgrade,
}
upgradeCmd.Flags().BoolVarP(&upgradeAssumeYes, "yes", "y", false, "Skip confirmation prompt")
// Skip parent PersistentPreRun (no API client needed).
upgradeCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {}

rootCmd.AddCommand(upgradeCmd)
}

func runUpgrade(cmd *cobra.Command, _ []string) error {
if version.Version == "undefined" {
return errors.New("upgrade is not available in development builds")
}

ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
defer cancel()

tag, err := upgrade.LatestTag(ctx)
if err != nil {
return err
}

if tag == version.Version {
fmt.Fprintf(cmd.OutOrStdout(), "Already on latest (%s)\n", tag)
return nil
}

method := upgrade.DetectInstallMethod()
switch method {
case upgrade.MethodBrew:
fmt.Fprintf(cmd.OutOrStdout(), "Upgrade via Homebrew:\n\n brew upgrade --cask ovh/tap/ovhcloud-cli\n\n")
return nil
case upgrade.MethodGoInstall:
fmt.Fprintf(cmd.OutOrStdout(), "Upgrade via go install:\n\n go install github.com/ovh/ovhcloud-cli/cmd/ovhcloud@latest\n\n")
return nil
}

if runtime.GOOS == "windows" {
fmt.Fprintf(cmd.OutOrStdout(), "Automatic upgrade on Windows is not supported. Download the latest release from:\n\n https://github.com/ovh/ovhcloud-cli/releases/tag/%s\n\n", tag)
return nil
}

exe, err := upgrade.ResolveExecutable()
if err != nil {
return err
}

if err := upgrade.CheckWritable(exe); err != nil {
return wrapPermissionError(err)
}

if !upgradeAssumeYes {
fmt.Fprintf(cmd.OutOrStdout(), "Replace %s (%s) with %s? [y/N] ", exe, version.Version, tag)
reader := bufio.NewReader(cmd.InOrStdin())
line, _ := reader.ReadString('\n')
answer := strings.TrimSpace(strings.ToLower(line))
if answer != "y" && answer != "yes" {
fmt.Fprintln(cmd.OutOrStdout(), "Aborted.")
return nil
}
}

fmt.Fprintf(cmd.OutOrStdout(), "Downloading %s...\n", tag)
if err := upgrade.SelfReplace(ctx, tag, exe); err != nil {
return wrapPermissionError(err)
}
fmt.Fprintf(cmd.OutOrStdout(), "Installed %s at %s\n", tag, exe)
return nil
}
43 changes: 43 additions & 0 deletions internal/upgrade/detect.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// SPDX-FileCopyrightText: 2025 OVH SAS <opensource@ovh.net>
//
// SPDX-License-Identifier: Apache-2.0

package upgrade

import (
"os"
"path/filepath"
"strings"
)

// DetectInstallMethod inspects the running binary's path and environment to
// guess how the CLI was installed.
func DetectInstallMethod() Method {
exePath, err := os.Executable()
if err != nil {
return MethodBinary
}
if resolved, err := filepath.EvalSymlinks(exePath); err == nil {
exePath = resolved
}
return detect(exePath, os.Getenv("GOBIN"), os.Getenv("GOPATH"), os.Getenv("HOME"))
}

func detect(exePath, gobin, gopath, home string) Method {
if strings.Contains(exePath, "/Cellar/") || strings.Contains(exePath, "/Caskroom/") {
return MethodBrew
}

dir := filepath.Dir(exePath)
if gobin != "" && dir == gobin {
return MethodGoInstall
}
if gopath != "" && dir == filepath.Join(gopath, "bin") {
return MethodGoInstall
}
if home != "" && dir == filepath.Join(home, "go", "bin") {
return MethodGoInstall
}

return MethodBinary
}
72 changes: 72 additions & 0 deletions internal/upgrade/detect_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// SPDX-FileCopyrightText: 2025 OVH SAS <opensource@ovh.net>
//
// SPDX-License-Identifier: Apache-2.0

package upgrade

import "testing"

func TestDetect(t *testing.T) {
cases := []struct {
name string
exePath string
gobin string
gopath string
home string
want Method
}{
{
name: "homebrew cellar",
exePath: "/opt/homebrew/Cellar/ovhcloud-cli/1.2.3/bin/ovhcloud",
home: "/Users/alice",
want: MethodBrew,
},
{
name: "homebrew caskroom",
exePath: "/usr/local/Caskroom/ovhcloud-cli/1.2.3/ovhcloud",
home: "/Users/alice",
want: MethodBrew,
},
{
name: "go install via GOBIN",
exePath: "/home/alice/go-bin/ovhcloud",
gobin: "/home/alice/go-bin",
home: "/home/alice",
want: MethodGoInstall,
},
{
name: "go install via GOPATH",
exePath: "/home/alice/gopath/bin/ovhcloud",
gopath: "/home/alice/gopath",
home: "/home/alice",
want: MethodGoInstall,
},
{
name: "go install via default $HOME/go/bin",
exePath: "/home/alice/go/bin/ovhcloud",
home: "/home/alice",
want: MethodGoInstall,
},
{
name: "install.sh in ~/.local/bin",
exePath: "/home/alice/.local/bin/ovhcloud",
home: "/home/alice",
want: MethodBinary,
},
{
name: "manual /usr/local/bin",
exePath: "/usr/local/bin/ovhcloud",
home: "/home/alice",
want: MethodBinary,
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := detect(tc.exePath, tc.gobin, tc.gopath, tc.home)
if got != tc.want {
t.Fatalf("detect(%q) = %v, want %v", tc.exePath, got, tc.want)
}
})
}
}
Loading
Loading