Files
kvs-sh/main.go
2025-10-05 20:03:35 +03:00

555 lines
12 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 {
fmt.Println(red("Invalid JSON:"), 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))
}
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
}