Initial commit.

This commit is contained in:
2025-10-11 17:19:11 +00:00
commit 24245e46e8
33 changed files with 4206 additions and 0 deletions

152
internal/client/client.go Normal file
View File

@@ -0,0 +1,152 @@
// Package client provides HTTP client for Wild Central daemon API
package client
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// Client is the HTTP client for the Wild Central daemon
type Client struct {
baseURL string
httpClient *http.Client
}
// NewClient creates a new API client
func NewClient(baseURL string) *Client {
return &Client{
baseURL: baseURL,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// APIResponse is the API response format
type APIResponse struct {
Data map[string]interface{}
Error string `json:"error,omitempty"`
}
// Get makes a GET request to the API
func (c *Client) Get(path string) (*APIResponse, error) {
return c.doRequest("GET", path, nil)
}
// Post makes a POST request to the API
func (c *Client) Post(path string, body interface{}) (*APIResponse, error) {
return c.doRequest("POST", path, body)
}
// Put makes a PUT request to the API
func (c *Client) Put(path string, body interface{}) (*APIResponse, error) {
return c.doRequest("PUT", path, body)
}
// Delete makes a DELETE request to the API
func (c *Client) Delete(path string) (*APIResponse, error) {
return c.doRequest("DELETE", path, nil)
}
// Patch makes a PATCH request to the API
func (c *Client) Patch(path string, body interface{}) (*APIResponse, error) {
return c.doRequest("PATCH", path, body)
}
// doRequest performs the actual HTTP request
func (c *Client) doRequest(method, path string, body interface{}) (*APIResponse, error) {
url := c.baseURL + path
var reqBody io.Reader
if body != nil {
jsonData, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
reqBody = bytes.NewBuffer(jsonData)
}
req, err := http.NewRequest(method, url, reqBody)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
// Check for HTTP error status
if resp.StatusCode >= 400 {
// Try to parse error response
var errResp map[string]interface{}
if err := json.Unmarshal(respBody, &errResp); err == nil {
if errMsg, ok := errResp["error"].(string); ok {
return nil, fmt.Errorf("API error: %s", errMsg)
}
}
return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(respBody))
}
// Parse response data directly (daemon doesn't wrap in "data" field)
var data map[string]interface{}
if err := json.Unmarshal(respBody, &data); err != nil {
return nil, fmt.Errorf("failed to parse response: %w\nResponse: %s", err, string(respBody))
}
return &APIResponse{Data: data}, nil
}
// GetData extracts data from API response
func (r *APIResponse) GetData(key string) interface{} {
if r.Data == nil {
return nil
}
return r.Data[key]
}
// GetString extracts string data from API response
func (r *APIResponse) GetString(key string) string {
val := r.GetData(key)
if str, ok := val.(string); ok {
return str
}
return ""
}
// GetMap extracts map data from API response
func (r *APIResponse) GetMap(key string) map[string]interface{} {
val := r.GetData(key)
if m, ok := val.(map[string]interface{}); ok {
return m
}
return nil
}
// GetArray extracts array data from API response
func (r *APIResponse) GetArray(key string) []interface{} {
val := r.GetData(key)
if arr, ok := val.([]interface{}); ok {
return arr
}
return nil
}
// BaseURL returns the base URL of the client
func (c *Client) BaseURL() string {
return c.baseURL
}

17
internal/config/config.go Normal file
View File

@@ -0,0 +1,17 @@
// Package config handles CLI configuration
package config
import (
"os"
)
// GetDaemonURL returns the daemon URL from environment or default
func GetDaemonURL() string {
// Check environment variable first
if url := os.Getenv("WILD_DAEMON_URL"); url != "" {
return url
}
// Use default matching daemon's port
return "http://localhost:5055"
}

View File

@@ -0,0 +1,44 @@
package config_test
import (
"fmt"
"github.com/wild-cloud/wild-central/wild/internal/config"
)
func ExampleGetValue() {
cfg := map[string]interface{}{
"cloud": map[string]interface{}{
"domain": "example.com",
},
}
value := config.GetValue(cfg, "cloud.domain")
fmt.Println(value)
// Output: example.com
}
func ExampleValidatePaths() {
cfg := map[string]interface{}{
"cloud": map[string]interface{}{
"domain": "example.com",
},
}
required := []string{"cloud.domain", "cloud.name", "network.subnet"}
missing := config.ValidatePaths(cfg, required)
fmt.Printf("Missing paths: %v\n", missing)
// Output: Missing paths: [cloud.name network.subnet]
}
func ExampleExpandTemplate() {
cfg := map[string]interface{}{
"cloud": map[string]interface{}{
"domain": "example.com",
},
}
result, _ := config.ExpandTemplate("registry.{{ .cloud.domain }}", cfg)
fmt.Println(result)
// Output: registry.example.com
}

View File

@@ -0,0 +1,83 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// GetWildCLIDataDir returns the Wild CLI data directory
func GetWildCLIDataDir() string {
if dir := os.Getenv("WILD_CLI_DATA"); dir != "" {
return dir
}
home, err := os.UserHomeDir()
if err != nil {
return ".wildcloud"
}
return filepath.Join(home, ".wildcloud")
}
// GetCurrentInstance resolves the current instance using the priority cascade:
// 1. --instance flag (passed as parameter)
// 2. $WILD_CLI_DATA/current_instance file
// 3. Auto-select first instance from API
func GetCurrentInstance(flagInstance string, apiClient InstanceLister) (string, string, error) {
// Priority 1: --instance flag
if flagInstance != "" {
return flagInstance, "flag", nil
}
// Priority 2: current_instance file
dataDir := GetWildCLIDataDir()
currentFile := filepath.Join(dataDir, "current_instance")
if data, err := os.ReadFile(currentFile); err == nil {
instance := strings.TrimSpace(string(data))
if instance != "" {
return instance, "file", nil
}
}
// Priority 3: Auto-select first instance from API
if apiClient != nil {
instances, err := apiClient.ListInstances()
if err != nil {
return "", "", fmt.Errorf("no instance configured and failed to list instances: %w", err)
}
if len(instances) == 0 {
return "", "", fmt.Errorf("no instance configured and no instances available (create one with: wild instance create <name>)")
}
// Auto-select first instance
return instances[0], "auto", nil
}
return "", "", fmt.Errorf("no instance configured (use --instance flag or run: wild instance use <name>)")
}
// SetCurrentInstance persists the instance selection to file
func SetCurrentInstance(instance string) error {
dataDir := GetWildCLIDataDir()
// Create directory if it doesn't exist
if err := os.MkdirAll(dataDir, 0755); err != nil {
return fmt.Errorf("failed to create data directory: %w", err)
}
currentFile := filepath.Join(dataDir, "current_instance")
// Write instance name to file
if err := os.WriteFile(currentFile, []byte(instance), 0644); err != nil {
return fmt.Errorf("failed to write current instance file: %w", err)
}
return nil
}
// InstanceLister is an interface for listing instances (allows for testing and dependency injection)
type InstanceLister interface {
ListInstances() ([]string, error)
}

View File

@@ -0,0 +1,210 @@
package config
import (
"os"
"path/filepath"
"testing"
)
// mockInstanceLister is a mock implementation of InstanceLister for testing
type mockInstanceLister struct {
instances []string
err error
}
func (m *mockInstanceLister) ListInstances() ([]string, error) {
if m.err != nil {
return nil, m.err
}
return m.instances, nil
}
func TestGetCurrentInstance(t *testing.T) {
// Save and restore env var
oldWildCLIData := os.Getenv("WILD_CLI_DATA")
defer os.Setenv("WILD_CLI_DATA", oldWildCLIData)
// Create temp directory for testing
tmpDir := t.TempDir()
os.Setenv("WILD_CLI_DATA", tmpDir)
tests := []struct {
name string
flagInstance string
fileInstance string
apiInstances []string
wantInstance string
wantSource string
wantErr bool
}{
{
name: "flag takes priority",
flagInstance: "flag-instance",
fileInstance: "file-instance",
apiInstances: []string{"api-instance"},
wantInstance: "flag-instance",
wantSource: "flag",
wantErr: false,
},
{
name: "file takes priority over api",
flagInstance: "",
fileInstance: "file-instance",
apiInstances: []string{"api-instance"},
wantInstance: "file-instance",
wantSource: "file",
wantErr: false,
},
{
name: "auto-select first from api",
flagInstance: "",
fileInstance: "",
apiInstances: []string{"first-instance", "second-instance"},
wantInstance: "first-instance",
wantSource: "auto",
wantErr: false,
},
{
name: "no instance available",
flagInstance: "",
fileInstance: "",
apiInstances: []string{},
wantInstance: "",
wantSource: "",
wantErr: true,
},
{
name: "no api client and no config",
flagInstance: "",
fileInstance: "",
apiInstances: nil,
wantInstance: "",
wantSource: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Set up file
currentFile := filepath.Join(tmpDir, "current_instance")
if tt.fileInstance != "" {
if err := os.WriteFile(currentFile, []byte(tt.fileInstance), 0644); err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
} else {
os.Remove(currentFile)
}
// Set up API client mock
var lister InstanceLister
if tt.apiInstances != nil {
lister = &mockInstanceLister{instances: tt.apiInstances}
}
// Test
gotInstance, gotSource, err := GetCurrentInstance(tt.flagInstance, lister)
if (err != nil) != tt.wantErr {
t.Errorf("GetCurrentInstance() error = %v, wantErr %v", err, tt.wantErr)
return
}
if gotInstance != tt.wantInstance {
t.Errorf("GetCurrentInstance() instance = %v, want %v", gotInstance, tt.wantInstance)
}
if gotSource != tt.wantSource {
t.Errorf("GetCurrentInstance() source = %v, want %v", gotSource, tt.wantSource)
}
})
}
}
func TestSetCurrentInstance(t *testing.T) {
// Save and restore env var
oldWildCLIData := os.Getenv("WILD_CLI_DATA")
defer os.Setenv("WILD_CLI_DATA", oldWildCLIData)
// Create temp directory for testing
tmpDir := t.TempDir()
os.Setenv("WILD_CLI_DATA", tmpDir)
// Test setting instance
testInstance := "test-instance"
err := SetCurrentInstance(testInstance)
if err != nil {
t.Fatalf("SetCurrentInstance() error = %v", err)
}
// Verify file was written
currentFile := filepath.Join(tmpDir, "current_instance")
data, err := os.ReadFile(currentFile)
if err != nil {
t.Fatalf("Failed to read current_instance file: %v", err)
}
if string(data) != testInstance {
t.Errorf("File content = %v, want %v", string(data), testInstance)
}
// Test updating instance
newInstance := "new-instance"
err = SetCurrentInstance(newInstance)
if err != nil {
t.Fatalf("SetCurrentInstance() error = %v", err)
}
data, err = os.ReadFile(currentFile)
if err != nil {
t.Fatalf("Failed to read current_instance file: %v", err)
}
if string(data) != newInstance {
t.Errorf("File content = %v, want %v", string(data), newInstance)
}
}
func TestGetWildCLIDataDir(t *testing.T) {
// Save and restore env var
oldWildCLIData := os.Getenv("WILD_CLI_DATA")
defer os.Setenv("WILD_CLI_DATA", oldWildCLIData)
tests := []struct {
name string
envVar string
wantDir string
}{
{
name: "custom directory from env",
envVar: "/custom/path",
wantDir: "/custom/path",
},
{
name: "default directory when env not set",
envVar: "",
// We can't predict the exact home directory, so we'll just check it's not empty
wantDir: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.envVar != "" {
os.Setenv("WILD_CLI_DATA", tt.envVar)
} else {
os.Unsetenv("WILD_CLI_DATA")
}
gotDir := GetWildCLIDataDir()
if tt.wantDir != "" && gotDir != tt.wantDir {
t.Errorf("GetWildCLIDataDir() = %v, want %v", gotDir, tt.wantDir)
}
if tt.wantDir == "" && gotDir == "" {
t.Errorf("GetWildCLIDataDir() should not be empty when using default")
}
})
}
}

