draftgo-cli 4.0.1 → 4.0.23
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.
- package/README.md +87 -11
- package/package.json +9 -4
- package/resources/custom-service-sdk/ai.go +520 -0
- package/resources/custom-service-sdk/ai_test.go +156 -0
- package/resources/custom-service-sdk/auth_test.go +56 -0
- package/resources/custom-service-sdk/billing.go +596 -0
- package/resources/custom-service-sdk/billing_test.go +150 -0
- package/resources/custom-service-sdk/go.mod +3 -0
- package/resources/custom-service-sdk/manifest.json +77 -0
- package/resources/custom-service-sdk/platform.go +345 -0
- package/resources/custom-service-sdk/platform_logger_test.go +24 -0
- package/resources/custom-service-sdk/registration_test.go +39 -0
- package/resources/custom-service-sdk/resources.go +246 -0
- package/resources/custom-service-sdk/resources_billing_test.go +115 -0
- package/resources/custom-service-sdk/resources_files_test.go +57 -0
- package/resources/custom-service-sdk/resources_scope_test.go +87 -0
- package/resources/custom-service-sdk/sdk.go +208 -0
- package/resources/skill/SKILL.md +36 -88
- package/resources/skill/init/SKILL.md +4 -4
- package/resources/skill/manifest.json +5 -1
- package/resources/skill/references/aihub.md +25 -2
- package/resources/skill/references/app-api.md +56 -6
- package/resources/skill/references/architecture.md +2 -2
- package/resources/skill/references/chat-sdk.md +4 -2
- package/resources/skill/references/checkout.md +17 -3
- package/resources/skill/references/custom-services.md +112 -47
- package/resources/skill/references/data.md +19 -4
- package/resources/skill/references/delivery.md +33 -0
- package/resources/skill/references/diagnostics.md +51 -0
- package/resources/skill/references/frontend.md +34 -46
- package/resources/skill/references/mcp.md +33 -5
- package/resources/skill/references/methods.md +189 -0
- package/resources/skill/references/modules.md +37 -9
- package/resources/skill/references/runtime.md +23 -1
- package/src/cli.js +24 -0
- package/src/commandRegistry.js +9 -1
- package/src/commands/api.js +21 -10
- package/src/commands/apiKey.js +34 -0
- package/src/commands/capabilities.js +93 -0
- package/src/commands/checkout.js +1 -1
- package/src/commands/commit.js +1 -1
- package/src/commands/components.js +550 -0
- package/src/commands/conflict.js +1 -1
- package/src/commands/connect.js +18 -8
- package/src/commands/customService.js +20 -4
- package/src/commands/dataRange.js +33 -0
- package/src/commands/delete.js +12 -1
- package/src/commands/diff.js +18 -2
- package/src/commands/grant.js +29 -0
- package/src/commands/group.js +38 -0
- package/src/commands/help.js +64 -20
- package/src/commands/init.js +3 -3
- package/src/commands/map.js +145 -17
- package/src/commands/mcp.js +2 -2
- package/src/commands/reconcile.js +1 -1
- package/src/commands/role.js +32 -0
- package/src/commands/space.js +41 -0
- package/src/commands/status.js +110 -7
- package/src/commands/update.js +23 -11
- package/src/commands/verify.js +75 -0
- package/src/commands/worklog.js +6 -2
- package/src/consoleEncoding.js +34 -0
- package/src/contractCompatibility.js +57 -0
- package/src/customServices.js +138 -18
- package/src/diffReport.js +106 -0
- package/src/index.js +2 -0
- package/src/localRuntime/compose.js +14 -17
- package/src/localRuntime/index.js +22 -23
- package/src/localRuntime/services.js +27 -36
- package/src/mcp/client.js +11 -2
- package/src/mcp/protocol.js +22 -2
- package/src/mcp/tools.js +14 -1
- package/src/platforms.js +9 -0
- package/src/projectConfig.js +6 -4
- package/src/releaseInstall.js +105 -0
- package/src/updateCheck.js +48 -28
- package/src/worklog.js +2 -1
- package/src/worktree/backend.js +1 -1
- 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,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"module": "draftgo/sdk",
|
|
4
|
+
"fingerprint": "94d0b5064c3d5c3d742ddf2856ed6e37196ff81bd16696aa88cd8267c3f57889",
|
|
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": "auth_test.go",
|
|
18
|
+
"sha256": "f0a02db739c8c5fa9d2856c59bd7ec823194cae50c1445f7b1340ce3dc0a616f",
|
|
19
|
+
"bytes": 1604
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"path": "billing.go",
|
|
23
|
+
"sha256": "9ce87c80885aa8b8996710622ec0f14cc7a74d3ca78e5ed4168b6c135994d583",
|
|
24
|
+
"bytes": 30976
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"path": "billing_test.go",
|
|
28
|
+
"sha256": "d6632b31d385fe3658dd1fbc74e31a87cc69b23bf97422d8339c5a46bf2d1aa2",
|
|
29
|
+
"bytes": 8009
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"path": "go.mod",
|
|
33
|
+
"sha256": "b89c04833ac41ea06dfbc8c60ebdc1c2e3e628243cf5adbb36b7320acfe2a3ef",
|
|
34
|
+
"bytes": 30
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"path": "platform.go",
|
|
38
|
+
"sha256": "deb0ab4a91dbb9a1f07974982ffb218d89f719b3691cf5e9a955abcc86a6d2ba",
|
|
39
|
+
"bytes": 11923
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"path": "platform_logger_test.go",
|
|
43
|
+
"sha256": "e8eb02e668c62bb317506f0197cfab3e520fd53534f3bb1f99f55155fa24218d",
|
|
44
|
+
"bytes": 540
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"path": "registration_test.go",
|
|
48
|
+
"sha256": "e9fd4ab60a2b6b28c997019ce76b224f7cec52b934d080ebfd94298448acd4f0",
|
|
49
|
+
"bytes": 1031
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"path": "resources.go",
|
|
53
|
+
"sha256": "e633aa1431beddd25b2c999370a770f77c3de7a2d7123687eca75c9ed0d3838f",
|
|
54
|
+
"bytes": 9708
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"path": "resources_billing_test.go",
|
|
58
|
+
"sha256": "3fadeb8b7af0fad43f34e2479e4331a18f022bcdabdcb3b4ba921768c6b6a7ec",
|
|
59
|
+
"bytes": 4859
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"path": "resources_files_test.go",
|
|
63
|
+
"sha256": "f77a3b6e421e20b528a1bfca5e2b164b63b3599bf3566b8f3998fdf72c4c0aa6",
|
|
64
|
+
"bytes": 2551
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"path": "resources_scope_test.go",
|
|
68
|
+
"sha256": "bba3243f348e9418888f37ee2d5b487fc26a31e4a646e75bef46d5d807938cee",
|
|
69
|
+
"bytes": 3592
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"path": "sdk.go",
|
|
73
|
+
"sha256": "b67db621642c389a2000e52b1640bacccf23a6d94f1fdcc1989e6a4244821924",
|
|
74
|
+
"bytes": 8224
|
|
75
|
+
}
|
|
76
|
+
]
|
|
77
|
+
}
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
package sdk
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"errors"
|
|
7
|
+
"fmt"
|
|
8
|
+
"net/http"
|
|
9
|
+
"strings"
|
|
10
|
+
"sync"
|
|
11
|
+
"time"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
// Client is implemented by the isolated runner protocol. It is deliberately
|
|
15
|
+
// generic so new platform capabilities do not require exposing host handles.
|
|
16
|
+
type Client interface {
|
|
17
|
+
Call(context.Context, string, any) (any, error)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type Database interface {
|
|
21
|
+
Create(string, map[string]any, ...int64) (map[string]any, error)
|
|
22
|
+
CreateMany(string, []map[string]any) ([]map[string]any, error)
|
|
23
|
+
Get(string, int64) (map[string]any, error)
|
|
24
|
+
Update(string, int64, map[string]any) (map[string]any, error)
|
|
25
|
+
UpdateMany(string, []map[string]any) ([]map[string]any, error)
|
|
26
|
+
Delete(string, int64) (bool, error)
|
|
27
|
+
Query(string, QueryOptions) (QueryResult, error)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type QueryOptions struct {
|
|
31
|
+
Filters map[string]any
|
|
32
|
+
Page int
|
|
33
|
+
PageSize int
|
|
34
|
+
OrderBy string
|
|
35
|
+
Order string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type QueryResult struct {
|
|
39
|
+
Items []map[string]any `json:"items"`
|
|
40
|
+
Total int `json:"total"`
|
|
41
|
+
Page int `json:"page"`
|
|
42
|
+
PageSize int `json:"page_size"`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type Users interface {
|
|
46
|
+
Get(int64) (map[string]any, error)
|
|
47
|
+
List(QueryOptions) (QueryResult, error)
|
|
48
|
+
Update(int64, map[string]any) (map[string]any, error)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type Auth interface {
|
|
52
|
+
RequireLogin() error
|
|
53
|
+
RequireAdmin() error
|
|
54
|
+
RequireRole(string) error
|
|
55
|
+
CurrentUser() map[string]any
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type Notifier interface {
|
|
59
|
+
Send(int64, string, string, string) error
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type Cache interface {
|
|
63
|
+
Get(string) (any, error)
|
|
64
|
+
Set(string, any, time.Duration) error
|
|
65
|
+
Delete(string) (bool, error)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type Config interface {
|
|
69
|
+
Get(string, any) (any, error)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type HTTPClient interface {
|
|
73
|
+
Get(context.Context, string, http.Header, time.Duration) (HTTPResponse, error)
|
|
74
|
+
Post(context.Context, string, any, http.Header, time.Duration) (HTTPResponse, error)
|
|
75
|
+
Put(context.Context, string, any, http.Header, time.Duration) (HTTPResponse, error)
|
|
76
|
+
Patch(context.Context, string, any, http.Header, time.Duration) (HTTPResponse, error)
|
|
77
|
+
Delete(context.Context, string, http.Header, time.Duration) (HTTPResponse, error)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
type HTTPResponse struct {
|
|
81
|
+
StatusCode int `json:"status_code"`
|
|
82
|
+
Headers http.Header `json:"headers,omitempty"`
|
|
83
|
+
Data any `json:"data,omitempty"`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
type LogEntry struct {
|
|
87
|
+
Level string `json:"level"`
|
|
88
|
+
Message string `json:"message"`
|
|
89
|
+
Timestamp time.Time `json:"timestamp"`
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Logger is local to one invocation and becomes part of the audited result.
|
|
93
|
+
type Logger struct {
|
|
94
|
+
mu sync.Mutex
|
|
95
|
+
entries []LogEntry
|
|
96
|
+
truncated bool
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
func (log *Logger) append(level, message string) {
|
|
100
|
+
if log == nil {
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
log.mu.Lock()
|
|
104
|
+
defer log.mu.Unlock()
|
|
105
|
+
if len(log.entries) < 500 {
|
|
106
|
+
if characters := []rune(message); len(characters) > 4096 {
|
|
107
|
+
message = string(characters[:4096])
|
|
108
|
+
log.truncated = true
|
|
109
|
+
}
|
|
110
|
+
log.entries = append(log.entries, LogEntry{Level: level, Message: message, Timestamp: time.Now()})
|
|
111
|
+
} else {
|
|
112
|
+
log.truncated = true
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
func (log *Logger) Truncated() bool {
|
|
117
|
+
if log == nil {
|
|
118
|
+
return false
|
|
119
|
+
}
|
|
120
|
+
log.mu.Lock()
|
|
121
|
+
defer log.mu.Unlock()
|
|
122
|
+
return log.truncated
|
|
123
|
+
}
|
|
124
|
+
func (log *Logger) Debug(message string) { log.append("debug", message) }
|
|
125
|
+
func (log *Logger) Info(message string) { log.append("info", message) }
|
|
126
|
+
func (log *Logger) Warn(message string) { log.append("warn", message) }
|
|
127
|
+
func (log *Logger) Error(message string) { log.append("error", message) }
|
|
128
|
+
func (log *Logger) Entries() []LogEntry {
|
|
129
|
+
if log == nil {
|
|
130
|
+
return nil
|
|
131
|
+
}
|
|
132
|
+
log.mu.Lock()
|
|
133
|
+
defer log.mu.Unlock()
|
|
134
|
+
out := make([]LogEntry, len(log.entries))
|
|
135
|
+
copy(out, log.entries)
|
|
136
|
+
return out
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
type platformClient struct {
|
|
140
|
+
client Client
|
|
141
|
+
context context.Context
|
|
142
|
+
admin bool
|
|
143
|
+
}
|
|
144
|
+
type dbClient struct{ platformClient }
|
|
145
|
+
type usersClient struct{ platformClient }
|
|
146
|
+
type notifierClient struct{ platformClient }
|
|
147
|
+
type cacheClient struct{ platformClient }
|
|
148
|
+
type configClient struct{ platformClient }
|
|
149
|
+
type httpClient struct{ platformClient }
|
|
150
|
+
type aiHubClient struct{ platformClient }
|
|
151
|
+
type knowledgeClient struct{ platformClient }
|
|
152
|
+
type memoryClient struct{ platformClient }
|
|
153
|
+
|
|
154
|
+
func (p platformClient) call(draftgo context.Context, operation string, args any) (any, error) {
|
|
155
|
+
if p.client == nil {
|
|
156
|
+
return nil, errors.New("DraftGo platform RPC is unavailable")
|
|
157
|
+
}
|
|
158
|
+
if draftgo == nil {
|
|
159
|
+
draftgo = p.context
|
|
160
|
+
}
|
|
161
|
+
if p.admin {
|
|
162
|
+
values, ok := args.(map[string]any)
|
|
163
|
+
if !ok {
|
|
164
|
+
raw, err := json.Marshal(args)
|
|
165
|
+
if err == nil {
|
|
166
|
+
ok = json.Unmarshal(raw, &values) == nil
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if ok {
|
|
170
|
+
cloned := make(map[string]any, len(values)+1)
|
|
171
|
+
for key, value := range values {
|
|
172
|
+
cloned[key] = value
|
|
173
|
+
}
|
|
174
|
+
cloned["__draftgo_admin"] = true
|
|
175
|
+
args = cloned
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return p.client.Call(draftgo, operation, args)
|
|
179
|
+
}
|
|
180
|
+
func mapResult(value any) map[string]any { result, _ := value.(map[string]any); return result }
|
|
181
|
+
func mapSlice(value any) []map[string]any {
|
|
182
|
+
raw, _ := value.([]any)
|
|
183
|
+
result := make([]map[string]any, 0, len(raw))
|
|
184
|
+
for _, item := range raw {
|
|
185
|
+
if object, ok := item.(map[string]any); ok {
|
|
186
|
+
result = append(result, object)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return result
|
|
190
|
+
}
|
|
191
|
+
func (p dbClient) Create(kind string, data map[string]any, userID ...int64) (map[string]any, error) {
|
|
192
|
+
args := map[string]any{"db_type": kind, "data": data}
|
|
193
|
+
if len(userID) > 0 {
|
|
194
|
+
args["owner_user_id"] = userID[0]
|
|
195
|
+
}
|
|
196
|
+
value, err := p.call(p.context, "db.create", args)
|
|
197
|
+
return mapResult(value), err
|
|
198
|
+
}
|
|
199
|
+
func (p dbClient) CreateMany(kind string, items []map[string]any) ([]map[string]any, error) {
|
|
200
|
+
value, err := p.call(p.context, "db.create_many", map[string]any{"db_type": kind, "items": items})
|
|
201
|
+
return mapSlice(value), err
|
|
202
|
+
}
|
|
203
|
+
func (p dbClient) Get(kind string, id int64) (map[string]any, error) {
|
|
204
|
+
value, err := p.call(p.context, "db.get", map[string]any{"db_type": kind, "record_id": id})
|
|
205
|
+
return mapResult(value), err
|
|
206
|
+
}
|
|
207
|
+
func (p dbClient) Update(kind string, id int64, data map[string]any) (map[string]any, error) {
|
|
208
|
+
value, err := p.call(p.context, "db.update", map[string]any{"db_type": kind, "record_id": id, "data": data})
|
|
209
|
+
return mapResult(value), err
|
|
210
|
+
}
|
|
211
|
+
func (p dbClient) UpdateMany(kind string, items []map[string]any) ([]map[string]any, error) {
|
|
212
|
+
value, err := p.call(p.context, "db.update_many", map[string]any{"db_type": kind, "items": items})
|
|
213
|
+
return mapSlice(value), err
|
|
214
|
+
}
|
|
215
|
+
func (p dbClient) Delete(kind string, id int64) (bool, error) {
|
|
216
|
+
value, err := p.call(p.context, "db.delete", map[string]any{"db_type": kind, "record_id": id})
|
|
217
|
+
ok, _ := value.(bool)
|
|
218
|
+
return ok, err
|
|
219
|
+
}
|
|
220
|
+
func (p dbClient) Query(kind string, options QueryOptions) (QueryResult, error) {
|
|
221
|
+
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})
|
|
222
|
+
return queryResult(value), err
|
|
223
|
+
}
|
|
224
|
+
func (p usersClient) GetUser(id int64) (map[string]any, error) {
|
|
225
|
+
value, err := p.call(p.context, "users.get", map[string]any{"user_id": id})
|
|
226
|
+
return mapResult(value), err
|
|
227
|
+
}
|
|
228
|
+
func (p usersClient) List(options QueryOptions) (QueryResult, error) {
|
|
229
|
+
value, err := p.call(p.context, "users.list", map[string]any{"filters": options.Filters, "page": nullablePositive(options.Page), "page_size": nullablePositive(options.PageSize)})
|
|
230
|
+
return queryResult(value), err
|
|
231
|
+
}
|
|
232
|
+
func (p usersClient) UpdateUser(id int64, data map[string]any) (map[string]any, error) {
|
|
233
|
+
value, err := p.call(p.context, "users.update", map[string]any{"user_id": id, "data": data})
|
|
234
|
+
return mapResult(value), err
|
|
235
|
+
}
|
|
236
|
+
func (p usersClient) Get(id int64) (map[string]any, error) { return p.GetUser(id) }
|
|
237
|
+
func (p usersClient) Update(id int64, data map[string]any) (map[string]any, error) {
|
|
238
|
+
return p.UpdateUser(id, data)
|
|
239
|
+
}
|
|
240
|
+
func (p notifierClient) Send(userID int64, title, content, noticeType string) error {
|
|
241
|
+
_, err := p.call(p.context, "notify.send", map[string]any{"user_id": userID, "title": title, "content": content, "notice_type": noticeType})
|
|
242
|
+
return err
|
|
243
|
+
}
|
|
244
|
+
func (p cacheClient) GetCache(key string) (any, error) {
|
|
245
|
+
return p.call(p.context, "cache.get", map[string]any{"key": key})
|
|
246
|
+
}
|
|
247
|
+
func (p cacheClient) SetCache(key string, value any, ttl time.Duration) error {
|
|
248
|
+
_, err := p.call(p.context, "cache.set", map[string]any{"key": key, "value": value, "ttl": int(ttl.Seconds())})
|
|
249
|
+
return err
|
|
250
|
+
}
|
|
251
|
+
func (p cacheClient) DeleteCache(key string) (bool, error) {
|
|
252
|
+
value, err := p.call(p.context, "cache.delete", map[string]any{"key": key})
|
|
253
|
+
ok, _ := value.(bool)
|
|
254
|
+
return ok, err
|
|
255
|
+
}
|
|
256
|
+
func (p configClient) GetConfig(key string, defaultValue any) (any, error) {
|
|
257
|
+
return p.call(p.context, "config.get", map[string]any{"key": key, "default": defaultValue})
|
|
258
|
+
}
|
|
259
|
+
func (p cacheClient) Get(key string) (any, error) { return p.GetCache(key) }
|
|
260
|
+
func (p cacheClient) Set(key string, value any, ttl time.Duration) error {
|
|
261
|
+
return p.SetCache(key, value, ttl)
|
|
262
|
+
}
|
|
263
|
+
func (p cacheClient) Delete(key string) (bool, error) { return p.DeleteCache(key) }
|
|
264
|
+
func (p configClient) Get(key string, defaultValue any) (any, error) {
|
|
265
|
+
return p.GetConfig(key, defaultValue)
|
|
266
|
+
}
|
|
267
|
+
func (p httpClient) request(draftgo context.Context, method, url string, body any, headers http.Header, timeout time.Duration) (HTTPResponse, error) {
|
|
268
|
+
value, err := p.call(draftgo, "http.request", map[string]any{"method": method, "url": url, "body": body, "headers": headers, "timeout_ms": int(timeout.Milliseconds())})
|
|
269
|
+
response := HTTPResponse{}
|
|
270
|
+
if raw, ok := value.(map[string]any); ok {
|
|
271
|
+
if status, ok := raw["status_code"].(float64); ok {
|
|
272
|
+
response.StatusCode = int(status)
|
|
273
|
+
}
|
|
274
|
+
response.Data = raw["data"]
|
|
275
|
+
if rawHeaders, ok := raw["headers"].(map[string]any); ok {
|
|
276
|
+
response.Headers = http.Header{}
|
|
277
|
+
for key, value := range rawHeaders {
|
|
278
|
+
response.Headers.Set(key, fmt.Sprint(value))
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return response, err
|
|
283
|
+
}
|
|
284
|
+
func (p httpClient) Get(draftgo context.Context, url string, headers http.Header, timeout time.Duration) (HTTPResponse, error) {
|
|
285
|
+
return p.request(draftgo, http.MethodGet, url, nil, headers, timeout)
|
|
286
|
+
}
|
|
287
|
+
func (p httpClient) Post(draftgo context.Context, url string, body any, headers http.Header, timeout time.Duration) (HTTPResponse, error) {
|
|
288
|
+
return p.request(draftgo, http.MethodPost, url, body, headers, timeout)
|
|
289
|
+
}
|
|
290
|
+
func (p httpClient) Put(draftgo context.Context, url string, body any, headers http.Header, timeout time.Duration) (HTTPResponse, error) {
|
|
291
|
+
return p.request(draftgo, http.MethodPut, url, body, headers, timeout)
|
|
292
|
+
}
|
|
293
|
+
func (p httpClient) Patch(draftgo context.Context, url string, body any, headers http.Header, timeout time.Duration) (HTTPResponse, error) {
|
|
294
|
+
return p.request(draftgo, http.MethodPatch, url, body, headers, timeout)
|
|
295
|
+
}
|
|
296
|
+
func (p httpClient) Delete(draftgo context.Context, url string, headers http.Header, timeout time.Duration) (HTTPResponse, error) {
|
|
297
|
+
return p.request(draftgo, http.MethodDelete, url, nil, headers, timeout)
|
|
298
|
+
}
|
|
299
|
+
func nullablePositive(value int) any {
|
|
300
|
+
if value <= 0 {
|
|
301
|
+
return nil
|
|
302
|
+
}
|
|
303
|
+
return value
|
|
304
|
+
}
|
|
305
|
+
func queryResult(value any) QueryResult {
|
|
306
|
+
object := mapResult(value)
|
|
307
|
+
result := QueryResult{Total: asInt(object["total"]), Page: asInt(object["page"]), PageSize: asInt(object["page_size"])}
|
|
308
|
+
result.Items = mapSlice(object["items"])
|
|
309
|
+
return result
|
|
310
|
+
}
|
|
311
|
+
func asInt(value any) int {
|
|
312
|
+
switch typed := value.(type) {
|
|
313
|
+
case int:
|
|
314
|
+
return typed
|
|
315
|
+
case int64:
|
|
316
|
+
return int(typed)
|
|
317
|
+
case float64:
|
|
318
|
+
return int(typed)
|
|
319
|
+
default:
|
|
320
|
+
return 0
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
type authClient struct {
|
|
325
|
+
platformClient
|
|
326
|
+
user map[string]any
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
func (a authClient) CurrentUser() map[string]any { return a.user }
|
|
330
|
+
func (a authClient) RequireLogin() error {
|
|
331
|
+
_, err := a.call(a.context, "auth.require_login", map[string]any{})
|
|
332
|
+
return err
|
|
333
|
+
}
|
|
334
|
+
func (a authClient) RequireAdmin() error {
|
|
335
|
+
_, err := a.call(a.context, "auth.require_admin", map[string]any{})
|
|
336
|
+
return err
|
|
337
|
+
}
|
|
338
|
+
func (a authClient) RequireRole(role string) error {
|
|
339
|
+
role = strings.TrimSpace(role)
|
|
340
|
+
if role == "" {
|
|
341
|
+
return errors.New("require_role failed: role is required")
|
|
342
|
+
}
|
|
343
|
+
_, err := a.call(a.context, "auth.require_role", map[string]any{"role": role})
|
|
344
|
+
return err
|
|
345
|
+
}
|
|
@@ -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
|
+
}
|