Skip to content

Commit a1325aa

Browse files
authored
Merge pull request #3 from HenryOwenz/feature/add-cli-flags-to-upgrade
Add CLI upgrade functionality and improve navigation - Add Cobra-based CLI with upgrade and version commands - Reorganize command structure for better modularity - Fix Lambda function navigation flows - Add comprehensive tests for all new functionality This improves the user experience by adding direct upgrade capabilities and fixing navigation issues, while making the codebase more maintainable through better organization and test coverage.
2 parents 423d178 + ac0131e commit a1325aa

File tree

13 files changed

+581
-16
lines changed

13 files changed

+581
-16
lines changed

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,15 @@ make install # Installs as 'cg' in your $GOPATH/bin
100100
cg # Launch the application
101101
```
102102

103+
### Command Line Options
104+
105+
| Option | Description |
106+
|--------|-------------|
107+
| `cg --upgrade` or `cg -u` | Upgrade cloudgate to the latest version |
108+
| `cg upgrade` | Upgrade cloudgate to the latest version (alternative syntax) |
109+
| `cg --version` or `cg -v` | Display the current version of cloudgate |
110+
| `cg version` | Display the current version of cloudgate (alternative syntax) |
111+
103112
### Navigation
104113

105114
| Key | Action |

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ require (
3030
github.com/charmbracelet/x/ansi v0.8.0 // indirect
3131
github.com/charmbracelet/x/term v0.2.1 // indirect
3232
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
33+
github.com/inconshreveable/mousetrap v1.1.0 // indirect
3334
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
3435
github.com/mattn/go-isatty v0.0.20 // indirect
3536
github.com/mattn/go-localereader v0.0.1 // indirect
@@ -38,6 +39,8 @@ require (
3839
github.com/muesli/cancelreader v0.2.2 // indirect
3940
github.com/muesli/termenv v0.16.0 // indirect
4041
github.com/rivo/uniseg v0.4.7 // indirect
42+
github.com/spf13/cobra v1.9.1 // indirect
43+
github.com/spf13/pflag v1.0.6 // indirect
4144
golang.org/x/sync v0.11.0 // indirect
4245
golang.org/x/sys v0.30.0 // indirect
4346
golang.org/x/text v0.22.0 // indirect

go.sum

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,11 @@ github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAM
5050
github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
5151
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
5252
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
53+
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
5354
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
5455
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
56+
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
57+
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
5558
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
5659
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
5760
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -69,6 +72,11 @@ github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3
6972
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
7073
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
7174
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
75+
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
76+
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
77+
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
78+
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
79+
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
7280
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
7381
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
7482
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -77,3 +85,5 @@ golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
7785
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
7886
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
7987
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
88+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
89+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package commands
2+
3+
import (
4+
"runtime"
5+
"testing"
6+
)
7+
8+
func TestVersionCommand(t *testing.T) {
9+
// Test that the command is properly configured
10+
cmd := NewVersionCmd()
11+
12+
if cmd.Use != "version" {
13+
t.Errorf("Expected command use to be 'version', got '%s'", cmd.Use)
14+
}
15+
16+
if cmd.Short == "" {
17+
t.Error("Command short description should not be empty")
18+
}
19+
20+
if cmd.Long == "" {
21+
t.Error("Command long description should not be empty")
22+
}
23+
24+
if cmd.Run == nil {
25+
t.Error("Command run function should not be nil")
26+
}
27+
}
28+
29+
func TestUpgradeCommand(t *testing.T) {
30+
// Test that the command is properly configured
31+
cmd := NewUpgradeCmd()
32+
33+
if cmd.Use != "upgrade" {
34+
t.Errorf("Expected command use to be 'upgrade', got '%s'", cmd.Use)
35+
}
36+
37+
if cmd.Short == "" {
38+
t.Error("Command short description should not be empty")
39+
}
40+
41+
if cmd.Long == "" {
42+
t.Error("Command long description should not be empty")
43+
}
44+
45+
if cmd.Run == nil {
46+
t.Error("Command run function should not be nil")
47+
}
48+
}
49+
50+
func TestUpgradeCloudgateOSDetection(t *testing.T) {
51+
// Test that the OS detection works correctly
52+
os := runtime.GOOS
53+
54+
switch os {
55+
case "windows", "darwin", "linux":
56+
t.Logf("OS %s is supported, skipping actual upgrade execution", os)
57+
default:
58+
err := upgradeCloudgate()
59+
if err == nil {
60+
t.Errorf("Expected error for unsupported OS: %s", os)
61+
}
62+
}
63+
}

internal/cmd/commands/upgrade.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package commands
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"os/exec"
7+
"runtime"
8+
9+
"github.com/HenryOwenz/cloudgate/internal/cmd/version"
10+
"github.com/spf13/cobra"
11+
)
12+
13+
// NewUpgradeCmd creates a new upgrade command
14+
func NewUpgradeCmd() *cobra.Command {
15+
cmd := &cobra.Command{
16+
Use: "upgrade",
17+
Short: "Upgrade cloudgate to the latest version",
18+
Long: `Upgrade cloudgate to the latest version from GitHub releases.`,
19+
Run: func(cmd *cobra.Command, args []string) {
20+
// Check if already on the latest version
21+
isNew, latestVersion, err := version.IsUpdateAvailable()
22+
if err != nil {
23+
fmt.Println("Unable to check for the latest version. Proceeding with upgrade anyway...")
24+
} else if !isNew {
25+
fmt.Printf("You are already on the latest version (%s).\n", version.Current)
26+
return
27+
} else {
28+
fmt.Printf("Upgrading cloudgate from %s to %s...\n", version.Current, latestVersion)
29+
}
30+
31+
err = upgradeCloudgate()
32+
if err != nil {
33+
fmt.Fprintf(os.Stderr, "Error upgrading cloudgate: %v\n", err)
34+
os.Exit(1)
35+
}
36+
fmt.Println("Upgrade completed successfully!")
37+
},
38+
}
39+
40+
return cmd
41+
}
42+
43+
// upgradeCloudgate runs the appropriate upgrade script based on the OS
44+
func upgradeCloudgate() error {
45+
switch runtime.GOOS {
46+
case "windows":
47+
return upgradeWindows()
48+
case "darwin", "linux":
49+
return upgradeUnix()
50+
default:
51+
return fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
52+
}
53+
}
54+
55+
// upgradeUnix runs the Unix (Linux/macOS) upgrade script
56+
func upgradeUnix() error {
57+
// Using the exact command from README.md
58+
cmd := exec.Command("bash", "-c", "bash -c \"$(curl -fsSL https://raw.githubusercontent.com/HenryOwenz/cloudgate/main/scripts/install.sh)\"")
59+
cmd.Stdout = os.Stdout
60+
cmd.Stderr = os.Stderr
61+
return cmd.Run()
62+
}
63+
64+
// upgradeWindows runs the Windows upgrade script
65+
func upgradeWindows() error {
66+
// Using the exact command from README.md
67+
powershellCmd := `Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://raw.githubusercontent.com/HenryOwenz/cloudgate/main/scripts/install.ps1'))`
68+
cmd := exec.Command("powershell", "-Command", powershellCmd)
69+
cmd.Stdout = os.Stdout
70+
cmd.Stderr = os.Stderr
71+
return cmd.Run()
72+
}

