refactor: split monolithic main.go into modular structure

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 <noreply@anthropic.com>
This commit is contained in:
2025-10-05 23:34:18 +03:00
parent fcd27dee97
commit 0c9e314d7c
10 changed files with 764 additions and 710 deletions

61
client.go Normal file
View File

@@ -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
}

61
cmd_cluster.go Normal file
View File

@@ -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)
}
}

125
cmd_kv.go Normal file
View File

@@ -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 <key>"))
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 <key> <json-data>"))
fmt.Println("Or: put <key> (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 <key>"))
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)
}
}

137
cmd_meta.go Normal file
View File

@@ -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 <get|set> [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 <key>"))
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 <key> --owner <uuid> | --group <uuid> | --permissions <number>"))
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())
}

69
cmd_profile.go Normal file
View File

@@ -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 <name> <token> <user-uuid> [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 <name>"))
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 <name>"))
return
}
delete(c.profiles, args[1])
if c.activeProfile == args[1] {
c.activeProfile = ""
}
fmt.Println(green("Profile removed:"), args[1])
}
}

59
cmd_system.go Normal file
View File

@@ -0,0 +1,59 @@
package main
import "fmt"
func (c *KVSClient) handleConnect(args []string) {
if len(args) < 1 {
fmt.Println(red("Usage: connect <base-url>"))
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 <token>"))
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 <url> - Connect to KVS server
auth <token> - Set authentication token
profile add <name> <token> <user-uuid> [url] - Add user profile
profile use <name> - Switch to user profile
profile remove <name> - Remove user profile
profile - List all profiles
Key-Value Operations:
get <key> - Retrieve value for key
put <key> <json> - Store JSON value at key
delete <key> - Delete key
Resource Metadata:
meta get <key> - Get metadata (owner, group) for a key
meta set <key> [flags] - Set metadata for a key
Flags: --owner <uuid>, --group <uuid>, --permissions <number>
Cluster Management:
members - List cluster members
health - Check service health
User Management:
user get <uuid> - Get user details
user create <nickname> - Create new user (admin only)
System:
help - Show this help
exit, quit - Exit shell
clear - Clear screen
`
fmt.Println(help)
}

86
cmd_user.go Normal file
View File

@@ -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 <uuid>"))
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 <nickname>"))
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)
}

710
main.go
View File

@@ -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 <base-url>"))
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 <token>"))
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 <name> <token> <user-uuid> [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 <name>"))
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 <name>"))
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 <key>"))
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 <key> <json-data>"))
fmt.Println("Or: put <key> (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 <key>"))
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 <get|set> [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 <key>"))
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 <key> --owner <uuid> | --group <uuid> | --permissions <number>"))
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 <uuid>"))
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 <nickname>"))
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 <url> - Connect to KVS server
auth <token> - Set authentication token
profile add <name> <token> <user-uuid> [url] - Add user profile
profile use <name> - Switch to user profile
profile remove <name> - Remove user profile
profile - List all profiles
Key-Value Operations:
get <key> - Retrieve value for key
put <key> <json> - Store JSON value at key
delete <key> - Delete key
Resource Metadata:
meta get <key> - Get metadata (owner, group) for a key
meta set <key> [flags] - Set metadata for a key
Flags: --owner <uuid>, --group <uuid>, --permissions <number>
Cluster Management:
members - List cluster members
health - Check service health
User Management:
user get <uuid> - Get user details
user create <nickname> - 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
}

42
types.go Normal file
View File

@@ -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"`
}

124
utils.go Normal file
View File

@@ -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"),
)