draftgo-cli 4.0.1 → 4.0.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +87 -11
  2. package/package.json +9 -4
  3. package/resources/custom-service-sdk/ai.go +520 -0
  4. package/resources/custom-service-sdk/ai_test.go +156 -0
  5. package/resources/custom-service-sdk/billing.go +596 -0
  6. package/resources/custom-service-sdk/billing_test.go +150 -0
  7. package/resources/custom-service-sdk/go.mod +3 -0
  8. package/resources/custom-service-sdk/manifest.json +72 -0
  9. package/resources/custom-service-sdk/platform.go +360 -0
  10. package/resources/custom-service-sdk/platform_logger_test.go +24 -0
  11. package/resources/custom-service-sdk/registration_test.go +39 -0
  12. package/resources/custom-service-sdk/resources.go +246 -0
  13. package/resources/custom-service-sdk/resources_billing_test.go +115 -0
  14. package/resources/custom-service-sdk/resources_files_test.go +57 -0
  15. package/resources/custom-service-sdk/resources_scope_test.go +87 -0
  16. package/resources/custom-service-sdk/sdk.go +208 -0
  17. package/resources/skill/SKILL.md +36 -88
  18. package/resources/skill/init/SKILL.md +4 -4
  19. package/resources/skill/manifest.json +5 -1
  20. package/resources/skill/references/aihub.md +25 -2
  21. package/resources/skill/references/app-api.md +56 -6
  22. package/resources/skill/references/architecture.md +2 -2
  23. package/resources/skill/references/chat-sdk.md +4 -2
  24. package/resources/skill/references/checkout.md +17 -3
  25. package/resources/skill/references/custom-services.md +111 -46
  26. package/resources/skill/references/data.md +19 -4
  27. package/resources/skill/references/delivery.md +33 -0
  28. package/resources/skill/references/diagnostics.md +51 -0
  29. package/resources/skill/references/frontend.md +34 -46
  30. package/resources/skill/references/mcp.md +33 -5
  31. package/resources/skill/references/methods.md +189 -0
  32. package/resources/skill/references/modules.md +36 -8
  33. package/resources/skill/references/runtime.md +23 -1
  34. package/src/cli.js +24 -0
  35. package/src/commandRegistry.js +9 -1
  36. package/src/commands/api.js +21 -10
  37. package/src/commands/apiKey.js +34 -0
  38. package/src/commands/capabilities.js +93 -0
  39. package/src/commands/checkout.js +1 -1
  40. package/src/commands/commit.js +1 -1
  41. package/src/commands/components.js +550 -0
  42. package/src/commands/conflict.js +1 -1
  43. package/src/commands/connect.js +18 -8
  44. package/src/commands/customService.js +20 -4
  45. package/src/commands/dataRange.js +33 -0
  46. package/src/commands/delete.js +12 -1
  47. package/src/commands/diff.js +18 -2
  48. package/src/commands/grant.js +29 -0
  49. package/src/commands/group.js +38 -0
  50. package/src/commands/help.js +64 -20
  51. package/src/commands/init.js +3 -3
  52. package/src/commands/map.js +145 -17
  53. package/src/commands/mcp.js +2 -2
  54. package/src/commands/reconcile.js +1 -1
  55. package/src/commands/role.js +32 -0
  56. package/src/commands/space.js +41 -0
  57. package/src/commands/status.js +110 -7
  58. package/src/commands/update.js +23 -11
  59. package/src/commands/verify.js +75 -0
  60. package/src/commands/worklog.js +6 -2
  61. package/src/consoleEncoding.js +34 -0
  62. package/src/contractCompatibility.js +57 -0
  63. package/src/customServices.js +138 -18
  64. package/src/diffReport.js +106 -0
  65. package/src/index.js +2 -0
  66. package/src/localRuntime/compose.js +14 -17
  67. package/src/localRuntime/index.js +22 -23
  68. package/src/localRuntime/services.js +27 -36
  69. package/src/mcp/client.js +11 -2
  70. package/src/mcp/protocol.js +2 -2
  71. package/src/mcp/tools.js +14 -1
  72. package/src/platforms.js +9 -0
  73. package/src/projectConfig.js +6 -4
  74. package/src/releaseInstall.js +105 -0
  75. package/src/updateCheck.js +48 -28
  76. package/src/worklog.js +2 -1
  77. package/src/worktree/backend.js +1 -1
  78. package/src/worktree/index.js +7 -2
