589 lines
14 KiB
Go
589 lines
14 KiB
Go
// kvs-shell/main.go
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"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) < 2 {
|
|
fmt.Println(red("Usage: put <key> <json-data>"))
|
|
return
|
|
}
|
|
|
|
// Parse JSON data
|
|
var data json.RawMessage
|
|
if err := json.Unmarshal([]byte(args[1]), &data); err != nil {
|
|
printJSONError(args[1], 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)
|
|
}
|
|
}
|
|
|
|
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))
|
|
}
|
|
|
|
// 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
|
|
|
|
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")
|
|
|
|
// Setup readline
|
|
rl, err := readline.NewEx(&readline.Config{
|
|
Prompt: cyan("kvs> "),
|
|
HistoryFile: os.Getenv("HOME") + "/.kvs_history",
|
|
AutoComplete: completer,
|
|
InterruptPrompt: "^C",
|
|
EOFPrompt: "exit",
|
|
})
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
defer rl.Close()
|
|
|
|
fmt.Println(magenta("KVS Interactive Shell"))
|
|
fmt.Println("Type 'help' for available commands")
|
|
fmt.Println()
|
|
|
|
for {
|
|
line, err := rl.Readline()
|
|
if err != nil {
|
|
break
|
|
}
|
|
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
parts := parseCommand(line)
|
|
if len(parts) == 0 {
|
|
continue
|
|
}
|
|
|
|
cmd := parts[0]
|
|
args := parts[1:]
|
|
|
|
switch cmd {
|
|
case "exit", "quit":
|
|
fmt.Println("Goodbye!")
|
|
return
|
|
case "clear":
|
|
print("\033[H\033[2J")
|
|
case "help":
|
|
client.handleHelp(args)
|
|
case "connect":
|
|
client.handleConnect(args)
|
|
case "auth":
|
|
client.handleAuth(args)
|
|
case "profile":
|
|
client.handleProfile(args)
|
|
case "get":
|
|
client.handleGet(args)
|
|
case "put":
|
|
client.handlePut(args)
|
|
case "delete":
|
|
client.handleDelete(args)
|
|
case "members":
|
|
client.handleMembers(args)
|
|
case "health":
|
|
client.handleHealth(args)
|
|
case "user":
|
|
if len(args) > 0 && args[0] == "get" {
|
|
client.handleUserGet(args[1:])
|
|
} else {
|
|
client.handleUserList(args)
|
|
}
|
|
default:
|
|
fmt.Println(red("Unknown command:"), cmd)
|
|
fmt.Println("Type 'help' for available commands")
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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("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
|
|
inQuotes := false
|
|
|
|
for i := 0; i < len(line); i++ {
|
|
ch := line[i]
|
|
switch ch {
|
|
case '"':
|
|
inQuotes = !inQuotes
|
|
case ' ', '\t':
|
|
if inQuotes {
|
|
current.WriteByte(ch)
|
|
} else if current.Len() > 0 {
|
|
parts = append(parts, current.String())
|
|
current.Reset()
|
|
}
|
|
default:
|
|
current.WriteByte(ch)
|
|
}
|
|
}
|
|
|
|
if current.Len() > 0 {
|
|
parts = append(parts, current.String())
|
|
}
|
|
|
|
return parts
|
|
}
|