From 0c9e314d7c2557002a3aa38876d655a769a36529 Mon Sep 17 00:00:00 2001 From: ryyst Date: Sun, 5 Oct 2025 23:34:18 +0300 Subject: [PATCH] refactor: split monolithic main.go into modular structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactored 800+ line main.go into clean, domain-separated files: - main.go (96 lines) - Entry point and command router only - types.go - Type definitions and data structures - client.go - HTTP client and request handling - cmd_kv.go - Key-value operations (get/put/delete) - cmd_meta.go - Resource metadata commands - cmd_user.go - User management commands - cmd_profile.go - Profile management - cmd_cluster.go - Cluster operations (members/health) - cmd_system.go - System commands (connect/auth/help) - utils.go - Shared utilities (parsing, colors, completion) No functional changes, pure reorganization for maintainability. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- client.go | 61 +++++ cmd_cluster.go | 61 +++++ cmd_kv.go | 125 +++++++++ cmd_meta.go | 137 ++++++++++ cmd_profile.go | 69 +++++ cmd_system.go | 59 ++++ cmd_user.go | 86 ++++++ main.go | 710 ------------------------------------------------- types.go | 42 +++ utils.go | 124 +++++++++ 10 files changed, 764 insertions(+), 710 deletions(-) create mode 100644 client.go create mode 100644 cmd_cluster.go create mode 100644 cmd_kv.go create mode 100644 cmd_meta.go create mode 100644 cmd_profile.go create mode 100644 cmd_system.go create mode 100644 cmd_user.go create mode 100644 types.go create mode 100644 utils.go diff --git a/client.go b/client.go new file mode 100644 index 0000000..f64d752 --- /dev/null +++ b/client.go @@ -0,0 +1,61 @@ +package main + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "time" +) + +// KVSClient maintains the connection state and configuration +type KVSClient struct { + baseURL string + currentToken string + currentUser string + httpClient *http.Client + profiles map[string]Profile + activeProfile string +} + +// NewKVSClient creates a new KVS client instance +func NewKVSClient(baseURL string) *KVSClient { + return &KVSClient{ + baseURL: baseURL, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + profiles: make(map[string]Profile), + } +} + +// doRequest performs an HTTP request with JSON body and auth headers +func (c *KVSClient) doRequest(method, path string, body interface{}) ([]byte, int, error) { + var reqBody io.Reader + if body != nil { + jsonData, err := json.Marshal(body) + if err != nil { + return nil, 0, err + } + reqBody = bytes.NewBuffer(jsonData) + } + + req, err := http.NewRequest(method, c.baseURL+path, reqBody) + if err != nil { + return nil, 0, err + } + + req.Header.Set("Content-Type", "application/json") + if c.currentToken != "" { + req.Header.Set("Authorization", "Bearer "+c.currentToken) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + return respBody, resp.StatusCode, err +} diff --git a/cmd_cluster.go b/cmd_cluster.go new file mode 100644 index 0000000..92b860f --- /dev/null +++ b/cmd_cluster.go @@ -0,0 +1,61 @@ +package main + +import ( + "encoding/json" + "fmt" + "time" +) + +func (c *KVSClient) handleMembers(args []string) { + respBody, status, err := c.doRequest("GET", "/members/", nil) + if err != nil { + fmt.Println(red("Error:"), err) + return + } + + if status != 200 { + fmt.Println(red("Error:"), string(respBody)) + return + } + + var members []Member + if err := json.Unmarshal(respBody, &members); err != nil { + fmt.Println(red("Error parsing response:"), err) + return + } + + if len(members) == 0 { + fmt.Println(yellow("No cluster members")) + return + } + + fmt.Println(cyan("Cluster Members:")) + for _, m := range members { + lastSeen := time.UnixMilli(m.LastSeen).Format(time.RFC3339) + fmt.Printf(" • %s (%s) - Last seen: %s\n", m.ID, m.Address, lastSeen) + } +} + +func (c *KVSClient) handleHealth(args []string) { + respBody, status, err := c.doRequest("GET", "/health", nil) + if err != nil { + fmt.Println(red("Error:"), err) + return + } + + if status != 200 { + fmt.Println(red("Service unhealthy")) + return + } + + var health map[string]interface{} + if err := json.Unmarshal(respBody, &health); err != nil { + fmt.Println(red("Error parsing response:"), err) + return + } + + fmt.Println(green("Service Status:")) + for k, v := range health { + fmt.Printf(" %s: %v\n", cyan(k), v) + } +} diff --git a/cmd_kv.go b/cmd_kv.go new file mode 100644 index 0000000..23d9fd0 --- /dev/null +++ b/cmd_kv.go @@ -0,0 +1,125 @@ +package main + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "os" + "strings" + "time" +) + +func (c *KVSClient) handleGet(args []string) { + if len(args) < 1 { + fmt.Println(red("Usage: get ")) + return + } + + respBody, status, err := c.doRequest("GET", "/kv/"+args[0], nil) + if err != nil { + fmt.Println(red("Error:"), err) + return + } + + if status == 404 { + fmt.Println(yellow("Key not found")) + return + } + + if status != 200 { + fmt.Println(red("Error:"), string(respBody)) + return + } + + var stored StoredValue + if err := json.Unmarshal(respBody, &stored); err != nil { + fmt.Println(red("Error parsing response:"), err) + return + } + + fmt.Println(cyan("UUID: "), stored.UUID) + fmt.Println(cyan("Timestamp:"), time.UnixMilli(stored.Timestamp).Format(time.RFC3339)) + fmt.Println(cyan("Data:")) + + var pretty bytes.Buffer + json.Indent(&pretty, stored.Data, "", " ") + fmt.Println(pretty.String()) +} + +func (c *KVSClient) handlePut(args []string) { + if len(args) < 1 { + fmt.Println(red("Usage: put ")) + fmt.Println("Or: put (then paste JSON on next line)") + return + } + + var jsonStr string + if len(args) == 1 { + // Multiline mode - read from next input + fmt.Println(yellow("Enter JSON (Ctrl+D when done):")) + scanner := bufio.NewScanner(os.Stdin) + var lines []string + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + jsonStr = strings.Join(lines, "\n") + } else { + // Single line mode + jsonStr = strings.Join(args[1:], " ") + } + + // Parse JSON data + var data json.RawMessage + if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { + printJSONError(jsonStr, err) + return + } + + respBody, status, err := c.doRequest("PUT", "/kv/"+args[0], data) + if err != nil { + fmt.Println(red("Error:"), err) + return + } + + if status != 200 && status != 201 { + fmt.Println(red("Error:"), string(respBody)) + return + } + + type PutResponse struct { + UUID string `json:"uuid"` + Timestamp int64 `json:"timestamp"` + } + + var resp PutResponse + if err := json.Unmarshal(respBody, &resp); err == nil { + fmt.Println(green("Stored successfully")) + fmt.Println(cyan("UUID: "), resp.UUID) + fmt.Println(cyan("Timestamp:"), time.UnixMilli(resp.Timestamp).Format(time.RFC3339)) + } +} + +func (c *KVSClient) handleDelete(args []string) { + if len(args) < 1 { + fmt.Println(red("Usage: delete ")) + return + } + + _, status, err := c.doRequest("DELETE", "/kv/"+args[0], nil) + if err != nil { + fmt.Println(red("Error:"), err) + return + } + + if status == 404 { + fmt.Println(yellow("Key not found")) + return + } + + if status == 204 { + fmt.Println(green("Deleted successfully")) + } else { + fmt.Println(red("Unexpected status:"), status) + } +} diff --git a/cmd_meta.go b/cmd_meta.go new file mode 100644 index 0000000..a7d335c --- /dev/null +++ b/cmd_meta.go @@ -0,0 +1,137 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "strconv" + "time" +) + +// handleMeta dispatches metadata sub-commands +func (c *KVSClient) handleMeta(args []string) { + if len(args) == 0 { + fmt.Println(red("Usage: meta [options]")) + return + } + + subCmd := args[0] + switch subCmd { + case "get": + c.handleMetaGet(args[1:]) + case "set": + c.handleMetaSet(args[1:]) + default: + fmt.Println(red("Unknown meta command:"), subCmd) + fmt.Println("Available commands: get, set") + } +} + +// handleMetaGet fetches and displays metadata for a key +func (c *KVSClient) handleMetaGet(args []string) { + if len(args) < 1 { + fmt.Println(red("Usage: meta get ")) + return + } + key := args[0] + + respBody, status, err := c.doRequest("GET", "/kv/"+key+"/metadata", nil) + if err != nil { + fmt.Println(red("Error:"), err) + return + } + + if status == http.StatusNotFound { + fmt.Println(yellow("No metadata found for key:"), key) + return + } + + if status != http.StatusOK { + fmt.Printf(red("Error getting metadata (status %d):\n"), status) + fmt.Println(string(respBody)) + return + } + + // Struct to parse the metadata response + type MetaResponse struct { + OwnerUUID string `json:"owner_uuid"` + GroupUUID string `json:"group_uuid"` + Permissions int `json:"permissions"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + } + + var meta MetaResponse + if err := json.Unmarshal(respBody, &meta); err != nil { + fmt.Println(red("Error parsing metadata response:"), err) + return + } + + fmt.Println(cyan("Metadata for key:"), key) + fmt.Printf(" Owner UUID: %s\n", meta.OwnerUUID) + fmt.Printf(" Group UUID: %s\n", meta.GroupUUID) + fmt.Printf(" Permissions: %d\n", meta.Permissions) + fmt.Printf(" Created At: %s\n", time.Unix(meta.CreatedAt, 0).Format(time.RFC3339)) + fmt.Printf(" Updated At: %s\n", time.Unix(meta.UpdatedAt, 0).Format(time.RFC3339)) +} + +// handleMetaSet updates metadata for a key using flags +func (c *KVSClient) handleMetaSet(args []string) { + if len(args) < 3 { + fmt.Println(red("Usage: meta set --owner | --group | --permissions ")) + return + } + key := args[0] + + // Use a map to build the JSON payload for partial updates + payload := make(map[string]interface{}) + + // Parse command-line flags + for i := 1; i < len(args); i++ { + switch args[i] { + case "--owner": + if i+1 < len(args) { + payload["owner_uuid"] = args[i+1] + i++ // Skip the value + } + case "--group": + if i+1 < len(args) { + payload["group_uuid"] = args[i+1] + i++ // Skip the value + } + case "--permissions": + if i+1 < len(args) { + perms, err := strconv.Atoi(args[i+1]) + if err != nil { + fmt.Println(red("Invalid permissions value:"), args[i+1]) + return + } + payload["permissions"] = perms + i++ // Skip the value + } + } + } + + if len(payload) == 0 { + fmt.Println(red("No valid metadata fields provided to set.")) + return + } + + respBody, status, err := c.doRequest("PUT", "/kv/"+key+"/metadata", payload) + if err != nil { + fmt.Println(red("Error:"), err) + return + } + + if status != http.StatusOK { + fmt.Printf(red("Error setting metadata (status %d):\n"), status) + fmt.Println(string(respBody)) + return + } + + fmt.Println(green("Metadata updated successfully for key:"), key) + var pretty bytes.Buffer + json.Indent(&pretty, respBody, "", " ") + fmt.Println(pretty.String()) +} diff --git a/cmd_profile.go b/cmd_profile.go new file mode 100644 index 0000000..4939310 --- /dev/null +++ b/cmd_profile.go @@ -0,0 +1,69 @@ +package main + +import "fmt" + +func (c *KVSClient) handleProfile(args []string) { + if len(args) == 0 { + // List profiles + if len(c.profiles) == 0 { + fmt.Println(yellow("No profiles configured")) + return + } + fmt.Println(cyan("Available profiles:")) + for name, profile := range c.profiles { + marker := " " + if name == c.activeProfile { + marker = "*" + } + fmt.Printf("%s %s (user: %s, url: %s)\n", marker, name, profile.UserUUID, profile.BaseURL) + } + return + } + + subCmd := args[0] + switch subCmd { + case "add": + if len(args) < 4 { + fmt.Println(red("Usage: profile add [base-url]")) + return + } + baseURL := c.baseURL + if len(args) >= 5 { + baseURL = args[4] + } + c.profiles[args[1]] = Profile{ + Name: args[1], + Token: args[2], + UserUUID: args[3], + BaseURL: baseURL, + } + fmt.Println(green("Profile added:"), args[1]) + + case "use": + if len(args) < 2 { + fmt.Println(red("Usage: profile use ")) + return + } + profile, ok := c.profiles[args[1]] + if !ok { + fmt.Println(red("Profile not found:"), args[1]) + return + } + c.currentToken = profile.Token + c.currentUser = profile.UserUUID + c.baseURL = profile.BaseURL + c.activeProfile = args[1] + fmt.Println(green("Switched to profile:"), args[1]) + + case "remove": + if len(args) < 2 { + fmt.Println(red("Usage: profile remove ")) + return + } + delete(c.profiles, args[1]) + if c.activeProfile == args[1] { + c.activeProfile = "" + } + fmt.Println(green("Profile removed:"), args[1]) + } +} diff --git a/cmd_system.go b/cmd_system.go new file mode 100644 index 0000000..02e934d --- /dev/null +++ b/cmd_system.go @@ -0,0 +1,59 @@ +package main + +import "fmt" + +func (c *KVSClient) handleConnect(args []string) { + if len(args) < 1 { + fmt.Println(red("Usage: connect ")) + return + } + c.baseURL = args[0] + fmt.Println(green("Connected to:"), c.baseURL) +} + +func (c *KVSClient) handleAuth(args []string) { + if len(args) < 1 { + fmt.Println(red("Usage: auth ")) + return + } + c.currentToken = args[0] + fmt.Println(green("Authentication token set")) +} + +func (c *KVSClient) handleHelp(args []string) { + help := ` +KVS Interactive Shell - Available Commands: + +Connection & Authentication: + connect - Connect to KVS server + auth - Set authentication token + profile add [url] - Add user profile + profile use - Switch to user profile + profile remove - Remove user profile + profile - List all profiles + +Key-Value Operations: + get - Retrieve value for key + put - Store JSON value at key + delete - Delete key + +Resource Metadata: + meta get - Get metadata (owner, group) for a key + meta set [flags] - Set metadata for a key + Flags: --owner , --group , --permissions + +Cluster Management: + members - List cluster members + health - Check service health + +User Management: + user get - Get user details + user create - Create new user (admin only) + +System: + help - Show this help + exit, quit - Exit shell + clear - Clear screen +` + fmt.Println(help) +} diff --git a/cmd_user.go b/cmd_user.go new file mode 100644 index 0000000..43a4509 --- /dev/null +++ b/cmd_user.go @@ -0,0 +1,86 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "time" +) + +func (c *KVSClient) handleUserList(args []string) { + // Note: Backend doesn't have a list-all endpoint, this would need to be added + fmt.Println(yellow("User listing not implemented in backend API")) +} + +func (c *KVSClient) handleUserGet(args []string) { + if len(args) < 1 { + fmt.Println(red("Usage: user get ")) + return + } + + respBody, status, err := c.doRequest("GET", "/api/users/"+args[0], nil) + if err != nil { + fmt.Println(red("Error:"), err) + return + } + + if status == 404 { + fmt.Println(yellow("User not found")) + return + } + + if status != 200 { + fmt.Println(red("Error:"), string(respBody)) + return + } + + var user User + if err := json.Unmarshal(respBody, &user); err != nil { + fmt.Println(red("Error parsing response:"), err) + return + } + + fmt.Println(cyan("User Details:")) + fmt.Printf(" UUID: %s\n", user.UUID) + fmt.Printf(" Nickname Hash: %s\n", user.NicknameHash) + fmt.Printf(" Groups: %v\n", user.Groups) + fmt.Printf(" Created: %s\n", time.Unix(user.CreatedAt, 0).Format(time.RFC3339)) + fmt.Printf(" Updated: %s\n", time.Unix(user.UpdatedAt, 0).Format(time.RFC3339)) +} + +func (c *KVSClient) handleUserCreate(args []string) { + if len(args) < 1 { + fmt.Println(red("Usage: user create ")) + return + } + + // The request body requires a JSON object like {"nickname": "..."} + requestPayload := map[string]string{ + "nickname": args[0], + } + + respBody, status, err := c.doRequest("POST", "/api/users", requestPayload) + if err != nil { + fmt.Println(red("Error:"), err) + return + } + + // The backend returns 200 OK on success + if status != http.StatusOK { + fmt.Printf(red("Error creating user (status %d):\n"), status) + fmt.Println(string(respBody)) + return + } + + // Parse the successful response to get the new user's UUID + var response struct { + UUID string `json:"uuid"` + } + if err := json.Unmarshal(respBody, &response); err != nil { + fmt.Println(red("Error parsing successful response:"), err) + return + } + + fmt.Println(green("User created successfully")) + fmt.Println(cyan("UUID:"), response.UUID) +} diff --git a/main.go b/main.go index 68b9de3..ca87df3 100644 --- a/main.go +++ b/main.go @@ -1,650 +1,13 @@ -// kvs-shell/main.go package main import ( - "bufio" - "bytes" - "encoding/json" "fmt" - "io" - "net/http" "os" - "strconv" "strings" - "time" "github.com/chzyer/readline" - "github.com/fatih/color" ) -// Types matching your backend -type StoredValue struct { - UUID string `json:"uuid"` - Timestamp int64 `json:"timestamp"` - Data json.RawMessage `json:"data"` -} - -type Member struct { - ID string `json:"id"` - Address string `json:"address"` - LastSeen int64 `json:"last_seen"` - JoinedTimestamp int64 `json:"joined_timestamp"` -} - -type User struct { - UUID string `json:"uuid"` - NicknameHash string `json:"nickname_hash"` - Groups []string `json:"groups"` - CreatedAt int64 `json:"created_at"` - UpdatedAt int64 `json:"updated_at"` -} - -type Group struct { - UUID string `json:"uuid"` - NameHash string `json:"name_hash"` - Members []string `json:"members"` - CreatedAt int64 `json:"created_at"` - UpdatedAt int64 `json:"updated_at"` -} - -// Client state -type KVSClient struct { - baseURL string - currentToken string - currentUser string - httpClient *http.Client - profiles map[string]Profile // Stored user profiles - activeProfile string -} - -type Profile struct { - Name string `json:"name"` - Token string `json:"token"` - UserUUID string `json:"user_uuid"` - BaseURL string `json:"base_url"` -} - -// Colors -var ( - cyan = color.New(color.FgCyan).SprintFunc() - green = color.New(color.FgGreen).SprintFunc() - yellow = color.New(color.FgYellow).SprintFunc() - red = color.New(color.FgRed).SprintFunc() - magenta = color.New(color.FgMagenta).SprintFunc() -) - -func NewKVSClient(baseURL string) *KVSClient { - return &KVSClient{ - baseURL: baseURL, - httpClient: &http.Client{ - Timeout: 30 * time.Second, - }, - profiles: make(map[string]Profile), - } -} - -// HTTP request helper -func (c *KVSClient) doRequest(method, path string, body interface{}) ([]byte, int, error) { - var reqBody io.Reader - if body != nil { - jsonData, err := json.Marshal(body) - if err != nil { - return nil, 0, err - } - reqBody = bytes.NewBuffer(jsonData) - } - - req, err := http.NewRequest(method, c.baseURL+path, reqBody) - if err != nil { - return nil, 0, err - } - - req.Header.Set("Content-Type", "application/json") - if c.currentToken != "" { - req.Header.Set("Authorization", "Bearer "+c.currentToken) - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, 0, err - } - defer resp.Body.Close() - - respBody, err := io.ReadAll(resp.Body) - return respBody, resp.StatusCode, err -} - -// Command handlers -func (c *KVSClient) handleConnect(args []string) { - if len(args) < 1 { - fmt.Println(red("Usage: connect ")) - return - } - c.baseURL = args[0] - fmt.Println(green("Connected to:"), c.baseURL) -} - -func (c *KVSClient) handleAuth(args []string) { - if len(args) < 1 { - fmt.Println(red("Usage: auth ")) - return - } - c.currentToken = args[0] - fmt.Println(green("Authentication token set")) -} - -func (c *KVSClient) handleProfile(args []string) { - if len(args) == 0 { - // List profiles - if len(c.profiles) == 0 { - fmt.Println(yellow("No profiles configured")) - return - } - fmt.Println(cyan("Available profiles:")) - for name, profile := range c.profiles { - marker := " " - if name == c.activeProfile { - marker = "*" - } - fmt.Printf("%s %s (user: %s, url: %s)\n", marker, name, profile.UserUUID, profile.BaseURL) - } - return - } - - subCmd := args[0] - switch subCmd { - case "add": - if len(args) < 4 { - fmt.Println(red("Usage: profile add [base-url]")) - return - } - baseURL := c.baseURL - if len(args) >= 5 { - baseURL = args[4] - } - c.profiles[args[1]] = Profile{ - Name: args[1], - Token: args[2], - UserUUID: args[3], - BaseURL: baseURL, - } - fmt.Println(green("Profile added:"), args[1]) - - case "use": - if len(args) < 2 { - fmt.Println(red("Usage: profile use ")) - return - } - profile, ok := c.profiles[args[1]] - if !ok { - fmt.Println(red("Profile not found:"), args[1]) - return - } - c.currentToken = profile.Token - c.currentUser = profile.UserUUID - c.baseURL = profile.BaseURL - c.activeProfile = args[1] - fmt.Println(green("Switched to profile:"), args[1]) - - case "remove": - if len(args) < 2 { - fmt.Println(red("Usage: profile remove ")) - return - } - delete(c.profiles, args[1]) - if c.activeProfile == args[1] { - c.activeProfile = "" - } - fmt.Println(green("Profile removed:"), args[1]) - } -} - -func (c *KVSClient) handleGet(args []string) { - if len(args) < 1 { - fmt.Println(red("Usage: get ")) - return - } - - respBody, status, err := c.doRequest("GET", "/kv/"+args[0], nil) - if err != nil { - fmt.Println(red("Error:"), err) - return - } - - if status == 404 { - fmt.Println(yellow("Key not found")) - return - } - - if status != 200 { - fmt.Println(red("Error:"), string(respBody)) - return - } - - var stored StoredValue - if err := json.Unmarshal(respBody, &stored); err != nil { - fmt.Println(red("Error parsing response:"), err) - return - } - - fmt.Println(cyan("UUID: "), stored.UUID) - fmt.Println(cyan("Timestamp:"), time.UnixMilli(stored.Timestamp).Format(time.RFC3339)) - fmt.Println(cyan("Data:")) - - var pretty bytes.Buffer - json.Indent(&pretty, stored.Data, "", " ") - fmt.Println(pretty.String()) -} - -func (c *KVSClient) handlePut(args []string) { - if len(args) < 1 { - fmt.Println(red("Usage: put ")) - fmt.Println("Or: put (then paste JSON on next line)") - return - } - - var jsonStr string - if len(args) == 1 { - // Multiline mode - read from next input - fmt.Println(yellow("Enter JSON (Ctrl+D when done):")) - scanner := bufio.NewScanner(os.Stdin) - var lines []string - for scanner.Scan() { - lines = append(lines, scanner.Text()) - } - jsonStr = strings.Join(lines, "\n") - } else { - // Single line mode - jsonStr = strings.Join(args[1:], " ") - } - - // Parse JSON data - var data json.RawMessage - if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { - printJSONError(jsonStr, err) - return - } - - respBody, status, err := c.doRequest("PUT", "/kv/"+args[0], data) - if err != nil { - fmt.Println(red("Error:"), err) - return - } - - if status != 200 && status != 201 { - fmt.Println(red("Error:"), string(respBody)) - return - } - - type PutResponse struct { - UUID string `json:"uuid"` - Timestamp int64 `json:"timestamp"` - } - - var resp PutResponse - if err := json.Unmarshal(respBody, &resp); err == nil { - fmt.Println(green("Stored successfully")) - fmt.Println(cyan("UUID: "), resp.UUID) - fmt.Println(cyan("Timestamp:"), time.UnixMilli(resp.Timestamp).Format(time.RFC3339)) - } -} - -func (c *KVSClient) handleDelete(args []string) { - if len(args) < 1 { - fmt.Println(red("Usage: delete ")) - return - } - - _, status, err := c.doRequest("DELETE", "/kv/"+args[0], nil) - if err != nil { - fmt.Println(red("Error:"), err) - return - } - - if status == 404 { - fmt.Println(yellow("Key not found")) - return - } - - if status == 204 { - fmt.Println(green("Deleted successfully")) - } else { - fmt.Println(red("Unexpected status:"), status) - } -} - -// handleMeta is the dispatcher for "meta" sub-commands -func (c *KVSClient) handleMeta(args []string) { - if len(args) == 0 { - fmt.Println(red("Usage: meta [options]")) - return - } - - subCmd := args[0] - switch subCmd { - case "get": - c.handleMetaGet(args[1:]) - case "set": - c.handleMetaSet(args[1:]) - default: - fmt.Println(red("Unknown meta command:"), subCmd) - fmt.Println("Available commands: get, set") - } -} - -// handleMetaGet fetches and displays metadata for a key -func (c *KVSClient) handleMetaGet(args []string) { - if len(args) < 1 { - fmt.Println(red("Usage: meta get ")) - return - } - key := args[0] - - respBody, status, err := c.doRequest("GET", "/kv/"+key+"/metadata", nil) - if err != nil { - fmt.Println(red("Error:"), err) - return - } - - if status == http.StatusNotFound { - fmt.Println(yellow("No metadata found for key:"), key) - return - } - - if status != http.StatusOK { - fmt.Printf(red("Error getting metadata (status %d):\n"), status) - fmt.Println(string(respBody)) - return - } - - // Struct to parse the metadata response - type MetaResponse struct { - OwnerUUID string `json:"owner_uuid"` - GroupUUID string `json:"group_uuid"` - Permissions int `json:"permissions"` - CreatedAt int64 `json:"created_at"` - UpdatedAt int64 `json:"updated_at"` - } - - var meta MetaResponse - if err := json.Unmarshal(respBody, &meta); err != nil { - fmt.Println(red("Error parsing metadata response:"), err) - return - } - - fmt.Println(cyan("Metadata for key:"), key) - fmt.Printf(" Owner UUID: %s\n", meta.OwnerUUID) - fmt.Printf(" Group UUID: %s\n", meta.GroupUUID) - fmt.Printf(" Permissions: %d\n", meta.Permissions) - fmt.Printf(" Created At: %s\n", time.Unix(meta.CreatedAt, 0).Format(time.RFC3339)) - fmt.Printf(" Updated At: %s\n", time.Unix(meta.UpdatedAt, 0).Format(time.RFC3339)) -} - -// handleMetaSet updates metadata for a key using flags -func (c *KVSClient) handleMetaSet(args []string) { - if len(args) < 3 { - fmt.Println(red("Usage: meta set --owner | --group | --permissions ")) - return - } - key := args[0] - - // Use a map to build the JSON payload for partial updates - payload := make(map[string]interface{}) - - // Parse command-line flags - for i := 1; i < len(args); i++ { - switch args[i] { - case "--owner": - if i+1 < len(args) { - payload["owner_uuid"] = args[i+1] - i++ // Skip the value - } - case "--group": - if i+1 < len(args) { - payload["group_uuid"] = args[i+1] - i++ // Skip the value - } - case "--permissions": - if i+1 < len(args) { - perms, err := strconv.Atoi(args[i+1]) - if err != nil { - fmt.Println(red("Invalid permissions value:"), args[i+1]) - return - } - payload["permissions"] = perms - i++ // Skip the value - } - } - } - - if len(payload) == 0 { - fmt.Println(red("No valid metadata fields provided to set.")) - return - } - - respBody, status, err := c.doRequest("PUT", "/kv/"+key+"/metadata", payload) - if err != nil { - fmt.Println(red("Error:"), err) - return - } - - if status != http.StatusOK { - fmt.Printf(red("Error setting metadata (status %d):\n"), status) - fmt.Println(string(respBody)) - return - } - - fmt.Println(green("Metadata updated successfully for key:"), key) - // The response body contains the full updated metadata, so we can print it for confirmation - var pretty bytes.Buffer - json.Indent(&pretty, respBody, "", " ") - fmt.Println(pretty.String()) -} - -func (c *KVSClient) handleMembers(args []string) { - respBody, status, err := c.doRequest("GET", "/members/", nil) - if err != nil { - fmt.Println(red("Error:"), err) - return - } - - if status != 200 { - fmt.Println(red("Error:"), string(respBody)) - return - } - - var members []Member - if err := json.Unmarshal(respBody, &members); err != nil { - fmt.Println(red("Error parsing response:"), err) - return - } - - if len(members) == 0 { - fmt.Println(yellow("No cluster members")) - return - } - - fmt.Println(cyan("Cluster Members:")) - for _, m := range members { - lastSeen := time.UnixMilli(m.LastSeen).Format(time.RFC3339) - fmt.Printf(" • %s (%s) - Last seen: %s\n", m.ID, m.Address, lastSeen) - } -} - -func (c *KVSClient) handleHealth(args []string) { - respBody, status, err := c.doRequest("GET", "/health", nil) - if err != nil { - fmt.Println(red("Error:"), err) - return - } - - if status != 200 { - fmt.Println(red("Service unhealthy")) - return - } - - var health map[string]interface{} - if err := json.Unmarshal(respBody, &health); err != nil { - fmt.Println(red("Error parsing response:"), err) - return - } - - fmt.Println(green("Service Status:")) - for k, v := range health { - fmt.Printf(" %s: %v\n", cyan(k), v) - } -} - -func (c *KVSClient) handleUserList(args []string) { - // Note: Backend doesn't have a list-all endpoint, this would need to be added - fmt.Println(yellow("User listing not implemented in backend API")) -} - -func (c *KVSClient) handleUserGet(args []string) { - if len(args) < 1 { - fmt.Println(red("Usage: user get ")) - return - } - - respBody, status, err := c.doRequest("GET", "/api/users/"+args[0], nil) - if err != nil { - fmt.Println(red("Error:"), err) - return - } - - if status == 404 { - fmt.Println(yellow("User not found")) - return - } - - if status != 200 { - fmt.Println(red("Error:"), string(respBody)) - return - } - - var user User - if err := json.Unmarshal(respBody, &user); err != nil { - fmt.Println(red("Error parsing response:"), err) - return - } - - fmt.Println(cyan("User Details:")) - fmt.Printf(" UUID: %s\n", user.UUID) - fmt.Printf(" Nickname Hash: %s\n", user.NicknameHash) - fmt.Printf(" Groups: %v\n", user.Groups) - fmt.Printf(" Created: %s\n", time.Unix(user.CreatedAt, 0).Format(time.RFC3339)) - fmt.Printf(" Updated: %s\n", time.Unix(user.UpdatedAt, 0).Format(time.RFC3339)) -} - -func (c *KVSClient) handleUserCreate(args []string) { - if len(args) < 1 { - fmt.Println(red("Usage: user create ")) - return - } - - // The request body requires a JSON object like {"nickname": "..."} - requestPayload := map[string]string{ - "nickname": args[0], - } - - respBody, status, err := c.doRequest("POST", "/api/users", requestPayload) - if err != nil { - fmt.Println(red("Error:"), err) - return - } - - // The backend returns 200 OK on success - if status != http.StatusOK { - fmt.Printf(red("Error creating user (status %d):\n"), status) - fmt.Println(string(respBody)) - return - } - - // Parse the successful response to get the new user's UUID - var response struct { - UUID string `json:"uuid"` - } - if err := json.Unmarshal(respBody, &response); err != nil { - fmt.Println(red("Error parsing successful response:"), err) - return - } - - fmt.Println(green("User created successfully")) - fmt.Println(cyan("UUID:"), response.UUID) -} - -// printJSONError displays a user-friendly JSON parsing error with helpful hints -func printJSONError(input string, err error) { - fmt.Println(red("❌ Invalid JSON:"), err) - fmt.Println() - - // Show what was received - if len(input) > 100 { - fmt.Println(yellow("Received:"), input[:100]+"...") - } else { - fmt.Println(yellow("Received:"), input) - } - fmt.Println() - - // Provide helpful hints based on common errors - errMsg := err.Error() - - if strings.Contains(errMsg, "looking for beginning of") || - strings.Contains(errMsg, "invalid character") { - fmt.Println(cyan("💡 Tip:"), "JSON with spaces needs quotes:") - fmt.Println(green(" ✓ Correct:"), "put key '{\"hello\":\"world\"}'") - fmt.Println(green(" ✓ Correct:"), "put key '{\"a\":1,\"b\":2}'") - fmt.Println(green(" ✓ Compact:"), "put key {\"a\":1,\"b\":2} (no spaces)") - fmt.Println(red(" ✗ Wrong: "), "put key {\"a\": 1, \"b\": 2} (spaces without quotes)") - } else if strings.Contains(errMsg, "unexpected end of JSON") { - fmt.Println(cyan("💡 Tip:"), "JSON appears incomplete or cut off") - fmt.Println(green(" Check:"), "Are all brackets/braces matched? {}, []") - fmt.Println(green(" Check:"), "Did spaces split your JSON into multiple arguments?") - } else { - fmt.Println(cyan("💡 Tip:"), "Wrap JSON in quotes for complex values:") - fmt.Println(green(" Single quotes:"), "put key '{\"your\":\"json\"}'") - fmt.Println(green(" Double quotes:"), "put key \"{\\\"your\\\":\\\"json\\\"}\"") - } -} - -func (c *KVSClient) handleHelp(args []string) { - help := ` -KVS Interactive Shell - Available Commands: - -Connection & Authentication: - connect - Connect to KVS server - auth - Set authentication token - profile add [url] - Add user profile - profile use - Switch to user profile - profile remove - Remove user profile - profile - List all profiles - -Key-Value Operations: - get - Retrieve value for key - put - Store JSON value at key - delete - Delete key - -Resource Metadata: - meta get - Get metadata (owner, group) for a key - meta set [flags] - Set metadata for a key - Flags: --owner , --group , --permissions - -Cluster Management: - members - List cluster members - health - Check service health - -User Management: - user get - Get user details - user create - Create new user (admin only) - -System: - help - Show this help - exit, quit - Exit shell - clear - Clear screen -` - fmt.Println(help) -} - func main() { client := NewKVSClient("http://localhost:8090") @@ -722,7 +85,6 @@ func main() { fmt.Println("Type 'help' for available commands") } } else { - // This was the original behavior, which notes that listing users is not implemented. client.handleUserList(args) } default: @@ -731,75 +93,3 @@ func main() { } } } - -// Auto-completion -var completer = readline.NewPrefixCompleter( - readline.PcItem("connect"), - readline.PcItem("auth"), - readline.PcItem("profile", - readline.PcItem("add"), - readline.PcItem("use"), - readline.PcItem("remove"), - ), - readline.PcItem("get"), - readline.PcItem("put"), - readline.PcItem("delete"), - readline.PcItem("meta", - readline.PcItem("get"), - readline.PcItem("set", - readline.PcItem("--owner"), - readline.PcItem("--group"), - readline.PcItem("--permissions"), - ), - ), - readline.PcItem("members"), - readline.PcItem("health"), - readline.PcItem("user", - readline.PcItem("get"), - readline.PcItem("create"), - ), - readline.PcItem("help"), - readline.PcItem("exit"), - readline.PcItem("quit"), - readline.PcItem("clear"), -) - -// Parse command line respecting quotes -func parseCommand(line string) []string { - var parts []string - var current strings.Builder - var quoteChar rune = 0 // Use a rune to store the active quote character (' or ") - - for _, ch := range line { - switch { - // If we see a quote character - case ch == '\'' || ch == '"': - if quoteChar == 0 { - // Not currently inside a quoted string, so start one - quoteChar = ch - } else if quoteChar == ch { - // Inside a quoted string and found the matching quote, so end it - quoteChar = 0 - } else { - // Inside a quoted string but found the *other* type of quote, treat it as a literal character - current.WriteRune(ch) - } - // If we see a space or tab and are NOT inside a quoted string - case (ch == ' ' || ch == '\t') && quoteChar == 0: - if current.Len() > 0 { - parts = append(parts, current.String()) - current.Reset() - } - // Any other character - default: - current.WriteRune(ch) - } - } - - // Add the last part if it exists - if current.Len() > 0 { - parts = append(parts, current.String()) - } - - return parts -} diff --git a/types.go b/types.go new file mode 100644 index 0000000..ed9f508 --- /dev/null +++ b/types.go @@ -0,0 +1,42 @@ +package main + +import "encoding/json" + +// Types matching the backend API + +type StoredValue struct { + UUID string `json:"uuid"` + Timestamp int64 `json:"timestamp"` + Data json.RawMessage `json:"data"` +} + +type Member struct { + ID string `json:"id"` + Address string `json:"address"` + LastSeen int64 `json:"last_seen"` + JoinedTimestamp int64 `json:"joined_timestamp"` +} + +type User struct { + UUID string `json:"uuid"` + NicknameHash string `json:"nickname_hash"` + Groups []string `json:"groups"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +type Group struct { + UUID string `json:"uuid"` + NameHash string `json:"name_hash"` + Members []string `json:"members"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +// Profile represents a saved user configuration +type Profile struct { + Name string `json:"name"` + Token string `json:"token"` + UserUUID string `json:"user_uuid"` + BaseURL string `json:"base_url"` +} diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..d1b9718 --- /dev/null +++ b/utils.go @@ -0,0 +1,124 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/chzyer/readline" + "github.com/fatih/color" +) + +// Color helpers +var ( + cyan = color.New(color.FgCyan).SprintFunc() + green = color.New(color.FgGreen).SprintFunc() + yellow = color.New(color.FgYellow).SprintFunc() + red = color.New(color.FgRed).SprintFunc() + magenta = color.New(color.FgMagenta).SprintFunc() +) + +// parseCommand parses command line respecting quotes +func parseCommand(line string) []string { + var parts []string + var current strings.Builder + var quoteChar rune = 0 // Use a rune to store the active quote character (' or ") + + for _, ch := range line { + switch { + // If we see a quote character + case ch == '\'' || ch == '"': + if quoteChar == 0 { + // Not currently inside a quoted string, so start one + quoteChar = ch + } else if quoteChar == ch { + // Inside a quoted string and found the matching quote, so end it + quoteChar = 0 + } else { + // Inside a quoted string but found the *other* type of quote, treat it as a literal character + current.WriteRune(ch) + } + // If we see a space or tab and are NOT inside a quoted string + case (ch == ' ' || ch == '\t') && quoteChar == 0: + if current.Len() > 0 { + parts = append(parts, current.String()) + current.Reset() + } + // Any other character + default: + current.WriteRune(ch) + } + } + + // Add the last part if it exists + if current.Len() > 0 { + parts = append(parts, current.String()) + } + + return parts +} + +// printJSONError displays a user-friendly JSON parsing error with helpful hints +func printJSONError(input string, err error) { + fmt.Println(red("❌ Invalid JSON:"), err) + fmt.Println() + + // Show what was received + if len(input) > 100 { + fmt.Println(yellow("Received:"), input[:100]+"...") + } else { + fmt.Println(yellow("Received:"), input) + } + fmt.Println() + + // Provide helpful hints based on common errors + errMsg := err.Error() + + if strings.Contains(errMsg, "looking for beginning of") || + strings.Contains(errMsg, "invalid character") { + fmt.Println(cyan("💡 Tip:"), "JSON with spaces needs quotes:") + fmt.Println(green(" ✓ Correct:"), "put key '{\"hello\":\"world\"}'") + fmt.Println(green(" ✓ Correct:"), "put key '{\"a\":1,\"b\":2}'") + fmt.Println(green(" ✓ Compact:"), "put key {\"a\":1,\"b\":2} (no spaces)") + fmt.Println(red(" ✗ Wrong: "), "put key {\"a\": 1, \"b\": 2} (spaces without quotes)") + } else if strings.Contains(errMsg, "unexpected end of JSON") { + fmt.Println(cyan("💡 Tip:"), "JSON appears incomplete or cut off") + fmt.Println(green(" Check:"), "Are all brackets/braces matched? {}, []") + fmt.Println(green(" Check:"), "Did spaces split your JSON into multiple arguments?") + } else { + fmt.Println(cyan("💡 Tip:"), "Wrap JSON in quotes for complex values:") + fmt.Println(green(" Single quotes:"), "put key '{\"your\":\"json\"}'") + fmt.Println(green(" Double quotes:"), "put key \"{\\\"your\\\":\\\"json\\\"}\"") + } +} + +// Auto-completion setup +var completer = readline.NewPrefixCompleter( + readline.PcItem("connect"), + readline.PcItem("auth"), + readline.PcItem("profile", + readline.PcItem("add"), + readline.PcItem("use"), + readline.PcItem("remove"), + ), + readline.PcItem("get"), + readline.PcItem("put"), + readline.PcItem("delete"), + readline.PcItem("meta", + readline.PcItem("get"), + readline.PcItem("set", + readline.PcItem("--owner"), + readline.PcItem("--group"), + readline.PcItem("--permissions"), + ), + ), + readline.PcItem("members"), + readline.PcItem("health"), + readline.PcItem("user", + readline.PcItem("get"), + readline.PcItem("create"), + ), + readline.PcItem("help"), + readline.PcItem("exit"), + readline.PcItem("quit"), + readline.PcItem("clear"), +)