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
21 changes: 19 additions & 2 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
sdklog "github.com/conductor-sdk/conductor-go/sdk/log"
"github.com/conductor-sdk/conductor-go/sdk/settings"
"github.com/orkes-io/conductor-cli/internal"
"github.com/orkes-io/conductor-cli/internal/updater"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/spf13/viper"
Expand All @@ -15,6 +16,7 @@ import (
"net"
"net/http"
"os"
"path/filepath"
"time"
)

Expand Down Expand Up @@ -48,6 +50,19 @@ var rootCmd = &cobra.Command{
if verbose {
log.SetLevel(log.DebugLevel)
}

// Check for updates in background (non-blocking)
// Skip update check for the update command itself
if cmd.Name() != "update" {
updater.CheckInBackground(cmd.Context(), Version)

// Show notification if update is available
if shouldNotify, latestVersion := updater.ShouldNotifyUpdate(Version); shouldNotify {
fmt.Fprintf(os.Stderr, "\n⚠ A new version is available: %s (current: %s)\n", latestVersion, Version)
fmt.Fprintf(os.Stderr, "Run 'orkes update' to download it or update with your package manager.\n\n")
}
}

// Get configuration values from Viper (which handles flags, env vars, and config file)
url = viper.GetString("server")
key = viper.GetString("auth-key")
Expand Down Expand Up @@ -115,9 +130,11 @@ func initConfig() {
home, err := os.UserHomeDir()
cobra.CheckErr(err)

viper.AddConfigPath(home)
// Use config directory structure: ~/.conductor-cli/config.yaml
configDir := filepath.Join(home, ".conductor-cli")
viper.AddConfigPath(configDir)
viper.SetConfigType("yaml")
viper.SetConfigName(".conductor-cli")
viper.SetConfigName("config")
}

// Environment variable mapping
Expand Down
85 changes: 85 additions & 0 deletions cmd/update.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package cmd

import (
"bytes"
"fmt"
"os"
"runtime"

goupdater "github.com/inconshreveable/go-update"
"github.com/orkes-io/conductor-cli/internal/updater"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
)

var updateCmd = &cobra.Command{
Use: "update",
Short: "Update the CLI to the latest version",
Long: "Download and install the latest version of the Conductor CLI from GitHub releases.",
RunE: runUpdate,
}

func init() {
rootCmd.AddCommand(updateCmd)
}

func runUpdate(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()

fmt.Println("Checking for updates...")

// Check for latest version
updateInfo, err := updater.CheckForUpdate(ctx, Version)
if err != nil {
return fmt.Errorf("failed to check for updates: %w", err)
}

// Compare versions
if updater.CompareVersions(updateInfo.LatestVersion, Version) <= 0 {
fmt.Printf("✓ Already on the latest version: %s\n", Version)
return nil
}

fmt.Printf("Update available: %s → %s\n", Version, updateInfo.LatestVersion)

// Check if we have a download URL for this platform
if updateInfo.DownloadURL == "" {
fmt.Printf("\nNo pre-built binary available for %s/%s\n", runtime.GOOS, runtime.GOARCH)
fmt.Printf("Please download manually from: %s\n", updateInfo.ReleaseURL)
return nil
}

fmt.Printf("Downloading from: %s\n", updateInfo.DownloadURL)

// Download the binary
binaryData, err := updater.DownloadBinary(ctx, updateInfo.DownloadURL)
if err != nil {
return fmt.Errorf("failed to download binary: %w", err)
}

fmt.Printf("Downloaded %d bytes\n", len(binaryData))

// Apply the update (replace current binary)
fmt.Println("Applying update...")
err = goupdater.Apply(bytes.NewReader(binaryData), goupdater.Options{})
if err != nil {
// Check if it's a permissions error
if os.IsPermission(err) {
return fmt.Errorf("permission denied: try running with elevated privileges (sudo)")
}
return fmt.Errorf("failed to apply update: %w", err)
}

// Update the state file with the new version
state := &updater.UpdateState{
LatestVersion: updateInfo.LatestVersion,
}
if err := state.Save(); err != nil {
log.Warnf("Failed to update state file: %v", err)
}

fmt.Printf("✓ Successfully updated to %s\n", updateInfo.LatestVersion)
fmt.Println("\nPlease restart your terminal or run the command again to use the new version.")

return nil
}
4 changes: 2 additions & 2 deletions cmd/webhook_metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ var (
updateWebHookMetadataCmd = &cobra.Command{
Use: "update <webhook_id>",
Short: "Update Webhook",
RunE: update,
RunE: updateWebhook,
SilenceUsage: true,
Example: "webhook update <webhook_id> --file webhook.json",
}
Expand Down Expand Up @@ -195,7 +195,7 @@ func create(cmd *cobra.Command, args []string) error {
return nil
}

func update(cmd *cobra.Command, args []string) error {
func updateWebhook(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return cmd.Usage()
}
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ require (
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w=
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf h1:WfD7VjIE6z8dIvMsI4/s+1qr5EL+zoIGev1BQj1eoJ8=
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
Expand Down
175 changes: 175 additions & 0 deletions internal/updater/checker.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package updater

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"runtime"
"time"
)

const (
githubRepo = "conductor-oss/conductor-cli"
githubAPITimeout = 10 * time.Second
releasesAPIURL = "https://api.github.com/repos/" + githubRepo + "/releases/latest"
)

// UpdateInfo contains information about an available update
type UpdateInfo struct {
LatestVersion string
CurrentVersion string
DownloadURL string
ReleaseURL string
}

// GitHubRelease represents the GitHub API response for a release
type GitHubRelease struct {
TagName string `json:"tag_name"`
Assets []struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
} `json:"assets"`
HTMLURL string `json:"html_url"`
}

// CheckForUpdate checks GitHub for the latest release
func CheckForUpdate(ctx context.Context, currentVersion string) (*UpdateInfo, error) {
ctx, cancel := context.WithTimeout(ctx, githubAPITimeout)
defer cancel()

req, err := http.NewRequestWithContext(ctx, "GET", releasesAPIURL, nil)
if err != nil {
return nil, err
}

// Set User-Agent header (GitHub API requirement)
req.Header.Set("User-Agent", "conductor-cli")
req.Header.Set("Accept", "application/vnd.github.v3+json")

client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("github api returned status %d", resp.StatusCode)
}

body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}

var release GitHubRelease
if err := json.Unmarshal(body, &release); err != nil {
return nil, err
}

// Find the appropriate asset for current platform
assetName := getAssetName()
downloadURL := ""
for _, asset := range release.Assets {
if asset.Name == assetName {
downloadURL = asset.BrowserDownloadURL
break
}
}

return &UpdateInfo{
LatestVersion: release.TagName,
CurrentVersion: currentVersion,
DownloadURL: downloadURL,
ReleaseURL: release.HTMLURL,
}, nil
}