internal/cmd/commands/version.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package commands
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/HenryOwenz/cloudgate/internal/cmd/version"
7+
"github.com/spf13/cobra"
8+
)
9+
10+
// NewVersionCmd creates a new version command
11+
func NewVersionCmd() *cobra.Command {
12+
cmd := &cobra.Command{
13+
Use: "version",
14+
Short: "Display the current version of cloudgate",
15+
Long: `Display the current version of cloudgate and check if a new version is available.`,
16+
Run: func(cmd *cobra.Command, args []string) {
17+
// Print the current version
18+
fmt.Printf("cloudgate version %s\n", version.Current)
19+
20+
// Check for new version and display message if available
21+
isNew, latestVersion, err := version.IsUpdateAvailable()
22+
if err == nil && isNew {
23+
// Use ANSI color codes for styling
24+
// Yellow text
25+
yellow := "\033[33m"
26+
// Cyan text
27+
cyan := "\033[36m"
28+
// Reset color
29+
reset := "\033[0m"
30+
31+
fmt.Printf("\n%sA new release of cloudgate is available: %s%s%s → %s%s%s\nTo upgrade, run: %scg --upgrade%s\n%s\n",
32+
yellow, cyan, version.Current, reset, cyan, latestVersion, reset, cyan, reset, version.RepositoryURL)
33+
}
34+
},
35+
}
36+
37+
return cmd
38+
}

