Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
9f3970d
Add retry logic and pagination support to SDK
poyrazK May 2, 2026
b0a958e
Add shell completion and config command to CLI
poyrazK May 2, 2026
63d8ab4
Add pagination flags to CLI list commands
poyrazK May 2, 2026
6db012e
Add pagination to container list and storage list commands
poyrazK May 2, 2026
d124de3
Add pagination flags to ssh-key list and instance-type list
poyrazK May 2, 2026
d6bfbd6
Update CLI documentation for new features
poyrazK May 2, 2026
1b36b4e
Add ListDeploymentsWithPagination and ListBucketsWithPagination
poyrazK May 2, 2026
533aa99
Fix review findings: config permissions, unused constant, error format
poyrazK May 5, 2026
941e842
Add context support to SDK pagination methods
poyrazK May 5, 2026
7af9bf6
Use listWithPagination helper in container list command
poyrazK May 5, 2026
512ee16
Use listWithPagination helper and fix trailing newline
poyrazK May 5, 2026
1ce2511
Fix CodeQL: mask API key in config set success message
poyrazK May 5, 2026
8c3787e
Fix CodeQL: completely avoid API key in printf for config set
poyrazK May 5, 2026
709e2b7
Further refine CodeQL fix: log key name not value for api-key
poyrazK May 5, 2026
79eb06c
Simplify config set success message to avoid CodeQL alert
poyrazK May 5, 2026
87802ba
Fix flaky TestCachedIdentityService_Unit test
poyrazK May 5, 2026
6e51e49
Merge main into release/cli-improvements to resolve conflict in cache…
poyrazK May 5, 2026
a18748d
debug: Add debug output to TestCreateRoleCmd to diagnose CI failure
poyrazK May 7, 2026
02d92b8
debug: Add panic recovery inside createRoleCmd.Run to catch crashes
poyrazK May 7, 2026
283da7c
debug: Add more granular debug output inside captureStdout
poyrazK May 7, 2026
68e0302
debug: Replace captureStdout with direct pipe to isolate crash
poyrazK May 7, 2026
2c4e686
debug: Call createRoleCmd.Run directly first to isolate where crash o…
poyrazK May 7, 2026
e18302c
fix: Use single getConfigFilePath() function for config file path
poyrazK May 7, 2026
2234374
test: Set CLOUD_API_KEY and use direct stdout capture in TestCreateRo…
poyrazK May 7, 2026
812a392
trigger CI
poyrazK May 7, 2026
dc124d9
test: Verify test file is being run in CI by changing debug output
poyrazK May 7, 2026
92826be
fix: Use opts.APIKey instead of env var to bypass HOME issue in CI
poyrazK May 7, 2026
653f5e3
chore: remove debug output after verification
poyrazK May 7, 2026
9890d4a
fix(rbac_cli_test): use captureStdout helper and remove unused imports
poyrazK May 7, 2026
0cd1599
ci: re-trigger CI after fix
poyrazK May 7, 2026
a32a35a
Merge origin/main into release/cli-improvements
poyrazK May 7, 2026
ec5387f
fix(container_cli_test): correct payload type for list deployments re…
poyrazK May 7, 2026
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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,13 @@ npm run dev

## Recent Improvements

### CLI Enhancements
- **Multiple Output Formats**: `--output table|json|yaml` flag (default: table)
- **Shell Completion**: `cloud completion [bash|zsh|fish|powershell]`
- **Persistent Configuration**: `cloud config show|set|unset` for storing API URL and defaults
- **Debug Mode**: `--debug` flag for tracing API calls
- **Pagination Support**: `--limit` and `--offset` flags on list commands

### Code Quality & Features
- **Simplified Architecture**: Refactored `InstanceService` using parameter structs and helper methods.
- **Enhanced Storage**: Added support for **LVM Block Storage** and **VNC Console** access.
Expand Down
3 changes: 1 addition & 2 deletions cmd/cloud/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,7 @@ type Config struct {
}

