Skip to content

Commit 4b639fd

Browse files
committed
feat(rpc): add two-layer RPC system with CommandTable, Fiber HTTP server and corsa-cli
1 parent a99fb10 commit 4b639fd

34 files changed

Lines changed: 7172 additions & 45 deletions

Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,6 @@ USER corsa
2424
ENV CORSA_LISTEN_ADDRESS=:64646
2525
VOLUME ["/home/corsa/.corsa"]
2626
EXPOSE 64646/tcp
27+
EXPOSE 46464/tcp
2728

2829
ENTRYPOINT ["/usr/local/bin/corsa-node"]

Makefile

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,5 +75,24 @@ build-node-all: build-node-macos-arm64 build-node-macos-amd64 build-node-linux-a
7575
.PHONY: build-desktop-all
7676
build-desktop-all: build-desktop-macos-arm64 build-desktop-macos-amd64 build-desktop-linux-amd64 build-desktop-windows-amd64
7777

78+
.PHONY: build-cli-macos-arm64
79+
build-cli-macos-arm64: build-dirs
80+
GOCACHE=$(GOCACHE) GOMODCACHE=$(GOMODCACHE) GOOS=darwin GOARCH=arm64 $(GO) build -o $(DIST_DIR)/corsa-cli-darwin-arm64 ./cmd/corsa-cli
81+
82+
.PHONY: build-cli-macos-amd64
83+
build-cli-macos-amd64: build-dirs
84+
GOCACHE=$(GOCACHE) GOMODCACHE=$(GOMODCACHE) GOOS=darwin GOARCH=amd64 $(GO) build -o $(DIST_DIR)/corsa-cli-darwin-amd64 ./cmd/corsa-cli
85+
86+
.PHONY: build-cli-linux-amd64
87+
build-cli-linux-amd64: build-dirs
88+
GOCACHE=$(GOCACHE) GOMODCACHE=$(GOMODCACHE) GOOS=linux GOARCH=amd64 $(GO) build -o $(DIST_DIR)/corsa-cli-linux-amd64 ./cmd/corsa-cli
89+
90+
.PHONY: build-cli-windows-amd64
91+
build-cli-windows-amd64: build-dirs
92+
GOCACHE=$(GOCACHE) GOMODCACHE=$(GOMODCACHE) GOOS=windows GOARCH=amd64 $(GO) build -o $(DIST_DIR)/corsa-cli-windows-amd64.exe ./cmd/corsa-cli
93+
94+
.PHONY: build-cli-all
95+
build-cli-all: build-cli-macos-arm64 build-cli-macos-amd64 build-cli-linux-amd64 build-cli-windows-amd64
96+
7897
.PHONY: build-all
79-
build-all: build-node-all build-desktop-all
98+
build-all: build-node-all build-desktop-all build-cli-all