@@ -0,0 +1,150 @@
1
+ package sdk
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "testing"
7
+ )
8
+
9
+ func TestBillingSDKCarriesStableOperationsAndIdempotency(t *testing.T) {
10
+ client := &recordingClient{}
11
+ draftgo := NewContext(context.Background(), nil, nil, nil, client)
12
+
13
+ _, err := draftgo.Billing.CreatePaymentOrder(draftgo.Context(), PaymentOrderRequest{
14
+ BusinessOrderID: "biz-1", AmountMinor: 100, Provider: "wechat", IdempotencyKey: "pay-1",
15
+ })
16
+ if err != nil || client.op != "billing.payment.create" {
17
+ t.Fatalf("create operation=%q err=%v", client.op, err)
18
+ }
19
+ if client.args["idempotency_key"] != "pay-1" || client.args["business_order_id"] != "biz-1" {
20
+ t.Fatalf("payment contract lost stable fields: %#v", client.args)
21
+ }
22
+ _, err = draftgo.Billing.RefundPaymentOrder(draftgo.Context(), RefundRequest{PaymentOrderID: "42", AmountMinor: 50, IdempotencyKey: "refund-42"})
23
+ if err != nil || client.op != "billing.payment.refund" || client.args["payment_order_id"] != "42" || client.args["idempotency_key"] != "refund-42" {
24
+ t.Fatalf("refund operation=%q args=%#v err=%v", client.op, client.args, err)
25
+ }
26
+ _, err = draftgo.Billing.ListPaymentRefunds(draftgo.Context(), "42")
27
+ if err != nil || client.op != "billing.payment.refunds.list" || client.args["payment_order_id"] != "42" {
28
+ t.Fatalf("refund list operation=%q err=%v", client.op, err)
29
+ }
30
+
31
+ _, err = draftgo.Billing.StartAIBilling(draftgo.Context(), AIBillingStartRequest{
32
+ IdempotencyKey: "ai-1", RequestID: "request-1", Model: "model-a", EstimatedMinor: 20,
33
+ })
34
+ if err != nil || client.op != "billing.ai.session.start" {
35
+ t.Fatalf("AI start operation=%q err=%v", client.op, err)
36
+ }
37
+ if client.args["idempotency_key"] != "ai-1" || client.args["request_id"] != "request-1" {
38
+ t.Fatalf("AI billing contract lost idempotency/request fields: %#v", client.args)
39
+ }
40
+
41
+ _, err = draftgo.Billing.SettleAIBilling(draftgo.Context(), AISettlementRequest{
42
+ SessionID: "session-1", Usage: map[string]any{"requests": 1}, IdempotencyKey: "settle-1",
43
+ })
44
+ if err != nil || client.op != "billing.ai.session.settle" {
45
+ t.Fatalf("AI settle operation=%q err=%v", client.op, err)
46
+ }
47
+ if client.args["session_id"] != "session-1" || client.args["idempotency_key"] != "settle-1" {
48
+ t.Fatalf("AI settlement contract lost stable fields: %#v", client.args)
49
+ }
50
+ }
51
+
52
+ // Keep this test tied to JSON-compatible SDK payloads: custom services cross
53
+ // an RPC boundary and must not depend on Go-only values.
54
+ func TestBillingSDKRequestsAreJSONCompatible(t *testing.T) {
55
+ payload := PaymentOrderRequest{BusinessOrderID: "biz-1", AmountMinor: 1, IdempotencyKey: "k"}
56
+ if _, err := json.Marshal(payload); err != nil {
57
+ t.Fatalf("payment request is not JSON compatible: %v", err)
58
+ }
59
+ settlement := AISettlementRequest{SessionID: "s-1", Usage: map[string]any{"input_tokens": int64(2)}, IdempotencyKey: "k"}
60
+ if _, err := json.Marshal(settlement); err != nil {
61
+ t.Fatalf("AI settlement is not JSON compatible: %v", err)
62
+ }
63
+ }
64
+
65
+ func TestPaymentOrderParserPreservesFulfillmentMetadata(t *testing.T) {
66
+ order := paymentOrder(map[string]any{
67
+ "id": "42", "business_order_id": "biz-42", "ownership_type": "space", "workspace_id": float64(2), "space_id": float64(8), "owner_user_id": float64(7),
68
+ "payer_user_id": float64(9), "created_by": float64(7), "provider": "wechat", "provider_trade_no": "trade-42", "status": "paid",
69
+ "amount_minor": float64(1288), "product_ref": "product:pro", "fulfillment_ref": "subscription:3",
70
+ "metadata": map[string]any{"sku": "pro"}, "created_at": "2026-08-14T10:00:00Z", "paid_at": "2026-08-14T10:01:00Z",
71
+ })
72
+ if order.Ownership.ScopeType != ScopeSpace || order.Ownership.WorkspaceID != 2 || order.Ownership.SpaceID != 8 || order.Ownership.OwnerUserID != 7 || order.PayerUserID != 9 || order.ProductRef != "product:pro" || order.FulfillmentRef != "subscription:3" || order.Metadata["sku"] != "pro" || order.ProviderTradeNo != "trade-42" {
73
+ t.Fatalf("payment order=%#v", order)
74
+ }
75
+ }
76
+
77
+ func TestBillingSDKPriceVersionOperations(t *testing.T) {
78
+ client := &recordingClient{}
79
+ draftgo := NewContext(context.Background(), nil, nil, nil, client)
80
+ created, err := draftgo.Admin.Billing.CreateAIPriceVersion(draftgo.Context(), AIPriceVersionRequest{Model: "model-a", Version: "v1", BillingMode: "per_call", BillingEnabled: true, RequestPriceMinor: 8})
81
+ if err != nil || client.op != "billing.ai.pricing.create" {
82
+ t.Fatalf("create = %+v, operation=%q, err=%v", created, client.op, err)
83
+ }
84
+ if client.args["__draftgo_admin"] != true || client.args["request_price_minor"] != float64(8) {
85
+ t.Fatalf("create args = %#v", client.args)
86
+ }
87
+ }
88
+
89
+ func TestBillingSDKAICostsAndProviderCost(t *testing.T) {
90
+ client := &recordingClient{}
91
+ draftgo := NewContext(context.Background(), nil, nil, nil, client)
92
+ _, err := draftgo.Admin.Billing.GetAICosts(draftgo.Context(), map[string]any{"model_key": "model-a"})
93
+ if err != nil || client.op != "billing.ai.costs.list" || client.args["__draftgo_admin"] != true {
94
+ t.Fatalf("cost operation=%q args=%#v err=%v", client.op, client.args, err)
95
+ }
96
+ _, err = draftgo.Admin.Billing.CreateAIPriceVersion(draftgo.Context(), AIPriceVersionRequest{Model: "model-a", Version: "v1", ProviderCostMode: "per_call", ProviderRequestCostMinor: 3})
97
+ if err != nil || client.args["provider_cost_mode"] != "per_call" || client.args["provider_request_cost_minor"] != float64(3) {
98
+ t.Fatalf("provider cost args=%#v err=%v", client.args, err)
99
+ }
100
+ }
101
+
102
+ func TestBillingSDKPlanAndSubscriptionOperations(t *testing.T) {
103
+ client := &recordingClient{}
104
+ draftgo := NewContext(context.Background(), nil, nil, nil, client)
105
+
106
+ _, err := draftgo.Admin.Billing.CreatePlan(draftgo.Context(), PlanRequest{
107
+ Code: "pro", Name: "Pro", PriceMinor: 9900, BillingCycle: "monthly",
108
+ Entitlements: map[string]any{"ai.requests": 1000},
109
+ })
110
+ if err != nil || client.op != "billing.plan.create" || client.args["__draftgo_admin"] != true {
111
+ t.Fatalf("create plan operation=%q args=%#v err=%v", client.op, client.args, err)
112
+ }
113
+ if client.args["code"] != "pro" || client.args["price_minor"] != float64(9900) {
114
+ t.Fatalf("create plan args=%#v", client.args)
115
+ }
116
+
117
+ _, err = draftgo.Billing.ListPlans(draftgo.Context(), PlanQuery{Status: "active"})
118
+ if err != nil || client.op != "billing.plan.list" {
119
+ t.Fatalf("list plans operation=%q err=%v", client.op, err)
120
+ }
121
+ query, _ := client.args["query"].(map[string]any)
122
+ if query["status"] != "active" {
123
+ t.Fatalf("list plan query=%#v", query)
124
+ }
125
+
126
+ _, err = draftgo.Admin.Billing.CreateSubscription(draftgo.Context(), SubscriptionRequest{
127
+ PlanID: 3, Subject: BillingSubject{Type: "space", ID: "8"}, SourceType: "payment_order", SourceID: "88", IdempotencyKey: "subscription-88",
128
+ })
129
+ if err != nil || client.op != "billing.subscription.create" || client.args["idempotency_key"] != "subscription-88" {
130
+ t.Fatalf("create subscription operation=%q args=%#v err=%v", client.op, client.args, err)
131
+ }
132
+
133
+ _, err = draftgo.Billing.ListSubscriptions(draftgo.Context(), SubscriptionQuery{Status: "active"})
134
+ if err != nil || client.op != "billing.subscription.list" {
135
+ t.Fatalf("list subscriptions operation=%q err=%v", client.op, err)
136
+ }
137
+
138
+ _, err = draftgo.Admin.Billing.ChangeSubscriptionPlan(draftgo.Context(), 9, SubscriptionChangeRequest{PlanID: 4})
139
+ if err != nil || client.op != "billing.subscription.change_plan" || client.args["id"] != float64(9) {
140
+ t.Fatalf("change subscription operation=%q args=%#v err=%v", client.op, client.args, err)
141
+ }
142
+ _, err = draftgo.Admin.Billing.CancelSubscription(draftgo.Context(), 9)
143
+ if err != nil || client.op != "billing.subscription.cancel" {
144
+ t.Fatalf("cancel subscription operation=%q err=%v", client.op, err)
145
+ }
146
+ _, err = draftgo.Admin.Billing.FulfillSubscriptionPayment(draftgo.Context(), SubscriptionPaymentFulfillmentRequest{PaymentOrderID: "88", PlanID: 3})
147
+ if err != nil || client.op != "billing.subscription.fulfill_payment" || client.args["payment_order_id"] != "88" || client.args["plan_id"] != float64(3) {
148
+ t.Fatalf("fulfill subscription operation=%q args=%#v err=%v", client.op, client.args, err)
149
+ }
150
+ }
@@ -0,0 +1,3 @@
1
+ module draftgo/sdk
2
+
3
+ go 1.26.0
@@ -0,0 +1,72 @@
1
+ {
2
+ "schema_version": 1,
3
+ "module": "draftgo/sdk",
4
+ "fingerprint": "c750610f8d4b1652da259ea436d47d9856104261c9af1b0e3753c073d36d4693",
5
+ "files": [
6
+ {
7
+ "path": "ai.go",
8
+ "sha256": "0d45e652f96769c1f2d371a1c25b03903d09356c600fbd0e6d67190466259248",
9
+ "bytes": 26627
10
+ },
11
+ {
12
+ "path": "ai_test.go",
13
+ "sha256": "d5a906ea3d9d0451f116d2acac54bfb83669e5e471d1d5ada7c0e1fdabef13b6",
14
+ "bytes": 6531
15
+ },
16
+ {
17
+ "path": "billing.go",
18
+ "sha256": "9ce87c80885aa8b8996710622ec0f14cc7a74d3ca78e5ed4168b6c135994d583",
19
+ "bytes": 30976
20
+ },
21
+ {
22
+ "path": "billing_test.go",
23
+ "sha256": "d6632b31d385fe3658dd1fbc74e31a87cc69b23bf97422d8339c5a46bf2d1aa2",
24
+ "bytes": 8009
25
+ },
26
+ {
27
+ "path": "go.mod",
28
+ "sha256": "b89c04833ac41ea06dfbc8c60ebdc1c2e3e628243cf5adbb36b7320acfe2a3ef",
29
+ "bytes": 30
30
+ },
31
+ {
32
+ "path": "platform.go",
33
+ "sha256": "ec0d8621a8bb677578001d029fe191af58df9d275204eced5e8061b339ad085b",
34
+ "bytes": 12373
35
+ },
36
+ {
37
+ "path": "platform_logger_test.go",
38
+ "sha256": "e8eb02e668c62bb317506f0197cfab3e520fd53534f3bb1f99f55155fa24218d",
39
+ "bytes": 540
40
+ },
41
+ {
42
+ "path": "registration_test.go",
43
+ "sha256": "e9fd4ab60a2b6b28c997019ce76b224f7cec52b934d080ebfd94298448acd4f0",
44
+ "bytes": 1031
45
+ },
46
+ {
47
+ "path": "resources.go",
48
+ "sha256": "e633aa1431beddd25b2c999370a770f77c3de7a2d7123687eca75c9ed0d3838f",
49
+ "bytes": 9708
50
+ },
51
+ {
52
+ "path": "resources_billing_test.go",
53
+ "sha256": "3fadeb8b7af0fad43f34e2479e4331a18f022bcdabdcb3b4ba921768c6b6a7ec",
54
+ "bytes": 4859
55
+ },
56
+ {
57
+ "path": "resources_files_test.go",
58
+ "sha256": "f77a3b6e421e20b528a1bfca5e2b164b63b3599bf3566b8f3998fdf72c4c0aa6",
59
+ "bytes": 2551
60
+ },
61
+ {
62
+ "path": "resources_scope_test.go",
63
+ "sha256": "bba3243f348e9418888f37ee2d5b487fc26a31e4a646e75bef46d5d807938cee",
64
+ "bytes": 3592
65
+ },
66
+ {
67
+ "path": "sdk.go",
68
+ "sha256": "95ffcc335416ac842e06ea51b75e6739a04ddd5573a946e4175d6c5f8c110a67",
69
+ "bytes": 8167
70
+ }
71
+ ]
72
+ }
@@ -0,0 +1,360 @@
1
+ package sdk
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "fmt"
8
+ "net/http"
9
+ "sync"
10
+ "time"
11
+ )
12
+
13
+ // Client is implemented by the isolated runner protocol. It is deliberately
14
+ // generic so new platform capabilities do not require exposing host handles.
15
+ type Client interface {
16
+ Call(context.Context, string, any) (any, error)
17
+ }
18
+
19
+ type Database interface {
20
+ Create(string, map[string]any, ...int64) (map[string]any, error)
21
+ CreateMany(string, []map[string]any) ([]map[string]any, error)
22
+ Get(string, int64) (map[string]any, error)
23
+ Update(string, int64, map[string]any) (map[string]any, error)
24
+ UpdateMany(string, []map[string]any) ([]map[string]any, error)
25
+ Delete(string, int64) (bool, error)
26
+ Query(string, QueryOptions) (QueryResult, error)
27
+ }
28
+
29
+ type QueryOptions struct {
30
+ Filters map[string]any
31
+ Page int
32
+ PageSize int
33
+ OrderBy string
34
+ Order string
35
+ }
36
+
37
+ type QueryResult struct {
38
+ Items []map[string]any `json:"items"`
39
+ Total int `json:"total"`
40
+ Page int `json:"page"`
41
+ PageSize int `json:"page_size"`
42
+ }
43
+
44
+ type Users interface {
45
+ Get(int64) (map[string]any, error)
46
+ List(QueryOptions) (QueryResult, error)
47
+ Update(int64, map[string]any) (map[string]any, error)
48
+ }
49
+
50
+ type Auth interface {
51
+ RequireLogin() error
52
+ RequireAdmin() error
53
+ RequireRole(string) error
54
+ CurrentUser() map[string]any
55
+ }
56
+
57
+ type Notifier interface {
58
+ Send(int64, string, string, string) error
59
+ }
60
+
61
+ type Cache interface {
62
+ Get(string) (any, error)
63
+ Set(string, any, time.Duration) error
64
+ Delete(string) (bool, error)
65
+ }
66
+
67
+ type Config interface {
68
+ Get(string, any) (any, error)
69
+ }
70
+
71
+ type HTTPClient interface {
72
+ Get(context.Context, string, http.Header, time.Duration) (HTTPResponse, error)
73
+ Post(context.Context, string, any, http.Header, time.Duration) (HTTPResponse, error)
74
+ Put(context.Context, string, any, http.Header, time.Duration) (HTTPResponse, error)
75
+ Patch(context.Context, string, any, http.Header, time.Duration) (HTTPResponse, error)
76
+ Delete(context.Context, string, http.Header, time.Duration) (HTTPResponse, error)
77
+ }
78
+
79
+ type HTTPResponse struct {
80
+ StatusCode int `json:"status_code"`
81
+ Headers http.Header `json:"headers,omitempty"`
82
+ Data any `json:"data,omitempty"`
83
+ }
84
+
85
+ type LogEntry struct {
86
+ Level string `json:"level"`
87
+ Message string `json:"message"`
88
+ Timestamp time.Time `json:"timestamp"`
89
+ }
90
+
91
+ // Logger is local to one invocation and becomes part of the audited result.
92
+ type Logger struct {
93
+ mu sync.Mutex
94
+ entries []LogEntry
95
+ truncated bool
96
+ }
97
+
98
+ func (log *Logger) append(level, message string) {
99
+ if log == nil {
100
+ return
101
+ }
102
+ log.mu.Lock()
103
+ defer log.mu.Unlock()
104
+ if len(log.entries) < 500 {
105
+ if characters := []rune(message); len(characters) > 4096 {
106
+ message = string(characters[:4096])
107
+ log.truncated = true
108
+ }
109
+ log.entries = append(log.entries, LogEntry{Level: level, Message: message, Timestamp: time.Now()})
110
+ } else {
111
+ log.truncated = true
112
+ }
113
+ }
114
+
115
+ func (log *Logger) Truncated() bool {
116
+ if log == nil {
117
+ return false
118
+ }
119
+ log.mu.Lock()
120
+ defer log.mu.Unlock()
121
+ return log.truncated
122
+ }
123
+ func (log *Logger) Debug(message string) { log.append("debug", message) }
124
+ func (log *Logger) Info(message string) { log.append("info", message) }
125
+ func (log *Logger) Warn(message string) { log.append("warn", message) }
126
+ func (log *Logger) Error(message string) { log.append("error", message) }
127
+ func (log *Logger) Entries() []LogEntry {
128
+ if log == nil {
129
+ return nil
130
+ }
131
+ log.mu.Lock()
132
+ defer log.mu.Unlock()
133
+ out := make([]LogEntry, len(log.entries))
134
+ copy(out, log.entries)
135
+ return out
136
+ }
137
+
138
+ type platformClient struct {
139
+ client Client
140
+ context context.Context
141
+ admin bool
142
+ }
143
+ type dbClient struct{ platformClient }
144
+ type usersClient struct{ platformClient }
145
+ type notifierClient struct{ platformClient }
146
+ type cacheClient struct{ platformClient }
147
+ type configClient struct{ platformClient }
148
+ type httpClient struct{ platformClient }
149
+ type aiHubClient struct{ platformClient }
150
+ type knowledgeClient struct{ platformClient }
151
+ type memoryClient struct{ platformClient }
152
+
153
+ func (p platformClient) call(draftgo context.Context, operation string, args any) (any, error) {
154
+ if p.client == nil {
155
+ return nil, errors.New("DraftGo platform RPC is unavailable")
156
+ }
157
+ if draftgo == nil {
158
+ draftgo = p.context
159
+ }
160
+ if p.admin {
161
+ values, ok := args.(map[string]any)
162
+ if !ok {
163
+ raw, err := json.Marshal(args)
164
+ if err == nil {
165
+ ok = json.Unmarshal(raw, &values) == nil
166
+ }
167
+ }
168
+ if ok {
169
+ cloned := make(map[string]any, len(values)+1)
170
+ for key, value := range values {
171
+ cloned[key] = value
172
+ }
173
+ cloned["__draftgo_admin"] = true
174
+ args = cloned
175
+ }
176
+ }
177
+ return p.client.Call(draftgo, operation, args)
178
+ }
179
+ func mapResult(value any) map[string]any { result, _ := value.(map[string]any); return result }
180
+ func mapSlice(value any) []map[string]any {
181
+ raw, _ := value.([]any)
182
+ result := make([]map[string]any, 0, len(raw))
183
+ for _, item := range raw {
184
+ if object, ok := item.(map[string]any); ok {
185
+ result = append(result, object)
186
+ }
187
+ }
188
+ return result
189
+ }
190
+ func (p dbClient) Create(kind string, data map[string]any, userID ...int64) (map[string]any, error) {
191
+ args := map[string]any{"db_type": kind, "data": data}
192
+ if len(userID) > 0 {
193
+ args["owner_user_id"] = userID[0]
194
+ }
195
+ value, err := p.call(p.context, "db.create", args)
196
+ return mapResult(value), err
197
+ }
198
+ func (p dbClient) CreateMany(kind string, items []map[string]any) ([]map[string]any, error) {
199
+ value, err := p.call(p.context, "db.create_many", map[string]any{"db_type": kind, "items": items})
200
+ return mapSlice(value), err
201
+ }
202
+ func (p dbClient) Get(kind string, id int64) (map[string]any, error) {
203
+ value, err := p.call(p.context, "db.get", map[string]any{"db_type": kind, "record_id": id})
204
+ return mapResult(value), err
205
+ }
206
+ func (p dbClient) Update(kind string, id int64, data map[string]any) (map[string]any, error) {
207
+ value, err := p.call(p.context, "db.update", map[string]any{"db_type": kind, "record_id": id, "data": data})
208
+ return mapResult(value), err
209
+ }
210
+ func (p dbClient) UpdateMany(kind string, items []map[string]any) ([]map[string]any, error) {
211
+ value, err := p.call(p.context, "db.update_many", map[string]any{"db_type": kind, "items": items})
212
+ return mapSlice(value), err
213
+ }
214
+ func (p dbClient) Delete(kind string, id int64) (bool, error) {
215
+ value, err := p.call(p.context, "db.delete", map[string]any{"db_type": kind, "record_id": id})
216
+ ok, _ := value.(bool)
217
+ return ok, err
218
+ }
219
+ func (p dbClient) Query(kind string, options QueryOptions) (QueryResult, error) {
220
+ value, err := p.call(p.context, "db.query", map[string]any{"db_type": kind, "filters": options.Filters, "page": nullablePositive(options.Page), "page_size": nullablePositive(options.PageSize), "order_by": options.OrderBy, "order": options.Order})
221
+ return queryResult(value), err
222
+ }
223
+ func (p usersClient) GetUser(id int64) (map[string]any, error) {
224
+ value, err := p.call(p.context, "users.get", map[string]any{"user_id": id})
225
+ return mapResult(value), err
226
+ }
227
+ func (p usersClient) List(options QueryOptions) (QueryResult, error) {
228
+ value, err := p.call(p.context, "users.list", map[string]any{"filters": options.Filters, "page": nullablePositive(options.Page), "page_size": nullablePositive(options.PageSize)})
229
+ return queryResult(value), err
230
+ }
231
+ func (p usersClient) UpdateUser(id int64, data map[string]any) (map[string]any, error) {
232
+ value, err := p.call(p.context, "users.update", map[string]any{"user_id": id, "data": data})
233
+ return mapResult(value), err
234
+ }
235
+ func (p usersClient) Get(id int64) (map[string]any, error) { return p.GetUser(id) }
236
+ func (p usersClient) Update(id int64, data map[string]any) (map[string]any, error) {
237
+ return p.UpdateUser(id, data)
238
+ }
239
+ func (p notifierClient) Send(userID int64, title, content, noticeType string) error {
240
+ _, err := p.call(p.context, "notify.send", map[string]any{"user_id": userID, "title": title, "content": content, "notice_type": noticeType})
241
+ return err
242
+ }
243
+ func (p cacheClient) GetCache(key string) (any, error) {
244
+ return p.call(p.context, "cache.get", map[string]any{"key": key})
245
+ }
246
+ func (p cacheClient) SetCache(key string, value any, ttl time.Duration) error {
247
+ _, err := p.call(p.context, "cache.set", map[string]any{"key": key, "value": value, "ttl": int(ttl.Seconds())})
248
+ return err
249
+ }
250
+ func (p cacheClient) DeleteCache(key string) (bool, error) {
251
+ value, err := p.call(p.context, "cache.delete", map[string]any{"key": key})
252
+ ok, _ := value.(bool)
253
+ return ok, err
254
+ }
255
+ func (p configClient) GetConfig(key string, defaultValue any) (any, error) {
256
+ return p.call(p.context, "config.get", map[string]any{"key": key, "default": defaultValue})
257
+ }
258
+ func (p cacheClient) Get(key string) (any, error) { return p.GetCache(key) }
259
+ func (p cacheClient) Set(key string, value any, ttl time.Duration) error {
260
+ return p.SetCache(key, value, ttl)
261
+ }
262
+ func (p cacheClient) Delete(key string) (bool, error) { return p.DeleteCache(key) }
263
+ func (p configClient) Get(key string, defaultValue any) (any, error) {
264
+ return p.GetConfig(key, defaultValue)
265
+ }
266
+ func (p httpClient) request(draftgo context.Context, method, url string, body any, headers http.Header, timeout time.Duration) (HTTPResponse, error) {
267
+ value, err := p.call(draftgo, "http.request", map[string]any{"method": method, "url": url, "body": body, "headers": headers, "timeout_ms": int(timeout.Milliseconds())})
268
+ response := HTTPResponse{}
269
+ if raw, ok := value.(map[string]any); ok {
270
+ if status, ok := raw["status_code"].(float64); ok {
271
+ response.StatusCode = int(status)
272
+ }
273
+ response.Data = raw["data"]
274
+ if rawHeaders, ok := raw["headers"].(map[string]any); ok {
275
+ response.Headers = http.Header{}
276
+ for key, value := range rawHeaders {
277
+ response.Headers.Set(key, fmt.Sprint(value))
278
+ }
279
+ }
280
+ }
281
+ return response, err
282
+ }
283
+ func (p httpClient) Get(draftgo context.Context, url string, headers http.Header, timeout time.Duration) (HTTPResponse, error) {
284
+ return p.request(draftgo, http.MethodGet, url, nil, headers, timeout)
285
+ }
286
+ func (p httpClient) Post(draftgo context.Context, url string, body any, headers http.Header, timeout time.Duration) (HTTPResponse, error) {
287
+ return p.request(draftgo, http.MethodPost, url, body, headers, timeout)
288
+ }
289
+ func (p httpClient) Put(draftgo context.Context, url string, body any, headers http.Header, timeout time.Duration) (HTTPResponse, error) {
290
+ return p.request(draftgo, http.MethodPut, url, body, headers, timeout)
291
+ }
292
+ func (p httpClient) Patch(draftgo context.Context, url string, body any, headers http.Header, timeout time.Duration) (HTTPResponse, error) {
293
+ return p.request(draftgo, http.MethodPatch, url, body, headers, timeout)
294
+ }
295
+ func (p httpClient) Delete(draftgo context.Context, url string, headers http.Header, timeout time.Duration) (HTTPResponse, error) {
296
+ return p.request(draftgo, http.MethodDelete, url, nil, headers, timeout)
297
+ }
298
+ func nullablePositive(value int) any {
299
+ if value <= 0 {
300
+ return nil
301
+ }
302
+ return value
303
+ }
304
+ func queryResult(value any) QueryResult {
305
+ object := mapResult(value)
306
+ result := QueryResult{Total: asInt(object["total"]), Page: asInt(object["page"]), PageSize: asInt(object["page_size"])}
307
+ result.Items = mapSlice(object["items"])
308
+ return result
309
+ }
310
+ func asInt(value any) int {
311
+ switch typed := value.(type) {
312
+ case int:
313
+ return typed
314
+ case int64:
315
+ return int(typed)
316
+ case float64:
317
+ return int(typed)
318
+ default:
319
+ return 0
320
+ }
321
+ }
322
+
323
+ type authClient struct{ user map[string]any }
324
+
325
+ func (a authClient) CurrentUser() map[string]any { return a.user }
326
+ func (a authClient) RequireLogin() error {
327
+ if a.user == nil {
328
+ return errors.New("require_login failed: user is not authenticated")
329
+ }
330
+ return nil
331
+ }
332
+ func (a authClient) RequireAdmin() error {
333
+ if err := a.RequireLogin(); err != nil {
334
+ return err
335
+ }
336
+ if a.user["is_admin"] == true || a.user["role_code"] == "admin" {
337
+ return nil
338
+ }
339
+ for _, role := range asSlice(a.user["roles"]) {
340
+ if item, ok := role.(map[string]any); ok && item["code"] == "admin" {
341
+ return nil
342
+ }
343
+ }
344
+ return errors.New("require_admin failed: user is not an administrator")
345
+ }
346
+ func (a authClient) RequireRole(role string) error {
347
+ if err := a.RequireLogin(); err != nil {
348
+ return err
349
+ }
350
+ if a.user["role_code"] == role {
351
+ return nil
352
+ }
353
+ for _, raw := range asSlice(a.user["roles"]) {
354
+ if item, ok := raw.(map[string]any); ok && item["code"] == role {
355
+ return nil
356
+ }
357
+ }
358
+ return fmt.Errorf("require_role failed: missing role %q", role)
359
+ }
360
+ func asSlice(value any) []any { items, _ := value.([]any); return items }
@@ -0,0 +1,24 @@
1
+ package sdk
2
+
3
+ import (
4
+ "strings"
5
+ "testing"
6
+ )
7
+
8
+ func TestLoggerReportsEntryAndMessageTruncation(t *testing.T) {
9
+ logger := &Logger{}
10
+ logger.Info(strings.Repeat("界", 4097))
11
+ for index := 1; index <= 500; index++ {
12
+ logger.Info("entry")
13
+ }
14
+ entries := logger.Entries()
15
+ if len(entries) != 500 {
16
+ t.Fatalf("entries = %d, want 500", len(entries))
17
+ }
18
+ if got := len([]rune(entries[0].Message)); got != 4096 {
19
+ t.Fatalf("message characters = %d, want 4096", got)
20
+ }
21
+ if !logger.Truncated() {
22
+ t.Fatal("Truncated() = false, want true")
23
+ }
24
+ }
@@ -0,0 +1,39 @@
1
+ package sdk
2
+
3
+ import (
4
+ "strings"
5
+ "testing"
6
+ )
7
+
8
+ func TestRouteCanonicalizesRuntimePath(t *testing.T) {
9
+ app := NewApp()
10
+ app.Route(" get ", "//health//", func(*Context) (any, error) { return nil, nil })
11
+
12
+ registrations := app.Registrations()
13
+ if len(registrations) != 1 || registrations[0].Method != "GET" || registrations[0].Path != "/health" {
14
+ t.Fatalf("canonical route registration = %#v", registrations)
15
+ }
16
+ }
17
+
18
+ func TestRouteRejectsCanonicalDuplicate(t *testing.T) {
19
+ app := NewApp()
20
+ app.Route("GET", "/health", func(*Context) (any, error) { return nil, nil })
21
+
22
+ deferred := recoverValue(func() {
23
+ app.Route(" get ", "/health/", func(*Context) (any, error) { return nil, nil })
24
+ })
25
+ if message := strings.TrimSpace(asPanicString(deferred)); message != "duplicate route GET /health" {
26
+ t.Fatalf("duplicate route panic = %q", message)
27
+ }
28
+ }
29
+
30
+ func recoverValue(run func()) (value any) {
31
+ defer func() { value = recover() }()
32
+ run()
33
+ return nil
34
+ }
35
+
36
+ func asPanicString(value any) string {
37
+ text, _ := value.(string)
38
+ return text
39
+ }