View File

@@ -0,0 +1,61 @@
package config
import (
"bytes"
"fmt"
"strings"
"text/template"
)
// ValidatePaths checks if all required paths exist in the config
// Returns a list of missing paths
func ValidatePaths(config map[string]interface{}, paths []string) []string {
var missing []string
for _, path := range paths {
if GetValue(config, path) == nil {
missing = append(missing, path)
}
}
return missing
}
// GetValue retrieves a nested value from config using dot notation
// Returns nil if the path doesn't exist
func GetValue(config map[string]interface{}, path string) interface{} {
parts := strings.Split(path, ".")
var current interface{} = config
for _, part := range parts {
m, ok := current.(map[string]interface{})
if !ok {
return nil
}
current = m[part]
if current == nil {
return nil
}
}
return current
}
// ExpandTemplate expands {{ .path.to.value }} templates in the string
// Returns the original string if no templates are present
func ExpandTemplate(tmpl string, config map[string]interface{}) (string, error) {
// Return original if no template markers
if !strings.Contains(tmpl, "{{") {
return tmpl, nil
}
t, err := template.New("config").Parse(tmpl)
if err != nil {
return "", fmt.Errorf("parse template: %w", err)
}
var buf bytes.Buffer
if err := t.Execute(&buf, config); err != nil {
return "", fmt.Errorf("execute template: %w", err)
}
return buf.String(), nil
}