internal/cmd/root.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"os"
6+
7+
"github.com/HenryOwenz/cloudgate/internal/cmd/commands"
8+
"github.com/HenryOwenz/cloudgate/internal/cmd/version"
9+
"github.com/HenryOwenz/cloudgate/internal/ui"
10+
tea "github.com/charmbracelet/bubbletea"
11+
"github.com/spf13/cobra"
12+
)
13+
14+
// rootCmd represents the base command when called without any subcommands
15+
var rootCmd = &cobra.Command{
16+
Use: "cg",
17+
Short: "A terminal-based application that unifies multi-cloud operations",
18+
Long: `cloudgate is a terminal-based application that unifies multi-cloud operations
19+
across AWS, Azure, and GCP.
20+
21+
Where your clouds converge.`,
22+
Run: func(cmd *cobra.Command, args []string) {
23+
// Check if upgrade flag is set
24+
upgrade, _ := cmd.Flags().GetBool("upgrade")
25+
if upgrade {
26+
// Run the upgrade command
27+
upgradeCmd := commands.NewUpgradeCmd()
28+
upgradeCmd.Run(cmd, args)
29+
return
30+
}
31+
32+
// Check if version flag is set
33+
versionFlag, _ := cmd.Flags().GetBool("version")
34+
if versionFlag {
35+
// Run the version command
36+
versionCmd := commands.NewVersionCmd()
37+
versionCmd.Run(cmd, args)
38+
return
39+
}
40+
41+
// Default behavior - run the UI
42+
// Clear the screen using ANSI escape codes (works cross-platform)
43+
fmt.Print("\033[H\033[2J")
44+
45+
// Create and run the program
46+
p := tea.NewProgram(ui.New())
47+
48+
if _, err := p.Run(); err != nil {
49+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
50+
os.Exit(1)
51+
}
52+
53+
// After the UI exits, check for new version and display message if available
54+
fmt.Print(version.ColoredUpdateMessage())
55+
},
56+
}
57+
58+
// Execute adds all child commands to the root command and sets flags appropriately.
59+
// This is called by main.main(). It only needs to happen once to the rootCmd.
60+
func Execute() {
61+
err := rootCmd.Execute()
62+
if err != nil {
63+
os.Exit(1)
64+
}
65+
}
66+
67+
func init() {
68+
// Add the upgrade flag to the root command
69+
rootCmd.Flags().BoolP("upgrade", "u", false, "Upgrade cloudgate to the latest version")
70+
71+
// Add the version flag to the root command
72+
rootCmd.Flags().BoolP("version", "v", false, "Display the current version of cloudgate")
73+
74+
// Add commands
75+
rootCmd.AddCommand(commands.NewUpgradeCmd())
76+
rootCmd.AddCommand(commands.NewVersionCmd())
77+
}

internal/cmd/root_test.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+
"testing"
5+
)
6+
7+
func TestRootCommand(t *testing.T) {
8+
// Test that the command is properly configured
9+
if rootCmd.Use != "cg" {
10+
t.Errorf("Expected command use to be 'cg', got '%s'", rootCmd.Use)
11+
}
12+
13+
if rootCmd.Short == "" {
14+
t.Error("Command short description should not be empty")
15+
}
16+
17+
if rootCmd.Long == "" {
18+
t.Error("Command long description should not be empty")
19+
}
20+
21+
if rootCmd.Run == nil {
22+
t.Error("Command run function should not be nil")
23+
}
24+
}
25+
26+
func TestRootCommandFlags(t *testing.T) {
27+
// Test that the upgrade flag is properly configured
28+
upgradeFlag := rootCmd.Flags().Lookup("upgrade")
29+
if upgradeFlag == nil {
30+
t.Error("Expected 'upgrade' flag to be defined")
31+
return
32+
}
33+
34+
if upgradeFlag.Shorthand != "u" {
35+
t.Errorf("Expected shorthand for 'upgrade' flag to be 'u', got '%s'", upgradeFlag.Shorthand)
36+
}
37+
38+
if upgradeFlag.Usage == "" {
39+
t.Error("Flag usage description should not be empty")
40+
}
41+
42+
// Test that the version flag is properly configured
43+
versionFlag := rootCmd.Flags().Lookup("version")
44+
if versionFlag == nil {
45+
t.Error("Expected 'version' flag to be defined")
46+
return
47+
}
48+
49+
if versionFlag.Shorthand != "v" {
50+
t.Errorf("Expected shorthand for 'version' flag to be 'v', got '%s'", versionFlag.Shorthand)
51+
}
52+
53+
if versionFlag.Usage == "" {
54+
t.Error("Flag usage description should not be empty")
55+
}
56+
}
57+
58+
func TestRootCommandSubcommands(t *testing.T) {
59+
// Test that the upgrade and version subcommands are properly added
60+
upgradeFound := false
61+
versionFound := false
62+
63+
for _, cmd := range rootCmd.Commands() {
64+
if cmd.Use == "upgrade" {
65+
upgradeFound = true
66+
}
67+
if cmd.Use == "version" {
68+
versionFound = true
69+
}
70+
}
71+
72+
if !upgradeFound {
73+
t.Error("Expected 'upgrade' subcommand to be added to root command")
74+
}
75+
76+
if !versionFound {
77+
t.Error("Expected 'version' subcommand to be added to root command")
78+
}
79+
}

0 commit comments

Comments
 (0)