func getConfigPath() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".cloud", "config.json")
return getConfigFilePath()
}

func saveConfig(key string) {
Expand Down
61 changes: 59 additions & 2 deletions cmd/cloud/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,41 +6,61 @@ import (
"os"

"github.com/poyrazk/thecloud/pkg/sdk"
"gopkg.in/yaml.v3"
)

type CLIOptions struct {
JSON bool
Output string
APIKey string
APIURL string
TenantID string
Debug bool
}

var opts CLIOptions

const errFmt = "Error: %v\n"

func createClient(o CLIOptions) *sdk.Client {
cfg := loadFullConfig()

key := o.APIKey
if key == "" {
key = os.Getenv("CLOUD_API_KEY")
}
if key == "" {
key = loadConfig()
key = loadConfigFile()
}

if key == "" {
fmt.Println("[WARN] No API Key found. Run 'cloud auth create-demo <name>' to get one.")
os.Exit(1)
}

client := sdk.NewClient(o.APIURL, key)
apiURL := o.APIURL
if apiURL == "http://localhost:8080" && cfg.APIURL != "" {
apiURL = cfg.APIURL
}
Comment on lines +41 to +44
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Magic string + likely dead code path for config API URL.

"http://localhost:8080" is a magic string (guideline violation), but more importantly the override logic is fragile: if --api-url flag default is "" (empty), this branch is never true and the persisted api_url from ~/.cloud/config.json is never applied. The precedence should be: CLI flag → config file → hardcoded default, not a string equality check against a specific default value.

🔧 Proposed fix
+const defaultAPIURL = "http://localhost:8080"
+
 func createClient(o CLIOptions) *sdk.Client {
 	cfg := loadFullConfig()
 	...
-	apiURL := o.APIURL
-	if apiURL == "http://localhost:8080" && cfg.APIURL != "" {
-		apiURL = cfg.APIURL
-	}
+	apiURL := o.APIURL
+	if (apiURL == "" || apiURL == defaultAPIURL) && cfg.APIURL != "" {
+		apiURL = cfg.APIURL
+	}
+	if apiURL == "" {
+		apiURL = defaultAPIURL
+	}

As per coding guidelines: "Do not use magic numbers — use named constants instead."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
apiURL := o.APIURL
if apiURL == "http://localhost:8080" && cfg.APIURL != "" {
apiURL = cfg.APIURL
}
const defaultAPIURL = "http://localhost:8080"
func createClient(o CLIOptions) *sdk.Client {
cfg := loadFullConfig()
...
apiURL := o.APIURL
if (apiURL == "" || apiURL == defaultAPIURL) && cfg.APIURL != "" {
apiURL = cfg.APIURL
}
if apiURL == "" {
apiURL = defaultAPIURL
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/cloud/common.go` around lines 41 - 44, The current apiURL selection uses
a magic string and fragile equality check; change the logic in the apiURL
assignment to follow precedence CLI flag → config file → hardcoded default by:
introduce a named constant (e.g. DEFAULT_API_URL) instead of the literal
"http://localhost:8080", then set apiURL to o.APIURL if o.APIURL != "", else to
cfg.APIURL if cfg.APIURL != "", else to DEFAULT_API_URL; update references
around the apiURL variable and remove the equality check against the magic
string in cmd/cloud/common.go.


client := sdk.NewClient(apiURL, key)

Comment on lines 25 to 47
tenant := o.TenantID
if tenant == "" {
tenant = os.Getenv("CLOUD_TENANT_ID")
}
if tenant == "" {
tenant = cfg.Tenant
}

if tenant != "" {
client.SetTenant(tenant)
}

if o.Debug || cfg.Debug {
client.EnableDebug()
}

return client
}

Expand All @@ -53,10 +73,47 @@ func printJSON(data interface{}) {
fmt.Println(string(b))
}

func printOutput(data interface{}) {
output := opts.Output
if output == "" {
output = "table"
}

switch output {
case "json":
printJSON(data)
case "yaml":
b, err := yaml.Marshal(data)
if err != nil {
fmt.Printf("Error marshaling YAML: %v\n", err)
return
}
fmt.Println(string(b))
default:
// For table output, fall back to JSON for now
// Commands that use tablewriter handle their own output
printJSON(data)
}
Comment on lines +76 to +96
}
Comment on lines +76 to +97
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check whether main.go or a PersistentPreRun hook syncs cfg.Output -> opts.Output
rg -n "opts.Output\s*=" --type go
rg -n "cfg\.Output" --type go

Repository: poyrazK/thecloud

Length of output: 148


🏁 Script executed:

# Find the definition of opts and cfg
rg -n "var opts" --type go -A 3
rg -n "type.*opts" --type go -A 5
rg -n "loadFullConfig" --type go -B 2 -A 10

Repository: poyrazK/thecloud

Length of output: 2846


🏁 Script executed:

# Check main.go and initialization flow
git ls-files "**/*.go" | grep -E "(main|cmd)" | head -20

Repository: poyrazK/thecloud

Length of output: 513


🏁 Script executed:

# Look at config.go to understand the config structure
wc -l cmd/cloud/config.go

Repository: poyrazK/thecloud

Length of output: 84


🏁 Script executed:

# Get CLIOptions and cliConfig struct definitions
rg -n "type CLIOptions" --type go -A 15
rg -n "type cliConfig" --type go -A 15

Repository: poyrazK/thecloud

Length of output: 1416


🏁 Script executed:

# Check main.go for opts initialization and command setup
fd main.go | head -5 | xargs -I {} sh -c 'wc -l {} && echo "---"'
cat cmd/cloud/main.go 2>/dev/null || cat cmd/*/main.go | head -100

Repository: poyrazK/thecloud

Length of output: 3247


🏁 Script executed:

# Look for where opts fields are set (CLI flags setup)
rg -n "opts\." --type go | grep -v "opts.Output" | head -20

Repository: poyrazK/thecloud

Length of output: 1502


🏁 Script executed:

# Check for PersistentPreRun or PersistentPreRunE hooks in main.go and command definitions
rg -n "PersistentPre" --type go

Repository: poyrazK/thecloud

Length of output: 42


🏁 Script executed:

# Double-check: look at all places where opts.Output is actually read/used
rg -n "opts\.Output" --type go

Repository: poyrazK/thecloud

Length of output: 240


printOutput ignores persisted output format from config file.

The opts.Output field (line 77) is populated only from CLI flags with a default of "table" (main.go:86). When a user runs cloud config set output yaml, that preference is saved to cfg.Output in the config file, but printOutput never reads it. There is no sync between cfg.Output and opts.Output, so the persisted config setting is silently ignored every time a command calls printOutput.

To fix this, load the config file output preference early (e.g., in a PersistentPreRun hook or before command execution) and set opts.Output = cfg.Output when no explicit --output flag is provided.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/cloud/common.go` around lines 76 - 97, printOutput currently only reads
opts.Output (defaulting to "table") and never respects the persisted cfg.Output,
so saved preferences are ignored; update command initialization to load the
config and set opts.Output = cfg.Output when the user did not pass an explicit
--output flag (e.g., in a PersistentPreRun hook or before command execution in
main.go), ensuring the CLI flag (opts.Output) remains authoritative if provided
but otherwise falls back to cfg.Output; reference the printOutput function, the
opts.Output field, and the cfg.Output config field when implementing this
synchronization.


func truncateID(id string) string {
const n = 8
if len(id) <= n {
return id
}
return id[:n]
}

// listWithPagination is a generic helper for list commands that support pagination.
// It calls the paginated function if limit/offset are set, otherwise calls the regular function.
func listWithPagination[T any](
regularFn func() ([]T, error),
pagFn func(int, int) ([]T, *sdk.ListResponse[T], error),
limit, offset int,
) ([]T, *sdk.ListResponse[T], error) {
if limit > 0 || offset > 0 {
return pagFn(limit, offset)
}
items, err := regularFn()
return items, nil, err
Comment on lines +107 to +118
}
Comment on lines +109 to +119
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

offset > 0 with limit == 0 may trigger an unintended paginated call.

The condition limit > 0 || offset > 0 routes to pagFn when only offset is provided (e.g., --offset 5 without --limit). Depending on the SDK's behavior for limit=0, this could return zero results or trigger an error, rather than the caller's expectation of "skip 5, return all".

Consider requiring both limit and offset to activate the paginated path, or defaulting limit to a sensible value when only offset is specified.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/cloud/common.go` around lines 109 - 119, The current listWithPagination
routes to pagFn when offset > 0 even if limit == 0, which can cause pagFn to be
called with limit==0; update listWithPagination (the function and its use of
regularFn/pagFn and the limit/offset parameters) to avoid invoking pagFn with a
zero limit — either require both limit > 0 && offset > 0 to choose the paginated
path, or when offset > 0 and limit == 0 set a sensible defaultLimit (e.g., 100)
before calling pagFn; ensure the chosen approach is applied where
listWithPagination calls pagFn so pagFn never receives limit == 0 unexpectedly.

156 changes: 156 additions & 0 deletions cmd/cloud/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package main

import (
"encoding/json"
"fmt"
"os"
"path/filepath"

"github.com/spf13/cobra"
)

type cliConfig struct {
APIKey string `json:"api_key"`
APIURL string `json:"api_url"`
Output string `json:"output"`
Tenant string `json:"tenant"`
Debug bool `json:"debug"`
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func getConfigFilePath() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".cloud", "config.json")
}

var configFile = getConfigFilePath()

func loadConfigFile() string {
Comment on lines +18 to +27
data, err := os.ReadFile(configFile)
if err != nil {
return ""
}

var cfg cliConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return ""
}

return cfg.APIKey
}

func loadFullConfig() *cliConfig {
data, err := os.ReadFile(configFile)
if err != nil {
return &cliConfig{}
}

var cfg cliConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return &cliConfig{}
}

return &cfg
}

func saveConfigFile(cfg cliConfig) error {
dir := filepath.Dir(configFile)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}

data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}

if err := os.WriteFile(configFile, data, 0600); err != nil {
return fmt.Errorf("failed to write config file: %w", err)
}

return nil
}

var configCmd = &cobra.Command{
Use: "config",
Short: "Manage CLI configuration",
}

var configShowCmd = &cobra.Command{
Use: "show",
Short: "Show current configuration",
Run: func(cmd *cobra.Command, args []string) {
cfg := loadFullConfig()
printOutput(cfg)
Comment on lines +78 to +83
},
}
Comment on lines +78 to +85

var configSetCmd = &cobra.Command{
Use: "set <key> <value>",
Short: "Set a configuration value",
Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
cfg := loadFullConfig()
key := args[0]
value := args[1]

switch key {
case "api-key":
cfg.APIKey = value
case "api-url":
cfg.APIURL = value
case "output":
cfg.Output = value
case "tenant":
Comment on lines +101 to +103
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Validate the output value in config set.

Any arbitrary string is accepted for the output key, but only "table", "json", and "yaml" are valid. An invalid value persists silently and confuses every subsequent command.

🔧 Proposed fix
 		case "output":
+			switch value {
+			case "table", "json", "yaml":
+				// valid
+			default:
+				fmt.Printf("Error: invalid output format %q. Valid values: table, json, yaml\n", value)
+				os.Exit(1)
+			}
 			cfg.Output = value
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/cloud/config.go` around lines 96 - 98, When handling the "output" key in
the config set flow (see the switch that assigns cfg.Output in
cmd/cloud/config.go), validate that the incoming value is one of the allowed
values "table", "json", or "yaml" before assigning and persisting it; if the
value is invalid, return an error (or surface a clear message) and do not set
cfg.Output. Update the code path that currently does cfg.Output = value to
perform this whitelist check and reject invalid values so subsequent commands
won't be confused by an unsupported output setting.

cfg.Tenant = value
default:
fmt.Printf("Error: unknown config key: %s\n", key)
fmt.Println("Valid keys: api-key, api-url, output, tenant")
os.Exit(1)
}
Comment on lines +96 to +109
Comment on lines +96 to +109

if err := saveConfigFile(*cfg); err != nil {
fmt.Printf("Error saving config: %v\n", err)
os.Exit(1)
}

fmt.Println("[SUCCESS] configuration updated")
},
}

var configUnsetCmd = &cobra.Command{
Use: "unset <key>",
Short: "Unset a configuration value",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
cfg := loadFullConfig()
key := args[0]

switch key {
case "api-key":
cfg.APIKey = ""
case "api-url":
cfg.APIURL = ""
case "output":
cfg.Output = ""
case "tenant":
cfg.Tenant = ""
default:
fmt.Printf("Error: unknown config key: %s\n", key)
fmt.Println("Valid keys: api-key, api-url, output, tenant")
Comment on lines +137 to +139
os.Exit(1)
}

if err := saveConfigFile(*cfg); err != nil {
fmt.Printf("Error saving config: %v\n", err)
os.Exit(1)
}

fmt.Printf("[SUCCESS] %s unset\n", key)
},
}

func init() {
configCmd.AddCommand(configShowCmd)
configCmd.AddCommand(configSetCmd)
configCmd.AddCommand(configUnsetCmd)
}
21 changes: 20 additions & 1 deletion cmd/cloud/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,15 @@ var listDeploymentsCmd = &cobra.Command{
Short: "List all deployments",
Run: func(cmd *cobra.Command, args []string) {
client := createClient(opts)
deps, err := client.ListDeployments()

limit, _ := cmd.Flags().GetInt("limit")
offset, _ := cmd.Flags().GetInt("offset")

deps, meta, err := listWithPagination(
client.ListDeployments,
client.ListDeploymentsWithPagination,
limit, offset,
)
if err != nil {
fmt.Printf(containerErrorFormat, err)
return
Expand All @@ -59,6 +67,14 @@ var listDeploymentsCmd = &cobra.Command{
})
}
table.Render()

if meta != nil {
fmt.Printf("\nShowing %d of %d total", len(deps), meta.TotalCount)
if meta.HasMore {
fmt.Print(" (more available)")
}
fmt.Println()
}
},
}