View File

@@ -0,0 +1,153 @@
package config
import (
"reflect"
"testing"
)
func TestGetValue(t *testing.T) {
config := map[string]interface{}{
"cloud": map[string]interface{}{
"domain": "example.com",
"name": "test-cloud",
},
"simple": "value",
}
tests := []struct {
name string
path string
expected interface{}
}{
{"simple path", "simple", "value"},
{"nested path", "cloud.domain", "example.com"},
{"nested path 2", "cloud.name", "test-cloud"},
{"missing path", "missing", nil},
{"missing nested", "cloud.missing", nil},
{"invalid nested", "simple.nested", nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := GetValue(config, tt.path)
if !reflect.DeepEqual(result, tt.expected) {
t.Errorf("GetValue(%q) = %v, want %v", tt.path, result, tt.expected)
}
})
}
}
func TestValidatePaths(t *testing.T) {
config := map[string]interface{}{
"cloud": map[string]interface{}{
"domain": "example.com",
"name": "test-cloud",
},
"network": map[string]interface{}{
"subnet": "10.0.0.0/24",
},
}
tests := []struct {
name string
paths []string
expected []string
}{
{
name: "all paths exist",
paths: []string{"cloud.domain", "cloud.name", "network.subnet"},
expected: nil,
},
{
name: "some paths missing",
paths: []string{"cloud.domain", "missing.path", "network.missing"},
expected: []string{"missing.path", "network.missing"},
},
{
name: "all paths missing",
paths: []string{"foo.bar", "baz.qux"},
expected: []string{"foo.bar", "baz.qux"},
},
{
name: "empty paths",
paths: []string{},
expected: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ValidatePaths(config, tt.paths)
if !reflect.DeepEqual(result, tt.expected) {
t.Errorf("ValidatePaths() = %v, want %v", result, tt.expected)
}
})
}
}
func TestExpandTemplate(t *testing.T) {
config := map[string]interface{}{
"cloud": map[string]interface{}{
"domain": "example.com",
"name": "test-cloud",
},
"port": 8080,
}
tests := []struct {
name string
template string
expected string
shouldErr bool
}{
{
name: "simple template",
template: "registry.{{ .cloud.domain }}",
expected: "registry.example.com",
},
{
name: "multiple templates",
template: "{{ .cloud.name }}.{{ .cloud.domain }}",
expected: "test-cloud.example.com",
},
{
name: "no template",
template: "plain-string",
expected: "plain-string",
},
{
name: "template with text",
template: "http://{{ .cloud.domain }}:{{ .port }}/api",
expected: "http://example.com:8080/api",
},
{
name: "invalid template syntax",
template: "{{ .cloud.domain",
shouldErr: true,
},
{
name: "missing value renders as no value",
template: "{{ .missing.path }}",
expected: "<no value>",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := ExpandTemplate(tt.template, config)
if tt.shouldErr {
if err == nil {
t.Errorf("ExpandTemplate() expected error, got nil")
}
return
}
if err != nil {
t.Errorf("ExpandTemplate() unexpected error: %v", err)
return
}
if result != tt.expected {
t.Errorf("ExpandTemplate() = %q, want %q", result, tt.expected)
}
})
}
}