cmd/corsa-cli/main.go

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"flag"
7+
"fmt"
8+
"io"
9+
"net"
10+
"net/http"
11+
"os"
12+
"strings"
13+
"time"
14+
15+
"corsa/internal/core/rpc"
16+
)
17+
18+
func main() {
19+
host := flag.String("host", "127.0.0.1", "RPC server host")
20+
port := flag.String("port", "46464", "RPC server port")
21+
username := flag.String("username", "", "RPC username")
22+
password := flag.String("password", "", "RPC password")
23+
named := flag.Bool("named", false, "Interpret arguments as key=value named parameters")
24+
flag.Parse()
25+
26+
args := flag.Args()
27+
if len(args) == 0 {
28+
fmt.Fprintln(os.Stderr, "usage: corsa-cli [flags] <command> [args...]")
29+
fmt.Fprintln(os.Stderr, " corsa-cli help — list all commands")
30+
fmt.Fprintln(os.Stderr, " corsa-cli <command> arg1 arg2 ... — positional arguments")
31+
fmt.Fprintln(os.Stderr, " corsa-cli <command> key=value ... — named arguments")
32+
fmt.Fprintln(os.Stderr, " corsa-cli <command> '{\"key\": \"value\"}' — JSON arguments")
33+
fmt.Fprintln(os.Stderr, " corsa-cli -named <command> key=value ... — explicit key=value mode")
34+
os.Exit(1)
35+
}
36+
37+
if (*username == "") != (*password == "") {
38+
fmt.Fprintln(os.Stderr, "error: both --username and --password must be set, or neither")
39+
os.Exit(1)
40+
}
41+
42+
command := strings.ToLower(args[0])
43+
cmdArgs, err := parseArgs(command, args[1:], *named)
44+
if err != nil {
45+
fmt.Fprintf(os.Stderr, "error: %v\n", err)
46+
os.Exit(1)
47+
}
48+
49+
baseURL := "http://" + net.JoinHostPort(*host, *port)
50+
result, statusCode, err := execRPC(baseURL, command, cmdArgs, *username, *password)
51+
if err != nil {
52+
fmt.Fprintf(os.Stderr, "error: %v\n", err)
53+
os.Exit(1)
54+
}
55+
56+
var prettyJSON bytes.Buffer
57+
if err := json.Indent(&prettyJSON, result, "", " "); err != nil {
58+
fmt.Print(string(result))
59+
} else {
60+
fmt.Println(prettyJSON.String())
61+
}
62+
63+
if statusCode >= 400 {
64+
os.Exit(1)
65+
}
66+
}
67+
68+
// parseArgs converts CLI arguments into a named args map.
69+
//
70+
// Four modes:
71+
// 1. No extra args → nil (no args field in request)
72+
// 2. Single arg that looks like JSON object → parse as map
73+
// 3. -named flag → key=value pairs parsed into map
74+
// 4. All args are key=value pairs → parsed into map (auto-detected)
75+
// 5. Otherwise → positional args, delegated to ParseConsoleInput,
76+
// the shared parser that maps positional args to named fields.
77+
// This matches what the desktop console and rpc.Client use.
78+
func parseArgs(command string, args []string, named bool) (map[string]interface{}, error) {
79+
if len(args) == 0 {
80+
return nil, nil
81+
}
82+
83+
if named {
84+
return parseNamedArgs(args)
85+
}
86+
87+
// Single argument starting with '{' — treat as raw JSON
88+
if len(args) == 1 && strings.HasPrefix(strings.TrimSpace(args[0]), "{") {
89+
var m map[string]interface{}
90+
if err := json.Unmarshal([]byte(args[0]), &m); err != nil {
91+
return nil, fmt.Errorf("invalid JSON argument: %w", err)
92+
}
93+
return m, nil
94+
}
95+
96+
// Key=value mode: only when ALL args are valid key=value pairs
97+
// (each has '=' with a non-empty key before it). If any arg is bare,
98+
// treat the whole input as positional — body text like "a=b" is common
99+
// and must not trigger key=value mode.
100+
allKeyValue := true
101+
for _, arg := range args {
102+
idx := strings.IndexByte(arg, '=')
103+
if idx < 1 {
104+
allKeyValue = false
105+
break
106+
}
107+
}
108+
if allKeyValue {
109+
return parseNamedArgs(args)
110+
}
111+
112+
// Bare positional args — reconstruct console input and delegate to
113+
// ParseConsoleInput, the single source of truth for positional mapping.
114+
input := command + " " + strings.Join(args, " ")
115+
req, err := rpc.ParseConsoleInput(input)
116+
if err != nil {
117+
return nil, err
118+
}
119+
return req.Args, nil
120+
}
121+
122+
// parseNamedArgs converts key=value pairs into a map.
123+
func parseNamedArgs(args []string) (map[string]interface{}, error) {
124+
m := make(map[string]interface{}, len(args))
125+
for _, arg := range args {
126+
idx := strings.IndexByte(arg, '=')
127+
if idx < 1 {
128+
return nil, fmt.Errorf("expected key=value, got %q (use -named or pass JSON)", arg)
129+
}
130+
key := arg[:idx]
131+
value := arg[idx+1:]
132+
133+
// Try to parse as JSON value (number, bool, null, object, array).
134+
// Fall back to plain string on parse failure.
135+
var parsed interface{}
136+
if err := json.Unmarshal([]byte(value), &parsed); err == nil {
137+
m[key] = parsed
138+
} else {
139+
m[key] = value
140+
}
141+
}
142+
return m, nil
143+
}
144+
145+
// execRPC sends a command to POST /rpc/v1/exec and returns raw response.
146+
func execRPC(baseURL, command string, args map[string]interface{}, username, password string) ([]byte, int, error) {
147+
reqBody := map[string]interface{}{
148+
"command": command,
149+
}
150+
if args != nil {
151+
reqBody["args"] = args
152+
}
153+
154+
data, err := json.Marshal(reqBody)
155+
if err != nil {
156+
return nil, 0, fmt.Errorf("marshal request: %w", err)
157+
}
158+
159+
client := &http.Client{Timeout: 10 * time.Second}
160+
req, err := http.NewRequest("POST", baseURL+"/rpc/v1/exec", bytes.NewReader(data))
161+
if err != nil {
162+
return nil, 0, fmt.Errorf("create request: %w", err)
163+
}
164+
req.Header.Set("Content-Type", "application/json")
165+
if username != "" && password != "" {
166+
req.SetBasicAuth(username, password)
167+
}
168+
169+
resp, err := client.Do(req)
170+
if err != nil {
171+
return nil, 0, fmt.Errorf("request failed: %w", err)
172+
}
173+
defer func() { _ = resp.Body.Close() }()
174+
175+
body, err := io.ReadAll(resp.Body)
176+
if err != nil {
177+
return nil, 0, fmt.Errorf("read response: %w", err)
178+
}
179+
180+
if resp.StatusCode == http.StatusUnauthorized {
181+
return nil, resp.StatusCode, fmt.Errorf("unauthorized: check RPC username/password")
182+
}
183+
184+
return body, resp.StatusCode, nil
185+
}

0 commit comments

Comments
 (0)