Expand Down Expand Up @@ -103,6 +119,9 @@ func init() {
createDeploymentCmd.Flags().IntP("replicas", "r", 1, "Number of replicas")
createDeploymentCmd.Flags().StringP("ports", "p", "", "Ports to expose (e.g. 80:80)")

listDeploymentsCmd.Flags().Int("limit", 0, "Maximum number of results (0 = use server default)")
listDeploymentsCmd.Flags().Int("offset", 0, "Number of results to skip")

containerCmd.AddCommand(createDeploymentCmd)
containerCmd.AddCommand(listDeploymentsCmd)
containerCmd.AddCommand(scaleDeploymentCmd)
Expand Down
6 changes: 5 additions & 1 deletion cmd/cloud/container_cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ func TestCreateDeploymentCmd(t *testing.T) {
}

func TestListDeploymentsCmd(t *testing.T) {
type listDeploymentsResponse struct {
Data []map[string]interface{} `json:"data"`
}

Comment on lines 57 to +61
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path != "/containers/deployments" || r.Method != http.MethodGet {
Expand All @@ -74,7 +78,7 @@ func TestListDeploymentsCmd(t *testing.T) {
},
},
}
_ = json.NewEncoder(w).Encode(payload)
_ = json.NewEncoder(w).Encode(listDeploymentsResponse{Data: payload["data"].([]map[string]interface{})})
}))
defer server.Close()

Expand Down
Loading
Loading