View File

@@ -0,0 +1,73 @@
package prompt_test
import (
"fmt"
// "github.com/wild-cloud/wild-central/wild/internal/prompt"
)
// ExampleString demonstrates the String prompt function
func ExampleString() {
// This example shows the prompt output format
// Actual usage would read from stdin interactively
fmt.Println("Enter SMTP host [smtp.gmail.com]:")
// User input: <empty> (returns default)
// Result: "smtp.gmail.com"
fmt.Println("Enter SMTP host [smtp.gmail.com]:")
// User input: "smtp.example.com"
// Result: "smtp.example.com"
}
// ExampleInt demonstrates the Int prompt function
func ExampleInt() {
// This example shows the prompt output format
fmt.Println("Enter SMTP port [587]:")
// User input: <empty> (returns default)
// Result: 587
fmt.Println("Enter SMTP port [587]:")
// User input: "465"
// Result: 465
}
// ExampleBool demonstrates the Bool prompt function
func ExampleBool() {
// This example shows the prompt output format
fmt.Println("Enable TLS [Y/n]:")
// User input: <empty> (returns default true)
// Result: true
fmt.Println("Enable TLS [Y/n]:")
// User input: "n"
// Result: false
fmt.Println("Enable debug mode [y/N]:")
// User input: <empty> (returns default false)
// Result: false
fmt.Println("Enable debug mode [y/N]:")
// User input: "yes"
// Result: true
}
// Example usage in a real command
func ExampleUsage() {
// Example of using prompt functions in a CLI command:
//
// host, err := prompt.String("Enter SMTP host", "smtp.gmail.com")
// if err != nil {
// return err
// }
//
// port, err := prompt.Int("Enter SMTP port", 587)
// if err != nil {
// return err
// }
//
// useTLS, err := prompt.Bool("Enable TLS", true)
// if err != nil {
// return err
// }
//
// fmt.Printf("Configuration: host=%s, port=%d, tls=%v\n", host, port, useTLS)
}