// CheckInBackground performs a background check and updates the state file
func CheckInBackground(ctx context.Context, currentVersion string) {
go func() {
state, err := LoadState()
if err != nil {
// Silently fail - don't block CLI
return
}

if !state.ShouldCheck() {
return
}

updateInfo, err := CheckForUpdate(ctx, currentVersion)
if err != nil {
// Silently fail - don't block CLI or spam errors
// Just update the timestamp so we don't retry immediately
state.LastCheck = time.Now()
_ = state.Save()
return
}

// Update state
state.LastCheck = time.Now()
state.LatestVersion = updateInfo.LatestVersion
_ = state.Save()
}()
}

// ShouldNotifyUpdate checks if we should notify the user about an update
func ShouldNotifyUpdate(currentVersion string) (bool, string) {
state, err := LoadState()
if err != nil {
return false, ""
}

if state.HasUpdate(currentVersion) {
return true, state.LatestVersion
}

return false, ""
}

// getAssetName returns the expected asset name for the current platform
func getAssetName() string {
// Format: orkes_<os>_<arch> or orkes_<os>_<arch>.exe for windows
osName := runtime.GOOS
archName := runtime.GOARCH

// Map Go arch names to common naming conventions
switch archName {
case "amd64":
archName = "x86_64"
case "arm64":
archName = "arm64"
}

assetName := fmt.Sprintf("orkes_%s_%s", osName, archName)

if runtime.GOOS == "windows" {
assetName += ".exe"
}

return assetName
}

// DownloadBinary downloads the binary from the given URL
func DownloadBinary(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}

client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("download failed with status %d", resp.StatusCode)
}

return io.ReadAll(resp.Body)
}
Loading
Loading