89
internal/prompt/prompt.go Normal file
View File

@@ -0,0 +1,89 @@
// Package prompt provides simple utilities for interactive CLI prompts
package prompt
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
)
// String prompts the user for a string value with a default
func String(prompt, defaultValue string) (string, error) {
if defaultValue != "" {
fmt.Printf("%s [%s]: ", prompt, defaultValue)
} else {
fmt.Printf("%s: ", prompt)
}
reader := bufio.NewReader(os.Stdin)
input, err := reader.ReadString('\n')
if err != nil {
return "", fmt.Errorf("failed to read input: %w", err)
}
input = strings.TrimSpace(input)
if input == "" {
return defaultValue, nil
}
return input, nil
}
// Int prompts the user for an integer value with a default
func Int(prompt string, defaultValue int) (int, error) {
fmt.Printf("%s [%d]: ", prompt, defaultValue)
reader := bufio.NewReader(os.Stdin)
input, err := reader.ReadString('\n')
if err != nil {
return 0, fmt.Errorf("failed to read input: %w", err)
}
input = strings.TrimSpace(input)
if input == "" {
return defaultValue, nil
}
value, err := strconv.Atoi(input)
if err != nil {
return 0, fmt.Errorf("invalid integer value: %s", input)
}
return value, nil
}
// Bool prompts the user for a boolean value with a default
func Bool(prompt string, defaultValue bool) (bool, error) {
defaultStr := "y/n"
if defaultValue {
defaultStr = "Y/n"
} else {
defaultStr = "y/N"
}
fmt.Printf("%s [%s]: ", prompt, defaultStr)
reader := bufio.NewReader(os.Stdin)
input, err := reader.ReadString('\n')
if err != nil {
return false, fmt.Errorf("failed to read input: %w", err)
}
input = strings.TrimSpace(input)
input = strings.ToLower(input)
if input == "" {
return defaultValue, nil
}
switch input {
case "y", "yes", "true":
return true, nil
case "n", "no", "false":
return false, nil
default:
return false, fmt.Errorf("invalid boolean value: %s (expected y/n/yes/no/true/false)", input)
}
}

View File

@@ -0,0 +1,64 @@
package prompt
import (
"testing"
)
// Note: These are basic unit tests for the prompt package.
// Interactive testing requires manual verification since the functions
// read from stdin and write to stdout.
func TestBoolParsing(t *testing.T) {
tests := []struct {
name string
input string
expected bool
hasError bool
}{
{"yes", "yes", true, false},
{"y", "y", true, false},
{"true", "true", true, false},
{"no", "no", false, false},
{"n", "n", false, false},
{"false", "false", false, false},
{"invalid", "maybe", false, true},
{"invalid", "xyz", false, true},
}
// Test the parsing logic that would be used by Bool function
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
input := tt.input
var result bool
var err error
switch input {
case "y", "yes", "true":
result = true
case "n", "no", "false":
result = false
default:
err = &invalidBoolError{}
}
if tt.hasError {
if err == nil {
t.Errorf("expected error for input %q, got nil", tt.input)
}
} else {
if err != nil {
t.Errorf("unexpected error for input %q: %v", tt.input, err)
}
if result != tt.expected {
t.Errorf("expected %v for input %q, got %v", tt.expected, tt.input, result)
}
}
})
}
}
type invalidBoolError struct{}
func (e *invalidBoolError) Error() string {
return "invalid boolean value"
}