mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
Compare commits
2 Commits
fix/wiki-n
...
feat/lark-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a88923ead | ||
|
|
3944eda0d3 |
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@@ -2,6 +2,7 @@
|
||||
|
||||
# Last match wins: existing domains below are exempt, only new skills/ entries need review.
|
||||
/skills/ @liangshuo-1
|
||||
/isolated-skills/ @liangshuo-1
|
||||
/skills/lark-approval/
|
||||
/skills/lark-apps/
|
||||
/skills/lark-attendance/
|
||||
|
||||
2
.github/workflows/skill-format-check.yml
vendored
2
.github/workflows/skill-format-check.yml
vendored
@@ -5,12 +5,14 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "skills/**"
|
||||
- "isolated-skills/**"
|
||||
- "scripts/skill-format-check/**"
|
||||
- ".github/workflows/skill-format-check.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "skills/**"
|
||||
- "isolated-skills/**"
|
||||
- "scripts/skill-format-check/**"
|
||||
- ".github/workflows/skill-format-check.yml"
|
||||
|
||||
|
||||
@@ -233,7 +233,7 @@ func apiRun(opts *APIOptions) error {
|
||||
}
|
||||
|
||||
if opts.PageAll {
|
||||
return apiPaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut,
|
||||
return apiPaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut, opts.Cmd.CommandPath(),
|
||||
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay})
|
||||
}
|
||||
|
||||
@@ -272,24 +272,13 @@ func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.Cl
|
||||
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
|
||||
}
|
||||
|
||||
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions) error {
|
||||
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions) error {
|
||||
if pagOpts.Identity == "" {
|
||||
pagOpts.Identity = request.As
|
||||
}
|
||||
// When jq is set, always aggregate all pages then filter.
|
||||
if jqExpr != "" {
|
||||
if err := client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, ac.CheckResponse); err != nil {
|
||||
return output.MarkRaw(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
switch format {
|
||||
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
|
||||
pf := output.NewPaginatedFormatter(out, format)
|
||||
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) {
|
||||
pf.FormatPage(items)
|
||||
}, pagOpts)
|
||||
result, err := ac.PaginateAll(ctx, request, pagOpts)
|
||||
if err != nil {
|
||||
return output.MarkRaw(err)
|
||||
}
|
||||
@@ -297,9 +286,46 @@ func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawAp
|
||||
output.FormatValue(out, result, output.FormatJSON)
|
||||
return output.MarkRaw(apiErr)
|
||||
}
|
||||
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
|
||||
CommandPath: commandPath,
|
||||
Identity: string(pagOpts.Identity),
|
||||
JqExpr: jqExpr,
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
})
|
||||
}
|
||||
|
||||
switch format {
|
||||
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
|
||||
pf := output.NewPaginatedFormatter(out, format)
|
||||
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
|
||||
// Streaming formats intentionally emit each page after that page has
|
||||
// passed safety scanning. A later page may still fail, so callers
|
||||
// must use the exit code to distinguish complete vs partial output.
|
||||
scanResult := output.ScanForSafety(commandPath, items, errOut)
|
||||
if scanResult.Blocked {
|
||||
return scanResult.BlockErr
|
||||
}
|
||||
if scanResult.Alert != nil {
|
||||
output.WriteAlertWarning(errOut, scanResult.Alert)
|
||||
}
|
||||
pf.FormatPage(items)
|
||||
return nil
|
||||
}, pagOpts)
|
||||
if err != nil {
|
||||
return output.MarkRaw(err)
|
||||
}
|
||||
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
|
||||
return output.MarkRaw(apiErr)
|
||||
}
|
||||
if !hasItems {
|
||||
fmt.Fprintf(errOut, "warning: this API does not return a list, format %q is not supported, falling back to json\n", format)
|
||||
output.FormatValue(out, result, output.FormatJSON)
|
||||
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
|
||||
CommandPath: commandPath,
|
||||
Identity: string(pagOpts.Identity),
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
@@ -311,7 +337,11 @@ func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawAp
|
||||
output.FormatValue(out, result, output.FormatJSON)
|
||||
return output.MarkRaw(apiErr)
|
||||
}
|
||||
output.FormatValue(out, result, format)
|
||||
return nil
|
||||
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
|
||||
CommandPath: commandPath,
|
||||
Identity: string(pagOpts.Identity),
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"sort"
|
||||
@@ -11,6 +13,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcs "github.com/larksuite/cli/extension/contentsafety"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
@@ -101,8 +104,19 @@ func TestApiCmd_BotMode(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "success") {
|
||||
t.Error("expected 'success' in output")
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if got["ok"] != true || got["identity"] != "bot" {
|
||||
t.Fatalf("unexpected envelope: %#v", got)
|
||||
}
|
||||
if _, hasCode := got["code"]; hasCode {
|
||||
t.Fatalf("success envelope leaked outer code: %s", stdout.String())
|
||||
}
|
||||
data, ok := got["data"].(map[string]interface{})
|
||||
if !ok || data["result"] != "success" {
|
||||
t.Fatalf("data = %#v, want result=success", got["data"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,8 +342,16 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
|
||||
t.Error("expected 'falling back to json' in stderr")
|
||||
}
|
||||
// Should output JSON result to stdout
|
||||
if !strings.Contains(stdout.String(), "u123") {
|
||||
t.Error("expected user_id in JSON output")
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
data, ok := got["data"].(map[string]interface{})
|
||||
if got["ok"] != true || got["identity"] != "bot" || !ok || data["user_id"] != "u123" {
|
||||
t.Fatalf("unexpected fallback envelope: %#v", got)
|
||||
}
|
||||
if _, hasCode := got["code"]; hasCode {
|
||||
t.Fatalf("fallback success envelope leaked outer code: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -342,7 +364,7 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/chats/oc_xxx/announcement",
|
||||
Body: map[string]interface{}{
|
||||
"code": 230001, "msg": "no permission",
|
||||
"code": 230027, "msg": "user not authorized",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -354,12 +376,20 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
|
||||
t.Fatal("expected an error for non-zero code")
|
||||
}
|
||||
// Should still output the response body so user can see the error details
|
||||
if !strings.Contains(stdout.String(), "230001") {
|
||||
if !strings.Contains(stdout.String(), "230027") {
|
||||
t.Errorf("expected error response in stdout, got: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "no permission") {
|
||||
if !strings.Contains(stdout.String(), "user not authorized") {
|
||||
t.Errorf("expected error message in stdout, got: %s", stdout.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), `"ok": true`) || strings.Contains(stdout.String(), `"ok":true`) {
|
||||
t.Fatalf("unexpected success envelope on error path: %s", stdout.String())
|
||||
}
|
||||
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
|
||||
var permErr *errs.PermissionError
|
||||
if !errors.As(err, &permErr) {
|
||||
t.Fatalf("expected PermissionError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
|
||||
@@ -395,6 +425,274 @@ func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-pageall-stream-err", AppSecret: "test-secret-pageall-stream-err", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/contact/v3/users",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "safe-page"}},
|
||||
"has_more": true,
|
||||
"page_token": "next",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/contact/v3/users",
|
||||
Body: map[string]interface{}{
|
||||
"code": 230027, "msg": "user not authorized",
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-zero code on later page")
|
||||
}
|
||||
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "safe-page") {
|
||||
t.Fatalf("expected earlier successful page to remain streamed, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "230027") || strings.Contains(out, "user not authorized") {
|
||||
t.Fatalf("streaming stdout should not contain raw error JSON, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "\n \"code\"") {
|
||||
t.Fatalf("streaming stdout should not contain indented JSON error dump, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_PageAll_BatchAPI_DefaultJSONEnvelope(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-pageall-json", AppSecret: "test-secret-pageall-json", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/contact/v3/users",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "1"}},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
data, ok := got["data"].(map[string]interface{})
|
||||
if got["ok"] != true || got["identity"] != "bot" || !ok {
|
||||
t.Fatalf("unexpected envelope: %#v", got)
|
||||
}
|
||||
if _, hasCode := got["code"]; hasCode {
|
||||
t.Fatalf("success envelope leaked outer code: %s", stdout.String())
|
||||
}
|
||||
items, ok := data["items"].([]interface{})
|
||||
if !ok || len(items) != 1 {
|
||||
t.Fatalf("data.items = %#v, want one item", data["items"])
|
||||
}
|
||||
}
|
||||
|
||||
type apiContentSafetyProvider struct {
|
||||
called bool
|
||||
path string
|
||||
data interface{}
|
||||
match string
|
||||
}
|
||||
|
||||
func (p *apiContentSafetyProvider) Name() string { return "api-test" }
|
||||
|
||||
func (p *apiContentSafetyProvider) Scan(_ context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
|
||||
p.called = true
|
||||
p.path = req.Path
|
||||
p.data = req.Data
|
||||
if p.match != "" {
|
||||
b, _ := json.Marshal(req.Data)
|
||||
if !strings.Contains(string(b), p.match) {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
return &extcs.Alert{Provider: "api-test", MatchedRules: []string{"pagination"}}, nil
|
||||
}
|
||||
|
||||
func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
|
||||
provider := &apiContentSafetyProvider{}
|
||||
extcs.Register(provider)
|
||||
t.Cleanup(func() { extcs.Register(nil) })
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-pageall-safety", AppSecret: "test-secret-pageall-safety", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/contact/v3/users",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "1"}},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.AddCommand(NewCmdApi(f, nil))
|
||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !provider.called {
|
||||
t.Fatal("expected content safety provider to scan paginated output")
|
||||
}
|
||||
if provider.path != "api" {
|
||||
t.Fatalf("scan path = %q, want api", provider.path)
|
||||
}
|
||||
data, ok := provider.data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("scanned data type = %T, want map", provider.data)
|
||||
}
|
||||
if _, hasCode := data["code"]; hasCode {
|
||||
t.Fatalf("scanned data should be business data only, got %#v", data)
|
||||
}
|
||||
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
alert, ok := got["_content_safety_alert"].(map[string]interface{})
|
||||
if !ok || alert["provider"] != "api-test" {
|
||||
t.Fatalf("missing content safety alert in envelope: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
|
||||
provider := &apiContentSafetyProvider{}
|
||||
extcs.Register(provider)
|
||||
t.Cleanup(func() { extcs.Register(nil) })
|
||||
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-pageall-stream-safety", AppSecret: "test-secret-pageall-stream-safety", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/contact/v3/users",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "1"}},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.AddCommand(NewCmdApi(f, nil))
|
||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !provider.called {
|
||||
t.Fatal("expected content safety provider to scan streamed paginated output")
|
||||
}
|
||||
if provider.path != "api" {
|
||||
t.Fatalf("scan path = %q, want api", provider.path)
|
||||
}
|
||||
items, ok := provider.data.([]interface{})
|
||||
if !ok || len(items) != 1 {
|
||||
t.Fatalf("scanned data = %#v, want one streamed item", provider.data)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "warning: content safety alert from api-test") {
|
||||
t.Fatalf("expected content safety warning on stderr, got: %s", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"id":"1"`) {
|
||||
t.Fatalf("expected streamed ndjson output, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
|
||||
provider := &apiContentSafetyProvider{match: "blocked"}
|
||||
extcs.Register(provider)
|
||||
t.Cleanup(func() { extcs.Register(nil) })
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-pageall-stream-block", AppSecret: "test-secret-pageall-stream-block", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/contact/v3/users",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "safe-page"}},
|
||||
"has_more": true,
|
||||
"page_token": "next",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/contact/v3/users",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "blocked-page"}},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.AddCommand(NewCmdApi(f, nil))
|
||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected content safety block error")
|
||||
}
|
||||
var safetyErr *errs.ContentSafetyError
|
||||
if !errors.As(err, &safetyErr) {
|
||||
t.Fatalf("expected ContentSafetyError, got %T: %v", err, err)
|
||||
}
|
||||
if safetyErr.Category != errs.CategoryPolicy || safetyErr.Subtype != errs.SubtypeContentSafety {
|
||||
t.Fatalf("problem = %s/%s, want %s/%s", safetyErr.Category, safetyErr.Subtype, errs.CategoryPolicy, errs.SubtypeContentSafety)
|
||||
}
|
||||
if len(safetyErr.Rules) != 1 || safetyErr.Rules[0] != "pagination" {
|
||||
t.Fatalf("rules = %v, want [pagination]", safetyErr.Rules)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "safe-page") {
|
||||
t.Fatalf("expected earlier safe page to remain streamed, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "blocked-page") {
|
||||
t.Fatalf("blocked page was written before safety block: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func requireProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype, code int) {
|
||||
t.Helper()
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != category || p.Subtype != subtype || p.Code != code {
|
||||
t.Fatalf("problem = %s/%s/%d, want %s/%s/%d", p.Category, p.Subtype, p.Code, category, subtype, code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalisePath_StripsQueryAndFragment(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
|
||||
@@ -380,7 +380,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
checkErr := ac.CheckResponse
|
||||
|
||||
if opts.PageAll {
|
||||
return servicePaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut,
|
||||
return servicePaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut, opts.Cmd.CommandPath(),
|
||||
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay}, checkErr)
|
||||
}
|
||||
|
||||
@@ -620,20 +620,45 @@ func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *cor
|
||||
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
|
||||
}
|
||||
|
||||
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions, checkErr func(interface{}, core.Identity) error) error {
|
||||
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions, checkErr func(interface{}, core.Identity) error) error {
|
||||
if pagOpts.Identity == "" {
|
||||
pagOpts.Identity = request.As
|
||||
}
|
||||
// When jq is set, always aggregate all pages then filter.
|
||||
if jqExpr != "" {
|
||||
return client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, checkErr)
|
||||
result, err := ac.PaginateAll(ctx, request, pagOpts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
|
||||
output.FormatValue(out, result, output.FormatJSON)
|
||||
return apiErr
|
||||
}
|
||||
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
|
||||
CommandPath: commandPath,
|
||||
Identity: string(pagOpts.Identity),
|
||||
JqExpr: jqExpr,
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
})
|
||||
}
|
||||
|
||||
switch format {
|
||||
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
|
||||
pf := output.NewPaginatedFormatter(out, format)
|
||||
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) {
|
||||
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
|
||||
// Streaming formats intentionally emit each page after that page has
|
||||
// passed safety scanning. A later page may still fail, so callers
|
||||
// must use the exit code to distinguish complete vs partial output.
|
||||
scanResult := output.ScanForSafety(commandPath, items, errOut)
|
||||
if scanResult.Blocked {
|
||||
return scanResult.BlockErr
|
||||
}
|
||||
if scanResult.Alert != nil {
|
||||
output.WriteAlertWarning(errOut, scanResult.Alert)
|
||||
}
|
||||
pf.FormatPage(items)
|
||||
return nil
|
||||
}, pagOpts)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -643,7 +668,12 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
|
||||
}
|
||||
if !hasItems {
|
||||
fmt.Fprintf(errOut, "warning: this API does not return a list, format %q is not supported, falling back to json\n", format)
|
||||
output.FormatValue(out, result, output.FormatJSON)
|
||||
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
|
||||
CommandPath: commandPath,
|
||||
Identity: string(pagOpts.Identity),
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
@@ -652,9 +682,14 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
|
||||
return err
|
||||
}
|
||||
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
|
||||
output.FormatValue(out, result, output.FormatJSON)
|
||||
return apiErr
|
||||
}
|
||||
output.FormatValue(out, result, format)
|
||||
return nil
|
||||
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
|
||||
CommandPath: commandPath,
|
||||
Identity: string(pagOpts.Identity),
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,15 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcs "github.com/larksuite/cli/extension/contentsafety"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
@@ -407,8 +412,19 @@ func TestServiceMethod_BotMode_Success(t *testing.T) {
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "success") {
|
||||
t.Errorf("expected 'success' in output, got:\n%s", stdout.String())
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if got["ok"] != true || got["identity"] != "bot" {
|
||||
t.Fatalf("unexpected envelope: %#v", got)
|
||||
}
|
||||
if _, hasCode := got["code"]; hasCode {
|
||||
t.Fatalf("success envelope leaked outer code: %s", stdout.String())
|
||||
}
|
||||
data, ok := got["data"].(map[string]interface{})
|
||||
if !ok || data["result"] != "success" {
|
||||
t.Fatalf("data = %#v, want result=success", got["data"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -436,8 +452,312 @@ func TestServiceMethod_BotMode_PageAll_JSON(t *testing.T) {
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"id"`) {
|
||||
t.Errorf("expected items in output, got:\n%s", stdout.String())
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
data, ok := got["data"].(map[string]interface{})
|
||||
if got["ok"] != true || got["identity"] != "bot" || !ok {
|
||||
t.Fatalf("unexpected envelope: %#v", got)
|
||||
}
|
||||
if _, hasCode := got["code"]; hasCode {
|
||||
t.Fatalf("success envelope leaked outer code: %s", stdout.String())
|
||||
}
|
||||
items, ok := data["items"].([]interface{})
|
||||
if !ok || len(items) != 1 {
|
||||
t.Fatalf("data.items = %#v, want one item", data["items"])
|
||||
}
|
||||
}
|
||||
|
||||
type serviceContentSafetyProvider struct {
|
||||
called bool
|
||||
path string
|
||||
data interface{}
|
||||
match string
|
||||
}
|
||||
|
||||
func (p *serviceContentSafetyProvider) Name() string { return "service-test" }
|
||||
|
||||
func (p *serviceContentSafetyProvider) Scan(_ context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
|
||||
p.called = true
|
||||
p.path = req.Path
|
||||
p.data = req.Data
|
||||
if p.match != "" {
|
||||
b, _ := json.Marshal(req.Data)
|
||||
if !strings.Contains(string(b), p.match) {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
return &extcs.Alert{Provider: "service-test", MatchedRules: []string{"pagination"}}, nil
|
||||
}
|
||||
|
||||
func TestServiceMethod_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
|
||||
provider := &serviceContentSafetyProvider{}
|
||||
extcs.Register(provider)
|
||||
t.Cleanup(func() { extcs.Register(nil) })
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-service-safety", AppSecret: "test-secret-service-safety", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "1"}},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.AddCommand(NewCmdServiceMethod(f, spec, method, "list", "items", nil))
|
||||
root.SetArgs([]string{"list", "--as", "bot", "--page-all"})
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !provider.called {
|
||||
t.Fatal("expected content safety provider to scan paginated output")
|
||||
}
|
||||
if provider.path != "list" {
|
||||
t.Fatalf("scan path = %q, want list", provider.path)
|
||||
}
|
||||
data, ok := provider.data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("scanned data type = %T, want map", provider.data)
|
||||
}
|
||||
if _, hasCode := data["code"]; hasCode {
|
||||
t.Fatalf("scanned data should be business data only, got %#v", data)
|
||||
}
|
||||
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
alert, ok := got["_content_safety_alert"].(map[string]interface{})
|
||||
if !ok || alert["provider"] != "service-test" {
|
||||
t.Fatalf("missing content safety alert in envelope: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
|
||||
provider := &serviceContentSafetyProvider{}
|
||||
extcs.Register(provider)
|
||||
t.Cleanup(func() { extcs.Register(nil) })
|
||||
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-service-stream-safety", AppSecret: "test-secret-service-stream-safety", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "1"}},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.AddCommand(NewCmdServiceMethod(f, spec, method, "list", "items", nil))
|
||||
root.SetArgs([]string{"list", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !provider.called {
|
||||
t.Fatal("expected content safety provider to scan streamed paginated output")
|
||||
}
|
||||
if provider.path != "list" {
|
||||
t.Fatalf("scan path = %q, want list", provider.path)
|
||||
}
|
||||
items, ok := provider.data.([]interface{})
|
||||
if !ok || len(items) != 1 {
|
||||
t.Fatalf("scanned data = %#v, want one streamed item", provider.data)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "warning: content safety alert from service-test") {
|
||||
t.Fatalf("expected content safety warning on stderr, got: %s", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"id":"1"`) {
|
||||
t.Fatalf("expected streamed ndjson output, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
|
||||
provider := &serviceContentSafetyProvider{match: "blocked"}
|
||||
extcs.Register(provider)
|
||||
t.Cleanup(func() { extcs.Register(nil) })
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-service-stream-block", AppSecret: "test-secret-service-stream-block", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "safe-page"}},
|
||||
"has_more": true,
|
||||
"page_token": "next",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "blocked-page"}},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.AddCommand(NewCmdServiceMethod(f, spec, method, "list", "items", nil))
|
||||
root.SetArgs([]string{"list", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected content safety block error")
|
||||
}
|
||||
var safetyErr *errs.ContentSafetyError
|
||||
if !errors.As(err, &safetyErr) {
|
||||
t.Fatalf("expected ContentSafetyError, got %T: %v", err, err)
|
||||
}
|
||||
if safetyErr.Category != errs.CategoryPolicy || safetyErr.Subtype != errs.SubtypeContentSafety {
|
||||
t.Fatalf("problem = %s/%s, want %s/%s", safetyErr.Category, safetyErr.Subtype, errs.CategoryPolicy, errs.SubtypeContentSafety)
|
||||
}
|
||||
if len(safetyErr.Rules) != 1 || safetyErr.Rules[0] != "pagination" {
|
||||
t.Fatalf("rules = %v, want [pagination]", safetyErr.Rules)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "safe-page") {
|
||||
t.Fatalf("expected earlier safe page to remain streamed, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "blocked-page") {
|
||||
t.Fatalf("blocked page was written before safety block: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_BusinessErrorReturnsTypedErrorWithoutSuccessEnvelope(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-service-err", AppSecret: "test-secret-service-err", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 230027, "msg": "user not authorized",
|
||||
},
|
||||
})
|
||||
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--as", "bot"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-zero code")
|
||||
}
|
||||
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
|
||||
var permErr *errs.PermissionError
|
||||
if !errors.As(err, &permErr) {
|
||||
t.Fatalf("expected PermissionError, got %T: %v", err, err)
|
||||
}
|
||||
if strings.Contains(stdout.String(), `"ok": true`) || strings.Contains(stdout.String(), `"ok":true`) {
|
||||
t.Fatalf("unexpected success envelope on error path: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_PageAll_DefaultBusinessErrorOutputsRawResponse(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-service-pageall-err", AppSecret: "test-secret-service-pageall-err", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 230027, "msg": "user not authorized",
|
||||
},
|
||||
})
|
||||
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--as", "bot", "--page-all"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-zero code")
|
||||
}
|
||||
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
|
||||
if !strings.Contains(stdout.String(), "230027") || !strings.Contains(stdout.String(), "user not authorized") {
|
||||
t.Fatalf("expected raw error response on stdout, got: %s", stdout.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), `"ok": true`) || strings.Contains(stdout.String(), `"ok":true`) {
|
||||
t.Fatalf("unexpected success envelope on error path: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-service-pageall-stream-err", AppSecret: "test-secret-service-pageall-stream-err", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "safe-page"}},
|
||||
"has_more": true,
|
||||
"page_token": "next",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 230027,
|
||||
"msg": "user not authorized",
|
||||
},
|
||||
})
|
||||
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-zero code")
|
||||
}
|
||||
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "safe-page") {
|
||||
t.Fatalf("expected earlier successful page to remain streamed, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "230027") || strings.Contains(out, "user not authorized") {
|
||||
t.Fatalf("streaming stdout should not contain raw error JSON, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "\n \"code\"") {
|
||||
t.Fatalf("streaming stdout should not contain indented JSON error dump, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -629,6 +949,51 @@ func TestServiceMethod_PageAll_WithJq(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_PageAll_WithJqBusinessErrorOutputsRawResponse(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-spjq-err", AppSecret: "test-secret-spjq-err", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 230027, "msg": "user not authorized",
|
||||
},
|
||||
})
|
||||
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--as", "bot", "--page-all", "--jq", ".data.items[].id"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-zero code")
|
||||
}
|
||||
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
|
||||
var permErr *errs.PermissionError
|
||||
if !errors.As(err, &permErr) {
|
||||
t.Fatalf("expected PermissionError, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "230027") || !strings.Contains(stdout.String(), "user not authorized") {
|
||||
t.Fatalf("expected raw error response on stdout, got: %s", stdout.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), `"ok": true`) || strings.Contains(stdout.String(), `"ok":true`) {
|
||||
t.Fatalf("unexpected success envelope on error path: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func requireProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype, code int) {
|
||||
t.Helper()
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != category || p.Subtype != subtype || p.Code != code {
|
||||
t.Fatalf("problem = %s/%s/%d, want %s/%s/%d", p.Category, p.Subtype, p.Code, category, subtype, code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── file upload ──
|
||||
|
||||
func imImageMethod() meta.Method {
|
||||
|
||||
@@ -85,10 +85,12 @@ func symArrow() string {
|
||||
|
||||
// UpdateOptions holds inputs for the update command.
|
||||
type UpdateOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
JSON bool
|
||||
Force bool
|
||||
Check bool
|
||||
Factory *cmdutil.Factory
|
||||
JSON bool
|
||||
Force bool
|
||||
Check bool
|
||||
SkillsLayout string
|
||||
CollectedSkills string
|
||||
}
|
||||
|
||||
// NewCmdUpdate creates the update command.
|
||||
@@ -114,6 +116,8 @@ Use --check to only check for updates without installing.`,
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmd.Flags().BoolVar(&opts.Force, "force", false, "force reinstall even if already up to date")
|
||||
cmd.Flags().BoolVar(&opts.Check, "check", false, "only check for updates, do not install")
|
||||
cmd.Flags().StringVar(&opts.SkillsLayout, "skills-layout", "", "skills layout: separate, suite, or hybrid")
|
||||
cmd.Flags().StringVar(&opts.CollectedSkills, "collected-skills", "", "comma-separated skills collected into lark-suite; only valid with --skills-layout hybrid")
|
||||
cmdutil.SetRisk(cmd, "high-risk-write")
|
||||
|
||||
return cmd
|
||||
@@ -121,6 +125,9 @@ Use --check to only check for updates without installing.`,
|
||||
|
||||
func updateRun(opts *UpdateOptions) error {
|
||||
io := opts.Factory.IOStreams
|
||||
if err := validateSkillsLayoutOptions(opts); err != nil {
|
||||
return reportError(opts, io, output.ExitValidation, "validation_error", "%s", err)
|
||||
}
|
||||
cur := currentVersion()
|
||||
updater := newUpdater()
|
||||
|
||||
@@ -144,7 +151,7 @@ func updateRun(opts *UpdateOptions) error {
|
||||
if !opts.Force && !update.IsNewer(latest, cur) {
|
||||
var skillsResult *skillscheck.SyncResult
|
||||
if !opts.Check {
|
||||
skillsResult = runSkillsAndState(updater, io, cur, opts.Force)
|
||||
skillsResult = runSkillsAndState(updater, io, cur, opts.Force, opts.SkillsLayout, opts.CollectedSkills, !opts.JSON)
|
||||
}
|
||||
return reportAlreadyUpToDate(opts, io, cur, latest, skillsResult, opts.Check)
|
||||
}
|
||||
@@ -202,7 +209,7 @@ func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest s
|
||||
}
|
||||
|
||||
func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
|
||||
skillsResult := runSkillsAndState(updater, io, cur, opts.Force)
|
||||
skillsResult := runSkillsAndState(updater, io, cur, opts.Force, opts.SkillsLayout, opts.CollectedSkills, !opts.JSON)
|
||||
|
||||
reason := detect.ManualReason()
|
||||
if opts.JSON {
|
||||
@@ -280,7 +287,7 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
}
|
||||
|
||||
skillsResult := runSkillsAndState(updater, io, latest, opts.Force)
|
||||
skillsResult := runSkillsAndState(updater, io, latest, opts.Force, opts.SkillsLayout, opts.CollectedSkills, !opts.JSON)
|
||||
|
||||
if opts.JSON {
|
||||
result := map[string]interface{}{
|
||||
@@ -317,23 +324,77 @@ func verificationFailureHint(updater *selfupdate.Updater, latest string) string
|
||||
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
||||
}
|
||||
|
||||
func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, stateVersion string, force bool) *skillscheck.SyncResult {
|
||||
if !force {
|
||||
if existing, ok := skillscheck.ReadSyncedVersion(); ok && normalizeVersion(existing) == normalizeVersion(stateVersion) {
|
||||
func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, stateVersion string, force bool, requestedLayout, requestedCollected string, allowInteractiveFallback bool) *skillscheck.SyncResult {
|
||||
layout, collected := resolveSkillsSyncOptions(requestedLayout, requestedCollected)
|
||||
layoutExplicit := strings.TrimSpace(requestedLayout) != ""
|
||||
if !force && !layoutExplicit {
|
||||
if existing, existingLayout, ok := skillscheck.ReadSyncedVersionAndLayout(); ok && existingLayout != "" && normalizeVersion(existing) == normalizeVersion(stateVersion) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
result := syncSkills(skillscheck.SyncOptions{
|
||||
Version: stateVersion,
|
||||
Force: force,
|
||||
Runner: updater,
|
||||
Version: stateVersion,
|
||||
Layout: layout,
|
||||
CollectedSkills: collected,
|
||||
Force: force,
|
||||
Runner: updater,
|
||||
})
|
||||
if result.Err != nil && result.CanFallback && allowInteractiveFallback && io.IsTerminal && confirmSeparateFallback(io, layout, result.Err) {
|
||||
result = syncSkills(skillscheck.SyncOptions{
|
||||
Version: stateVersion,
|
||||
Layout: skillscheck.LayoutSeparate,
|
||||
Force: force,
|
||||
Runner: updater,
|
||||
})
|
||||
}
|
||||
if result.Err != nil && strings.Contains(result.Err.Error(), "state not written") {
|
||||
fmt.Fprintf(io.ErrOut, "warning: %v\n", result.Err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func confirmSeparateFallback(io *cmdutil.IOStreams, layout string, err error) bool {
|
||||
fmt.Fprintf(io.ErrOut, "Failed to install %s skills layout: %v\n", layout, err)
|
||||
fmt.Fprintf(io.ErrOut, "Use separate layout instead? [y/N]: ")
|
||||
var answer string
|
||||
if _, scanErr := fmt.Fscan(io.In, &answer); scanErr != nil {
|
||||
fmt.Fprintln(io.ErrOut)
|
||||
return false
|
||||
}
|
||||
answer = strings.TrimSpace(strings.ToLower(answer))
|
||||
return answer == "y" || answer == "yes"
|
||||
}
|
||||
|
||||
func validateSkillsLayoutOptions(opts *UpdateOptions) error {
|
||||
layout, ok := skillscheck.NormalizeLayout(opts.SkillsLayout)
|
||||
if !ok {
|
||||
return fmt.Errorf("--skills-layout must be one of separate, suite, or hybrid")
|
||||
}
|
||||
if opts.CollectedSkills != "" && layout != skillscheck.LayoutHybrid {
|
||||
return fmt.Errorf("--collected-skills can only be used with --skills-layout hybrid")
|
||||
}
|
||||
for _, skill := range skillscheck.ParseCollectedSkills(opts.CollectedSkills) {
|
||||
if skill == "lark-shared" {
|
||||
return fmt.Errorf("lark-shared cannot be selected by --collected-skills; hybrid keeps it both at top level and inside lark-suite for compatibility")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveSkillsSyncOptions(requestedLayout, requestedCollected string) (string, []string) {
|
||||
if strings.TrimSpace(requestedLayout) != "" {
|
||||
layout, _ := skillscheck.NormalizeLayout(requestedLayout)
|
||||
return layout, skillscheck.ParseCollectedSkills(requestedCollected)
|
||||
}
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err == nil && readable {
|
||||
if layout, ok := skillscheck.NormalizeLayout(state.Layout); ok && state.Layout != "" {
|
||||
return layout, state.CollectedSkills
|
||||
}
|
||||
}
|
||||
return skillscheck.LayoutSeparate, []string{}
|
||||
}
|
||||
|
||||
// reportAlreadyUpToDate emits the JSON / pretty output for the
|
||||
// already-up-to-date branch, including any skills_action / skills_warning
|
||||
// fields derived from skillsResult. When check is true, this is the pure
|
||||
@@ -380,6 +441,12 @@ func applySkillsStatus(env map[string]interface{}, target string) {
|
||||
if len(state.SkippedDeletedSkills) > 0 {
|
||||
status["skipped_deleted"] = state.SkippedDeletedSkills
|
||||
}
|
||||
if state.Layout != "" {
|
||||
status["layout"] = state.Layout
|
||||
}
|
||||
if len(state.CollectedSkills) > 0 {
|
||||
status["collected_skills"] = state.CollectedSkills
|
||||
}
|
||||
env["skills_status"] = status
|
||||
}
|
||||
|
||||
@@ -407,6 +474,12 @@ func skillsSummary(r *skillscheck.SyncResult) map[string]interface{} {
|
||||
if len(r.Failed) > 0 {
|
||||
summary["failed"] = r.Failed
|
||||
}
|
||||
if r.Layout != "" {
|
||||
summary["layout"] = r.Layout
|
||||
}
|
||||
if len(r.Collected) > 0 {
|
||||
summary["collected"] = r.Collected
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
@@ -420,9 +493,9 @@ func emitSkillsTextHints(io *cmdutil.IOStreams, r *skillscheck.SyncResult) {
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, " To retry all official skills: lark-cli update --force\n")
|
||||
case r.Force:
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills updated: restored all %d official skills\n", symOK(), len(r.Official))
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills updated: restored all %d official skills (%s layout)\n", symOK(), len(r.Official), r.Layout)
|
||||
default:
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills updated: %d official, %d updated, %d added, %d skipped because deleted locally\n", symOK(), len(r.Official), len(r.Updated), len(r.Added), len(r.SkippedDeleted))
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills updated: %d official, %d updated, %d added, %d skipped because deleted locally (%s layout)\n", symOK(), len(r.Official), len(r.Updated), len(r.Added), len(r.SkippedDeleted), r.Layout)
|
||||
if len(r.SkippedDeleted) > 0 {
|
||||
fmt.Fprintf(io.ErrOut, " To restore all official skills: lark-cli update --force\n")
|
||||
}
|
||||
|
||||
@@ -918,9 +918,21 @@ func newTestIO() *cmdutil.IOStreams {
|
||||
return cmdutil.NewIOStreams(&bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{})
|
||||
}
|
||||
|
||||
func assertStringsEqual(t *testing.T, got, want []string) {
|
||||
t.Helper()
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %#v, want %#v", got, want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("got %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkillsAndState_DedupHit(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21", Layout: skillscheck.LayoutSeparate}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
called := false
|
||||
@@ -930,7 +942,7 @@ func TestRunSkillsAndState_DedupHit(t *testing.T) {
|
||||
return &selfupdate.NpmResult{}
|
||||
},
|
||||
}
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false, "", "", false)
|
||||
if got != nil {
|
||||
t.Errorf("runSkillsAndState() = %+v, want nil for dedup hit", got)
|
||||
}
|
||||
@@ -939,11 +951,72 @@ func TestRunSkillsAndState_DedupHit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
|
||||
func TestRunSkillsAndState_MissingLayoutDoesNotDedup(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
origSync := syncSkills
|
||||
called := false
|
||||
syncSkills = func(opts skillscheck.SyncOptions) *skillscheck.SyncResult {
|
||||
called = true
|
||||
if opts.Layout != skillscheck.LayoutSeparate {
|
||||
t.Fatalf("opts.Layout = %q, want %q", opts.Layout, skillscheck.LayoutSeparate)
|
||||
}
|
||||
return &skillscheck.SyncResult{Action: "synced", Layout: opts.Layout}
|
||||
}
|
||||
t.Cleanup(func() { syncSkills = origSync })
|
||||
|
||||
got := runSkillsAndState(&selfupdate.Updater{}, newTestIO(), "1.0.21", false, "", "", false)
|
||||
if got == nil || got.Err != nil {
|
||||
t.Fatalf("runSkillsAndState() = %+v, want sync result", got)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("syncSkills not called; missing layout must not dedup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkillsAndState_InteractiveFallbackToSeparate(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
origSync := syncSkills
|
||||
calls := []string{}
|
||||
syncSkills = func(opts skillscheck.SyncOptions) *skillscheck.SyncResult {
|
||||
calls = append(calls, opts.Layout)
|
||||
if len(calls) == 1 {
|
||||
return &skillscheck.SyncResult{
|
||||
Action: "failed",
|
||||
Err: fmt.Errorf("special source failed"),
|
||||
Layout: opts.Layout,
|
||||
CanFallback: true,
|
||||
}
|
||||
}
|
||||
if opts.Layout != skillscheck.LayoutSeparate {
|
||||
t.Fatalf("fallback opts.Layout = %q, want %q", opts.Layout, skillscheck.LayoutSeparate)
|
||||
}
|
||||
return &skillscheck.SyncResult{Action: "synced", Layout: opts.Layout}
|
||||
}
|
||||
t.Cleanup(func() { syncSkills = origSync })
|
||||
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
io := cmdutil.NewIOStreams(strings.NewReader("y\n"), stdout, stderr)
|
||||
io.IsTerminal = true
|
||||
got := runSkillsAndState(&selfupdate.Updater{}, io, "1.0.21", false, skillscheck.LayoutSuite, "", true)
|
||||
|
||||
if got == nil || got.Err != nil || got.Layout != skillscheck.LayoutSeparate {
|
||||
t.Fatalf("runSkillsAndState() = %+v, want successful separate fallback", got)
|
||||
}
|
||||
assertStringsEqual(t, calls, []string{skillscheck.LayoutSuite, skillscheck.LayoutSeparate})
|
||||
if !strings.Contains(stderr.String(), "Use separate layout instead?") {
|
||||
t.Fatalf("stderr = %q, want fallback prompt", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21", Layout: skillscheck.LayoutSeparate}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
called := false
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
@@ -951,7 +1024,7 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
|
||||
return successfulSkillsCommand()(args...)
|
||||
},
|
||||
}
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", true)
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", true, "", "", false)
|
||||
if got == nil || got.Err != nil {
|
||||
t.Fatalf("runSkillsAndState(force=true) = %+v, want successful result", got)
|
||||
}
|
||||
@@ -963,7 +1036,7 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
|
||||
func TestRunSkillsAndState_SuccessWritesState(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
updater := &selfupdate.Updater{SkillsCommandOverride: successfulSkillsCommand()}
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false, "", "", false)
|
||||
if got == nil || got.Err != nil {
|
||||
t.Fatalf("runSkillsAndState() = %+v, want non-nil with nil Err", got)
|
||||
}
|
||||
@@ -988,7 +1061,7 @@ func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) {
|
||||
return r
|
||||
},
|
||||
}
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false, "", "", false)
|
||||
if got == nil || got.Err == nil {
|
||||
t.Fatalf("runSkillsAndState() = %+v, want non-nil with non-nil Err", got)
|
||||
}
|
||||
@@ -1001,6 +1074,49 @@ func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSkillsLayoutOptions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
opts UpdateOptions
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "collected without hybrid",
|
||||
opts: UpdateOptions{SkillsLayout: skillscheck.LayoutSeparate, CollectedSkills: "lark-im"},
|
||||
want: "--collected-skills can only be used with --skills-layout hybrid",
|
||||
},
|
||||
{
|
||||
name: "shared cannot be collected",
|
||||
opts: UpdateOptions{SkillsLayout: skillscheck.LayoutHybrid, CollectedSkills: "lark-shared"},
|
||||
want: "lark-shared cannot be selected",
|
||||
},
|
||||
{
|
||||
name: "unknown layout",
|
||||
opts: UpdateOptions{SkillsLayout: "compact"},
|
||||
want: "--skills-layout must be one of separate, suite, or hybrid",
|
||||
},
|
||||
{
|
||||
name: "hybrid collected ok",
|
||||
opts: UpdateOptions{SkillsLayout: skillscheck.LayoutHybrid, CollectedSkills: "lark-im,lark-base"},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateSkillsLayoutOptions(&tt.opts)
|
||||
if tt.want == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("validateSkillsLayoutOptions() err = %v, want nil", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("validateSkillsLayoutOptions() err = %v, want containing %q", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncate(t *testing.T) {
|
||||
long := strings.Repeat("x", 3000)
|
||||
got := selfupdate.Truncate(long, 2000)
|
||||
@@ -1281,7 +1397,7 @@ func TestRunSkillsAndState_StateWriteFailureWarns(t *testing.T) {
|
||||
t.Cleanup(func() { syncSkills = origSync })
|
||||
|
||||
f, _, stderr := newTestFactory(t)
|
||||
got := runSkillsAndState(&selfupdate.Updater{}, f.IOStreams, "1.0.21", false)
|
||||
got := runSkillsAndState(&selfupdate.Updater{}, f.IOStreams, "1.0.21", false, "", "", false)
|
||||
if got == nil || got.Err == nil {
|
||||
t.Fatalf("runSkillsAndState() = %+v, want non-nil with write error", got)
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ const (
|
||||
const (
|
||||
SubtypeChallengeRequired Subtype = "challenge_required" // user must complete browser challenge / MFA
|
||||
SubtypeAccessDenied Subtype = "access_denied" // policy denies access outright
|
||||
SubtypeContentSafety Subtype = "content_safety" // content-safety scanner blocked output in block mode
|
||||
)
|
||||
|
||||
// CategoryInternal subtypes
|
||||
|
||||
@@ -350,7 +350,7 @@ func (c *APIClient) CallAPI(ctx context.Context, request RawApiRequest) (interfa
|
||||
|
||||
// paginateLoop runs the core pagination loop. For each successful page (code == 0),
|
||||
// it calls onResult if non-nil. It always accumulates and returns all raw page results.
|
||||
func (c *APIClient) paginateLoop(ctx context.Context, request RawApiRequest, opts PaginationOptions, onResult func(interface{})) ([]interface{}, error) {
|
||||
func (c *APIClient) paginateLoop(ctx context.Context, request RawApiRequest, opts PaginationOptions, onResult func(interface{}) error) ([]interface{}, error) {
|
||||
var allResults []interface{}
|
||||
var pageToken string
|
||||
page := 0
|
||||
@@ -399,7 +399,9 @@ func (c *APIClient) paginateLoop(ctx context.Context, request RawApiRequest, opt
|
||||
}
|
||||
|
||||
if onResult != nil {
|
||||
onResult(result)
|
||||
if err := onResult(result); err != nil {
|
||||
return allResults, err
|
||||
}
|
||||
}
|
||||
allResults = append(allResults, result)
|
||||
|
||||
@@ -452,28 +454,31 @@ func (c *APIClient) PaginateAll(ctx context.Context, request RawApiRequest, opts
|
||||
// StreamPages fetches all pages and streams each page's list items via onItems.
|
||||
// Returns the last page result (for error checking), whether any list items were found,
|
||||
// and any network error. Use this for streaming formats (ndjson, table, csv).
|
||||
func (c *APIClient) StreamPages(ctx context.Context, request RawApiRequest, onItems func([]interface{}), opts PaginationOptions) (result interface{}, hasItems bool, err error) {
|
||||
func (c *APIClient) StreamPages(ctx context.Context, request RawApiRequest, onItems func([]interface{}) error, opts PaginationOptions) (result interface{}, hasItems bool, err error) {
|
||||
totalItems := 0
|
||||
results, loopErr := c.paginateLoop(ctx, request, opts, func(r interface{}) {
|
||||
results, loopErr := c.paginateLoop(ctx, request, opts, func(r interface{}) error {
|
||||
resultMap, ok := r.(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
data, ok := resultMap["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
arrayField := output.FindArrayField(data)
|
||||
if arrayField == "" {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
items, ok := data[arrayField].([]interface{})
|
||||
if !ok {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
totalItems += len(items)
|
||||
onItems(items)
|
||||
if err := onItems(items); err != nil {
|
||||
return err
|
||||
}
|
||||
hasItems = true
|
||||
return nil
|
||||
})
|
||||
if loopErr != nil {
|
||||
return nil, false, loopErr
|
||||
|
||||
@@ -124,8 +124,9 @@ func TestStreamPages_NonBatchAPI_NoArrayField(t *testing.T) {
|
||||
Method: "GET",
|
||||
URL: "/open-apis/contact/v3/users/u123",
|
||||
As: "bot",
|
||||
}, func(items []interface{}) {
|
||||
}, func(items []interface{}) error {
|
||||
t.Error("onItems should not be called for non-batch API")
|
||||
return nil
|
||||
}, PaginationOptions{})
|
||||
|
||||
if err != nil {
|
||||
@@ -168,8 +169,9 @@ func TestStreamPages_BatchAPI_WithArrayField(t *testing.T) {
|
||||
Method: "GET",
|
||||
URL: "/open-apis/contact/v3/users",
|
||||
As: "bot",
|
||||
}, func(items []interface{}) {
|
||||
}, func(items []interface{}) error {
|
||||
streamedItems = append(streamedItems, items...)
|
||||
return nil
|
||||
}, PaginationOptions{})
|
||||
|
||||
if err != nil {
|
||||
@@ -189,6 +191,58 @@ func TestStreamPages_BatchAPI_WithArrayField(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamPages_OnItemsErrorStopsPagination(t *testing.T) {
|
||||
apiCalls := 0
|
||||
rt := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
apiCalls++
|
||||
if apiCalls == 1 {
|
||||
return jsonResponse(map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "1"}},
|
||||
"has_more": true,
|
||||
"page_token": "next",
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return jsonResponse(map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "2"}},
|
||||
"has_more": false,
|
||||
},
|
||||
}), nil
|
||||
})
|
||||
|
||||
ac, _ := newTestAPIClient(t, rt)
|
||||
sentinel := errors.New("stop streaming")
|
||||
var streamedItems []interface{}
|
||||
result, hasItems, err := ac.StreamPages(context.Background(), RawApiRequest{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/contact/v3/users",
|
||||
As: "bot",
|
||||
}, func(items []interface{}) error {
|
||||
streamedItems = append(streamedItems, items...)
|
||||
return sentinel
|
||||
}, PaginationOptions{PageDelay: 0})
|
||||
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("err = %v, want sentinel", err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Fatalf("result = %#v, want nil when callback stops pagination", result)
|
||||
}
|
||||
if hasItems {
|
||||
t.Fatal("hasItems = true, want false when callback stops before returning")
|
||||
}
|
||||
if apiCalls != 1 {
|
||||
t.Fatalf("apiCalls = %d, want early stop after first page", apiCalls)
|
||||
}
|
||||
if len(streamedItems) != 1 {
|
||||
t.Fatalf("streamedItems = %d, want first page only", len(streamedItems))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginateAll_PageLimitStopsPagination(t *testing.T) {
|
||||
apiCalls := 0
|
||||
rt := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
@@ -19,33 +18,6 @@ type PaginationOptions struct {
|
||||
Identity core.Identity // identity passed to checkErr; defaults to AsUser when empty
|
||||
}
|
||||
|
||||
// PaginateWithJq aggregates all pages, checks for API errors, then applies a jq filter.
|
||||
// If checkErr detects an error, the raw result is printed as JSON before returning the error.
|
||||
func PaginateWithJq(ctx context.Context, ac *APIClient, request RawApiRequest,
|
||||
jqExpr string, out io.Writer, pagOpts PaginationOptions,
|
||||
checkErr func(interface{}, core.Identity) error) error {
|
||||
result, err := ac.PaginateAll(ctx, request, pagOpts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Identity resolution honors pagOpts.Identity first, then the request's
|
||||
// own identity, and only falls back to AsUser when neither caller
|
||||
// supplied one. Without checking request.As, bot/auto requests would
|
||||
// always be classified as user identity for checkErr.
|
||||
identity := pagOpts.Identity
|
||||
if identity == "" {
|
||||
identity = request.As
|
||||
}
|
||||
if identity == "" || identity == core.AsAuto {
|
||||
identity = core.AsUser
|
||||
}
|
||||
if apiErr := checkErr(result, identity); apiErr != nil {
|
||||
output.FormatValue(out, result, output.FormatJSON)
|
||||
return apiErr
|
||||
}
|
||||
return output.JqFilter(out, result, jqExpr)
|
||||
}
|
||||
|
||||
func mergePagedResults(w io.Writer, results []interface{}) interface{} {
|
||||
if len(results) == 0 {
|
||||
return map[string]interface{}{}
|
||||
|
||||
@@ -89,23 +89,37 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
|
||||
if apiErr := check(result, identity); apiErr != nil {
|
||||
return apiErr
|
||||
}
|
||||
// Content safety scanning
|
||||
scanResult := output.ScanForSafety(opts.CommandPath, result, opts.ErrOut)
|
||||
if scanResult.Blocked {
|
||||
return scanResult.BlockErr
|
||||
}
|
||||
if opts.OutputPath != "" {
|
||||
// File downloads keep the existing raw-response scan path because the
|
||||
// saved payload is the API response body, not the success envelope.
|
||||
scanResult := output.ScanForSafety(opts.CommandPath, result, opts.ErrOut)
|
||||
if scanResult.Blocked {
|
||||
return scanResult.BlockErr
|
||||
}
|
||||
if scanResult.Alert != nil {
|
||||
output.WriteAlertWarning(opts.ErrOut, scanResult.Alert)
|
||||
}
|
||||
return saveAndPrint(opts.FileIO, resp, opts.OutputPath, opts.Out)
|
||||
}
|
||||
|
||||
if opts.JqExpr != "" || opts.Format == output.FormatJSON {
|
||||
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
|
||||
CommandPath: opts.CommandPath,
|
||||
Identity: string(identity),
|
||||
JqExpr: opts.JqExpr,
|
||||
Out: opts.Out,
|
||||
ErrOut: opts.ErrOut,
|
||||
})
|
||||
}
|
||||
|
||||
// Content safety scanning for non-JSON presentation formats.
|
||||
scanResult := output.ScanForSafety(opts.CommandPath, result, opts.ErrOut)
|
||||
if scanResult.Blocked {
|
||||
return scanResult.BlockErr
|
||||
}
|
||||
if scanResult.Alert != nil {
|
||||
output.WriteAlertWarning(opts.ErrOut, scanResult.Alert)
|
||||
}
|
||||
if opts.JqExpr != "" {
|
||||
return output.JqFilter(opts.Out, result, opts.JqExpr)
|
||||
}
|
||||
output.FormatValue(opts.Out, result, opts.Format)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
)
|
||||
@@ -207,15 +209,54 @@ func TestHandleResponse_JSON(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
var errOut bytes.Buffer
|
||||
err := HandleResponse(resp, ResponseOptions{
|
||||
Out: &out,
|
||||
ErrOut: &errOut,
|
||||
FileIO: &localfileio.LocalFileIO{},
|
||||
Identity: core.AsBot,
|
||||
Out: &out,
|
||||
ErrOut: &errOut,
|
||||
FileIO: &localfileio.LocalFileIO{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("HandleResponse failed: %v", err)
|
||||
}
|
||||
if !bytes.Contains(out.Bytes(), []byte(`"code"`)) {
|
||||
t.Errorf("expected JSON output, got: %s", out.String())
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("invalid JSON output: %v\n%s", err, out.String())
|
||||
}
|
||||
if got["ok"] != true {
|
||||
t.Fatalf("ok = %v, want true; output: %s", got["ok"], out.String())
|
||||
}
|
||||
if got["identity"] != "bot" {
|
||||
t.Fatalf("identity = %v, want bot; output: %s", got["identity"], out.String())
|
||||
}
|
||||
if _, hasCode := got["code"]; hasCode {
|
||||
t.Fatalf("success envelope leaked outer code field: %s", out.String())
|
||||
}
|
||||
data, ok := got["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("data = %T, want object; output: %s", got["data"], out.String())
|
||||
}
|
||||
if data["id"] != "1" {
|
||||
t.Fatalf("data.id = %v, want 1; output: %s", data["id"], out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponse_JSONWithJqUsesSuccessEnvelope(t *testing.T) {
|
||||
body := []byte(`{"code":0,"msg":"ok","data":{"id":"1"}}`)
|
||||
resp := newApiResp(body, map[string]string{"Content-Type": "application/json"})
|
||||
|
||||
var out bytes.Buffer
|
||||
var errOut bytes.Buffer
|
||||
err := HandleResponse(resp, ResponseOptions{
|
||||
Identity: core.AsBot,
|
||||
JqExpr: ".data.id",
|
||||
Out: &out,
|
||||
ErrOut: &errOut,
|
||||
FileIO: &localfileio.LocalFileIO{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("HandleResponse failed: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(out.String()) != "1" {
|
||||
t.Fatalf("jq output = %q, want %q", out.String(), "1")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,6 +274,12 @@ func TestHandleResponse_JSONWithError(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Error("expected error for non-zero code")
|
||||
}
|
||||
if _, ok := errs.ProblemOf(err); !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if strings.Contains(out.String(), `"ok": true`) || strings.Contains(out.String(), `"ok":true`) {
|
||||
t.Fatalf("unexpected success envelope on error path: %s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponse_BinaryAutoSave(t *testing.T) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcs "github.com/larksuite/cli/extension/contentsafety"
|
||||
)
|
||||
|
||||
@@ -35,19 +36,16 @@ func ScanForSafety(cmdPath string, data any, errOut io.Writer) ScanResult {
|
||||
return ScanResult{Alert: alert}
|
||||
}
|
||||
|
||||
// wrapBlockError creates an ExitError for content-safety block.
|
||||
// wrapBlockError creates a typed error for content-safety block.
|
||||
func wrapBlockError(alert *extcs.Alert) error {
|
||||
rules := ""
|
||||
var matchedRules []string
|
||||
if alert != nil {
|
||||
rules = strings.Join(alert.MatchedRules, ", ")
|
||||
}
|
||||
return &ExitError{
|
||||
Code: ExitContentSafety,
|
||||
Detail: &ErrDetail{
|
||||
Type: "content_safety_blocked",
|
||||
Message: fmt.Sprintf("content safety violation detected (rules: %s)", rules),
|
||||
},
|
||||
matchedRules = alert.MatchedRules
|
||||
}
|
||||
return errs.NewContentSafetyError(errs.SubtypeContentSafety,
|
||||
"content safety violation detected (rules: %s)", strings.Join(matchedRules, ", ")).
|
||||
WithRules(matchedRules...).
|
||||
WithCause(errBlocked)
|
||||
}
|
||||
|
||||
// WriteAlertWarning writes a human-readable content-safety warning to w.
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcs "github.com/larksuite/cli/extension/contentsafety"
|
||||
)
|
||||
|
||||
@@ -72,12 +73,18 @@ func TestScanForSafety_ModeBlock_WithAlert(t *testing.T) {
|
||||
if result.BlockErr == nil {
|
||||
t.Error("block mode with alert should have BlockErr")
|
||||
}
|
||||
var exitErr *ExitError
|
||||
if !errors.As(result.BlockErr, &exitErr) {
|
||||
t.Fatalf("BlockErr should be *ExitError, got %T", result.BlockErr)
|
||||
var safetyErr *errs.ContentSafetyError
|
||||
if !errors.As(result.BlockErr, &safetyErr) {
|
||||
t.Fatalf("BlockErr should be *ContentSafetyError, got %T", result.BlockErr)
|
||||
}
|
||||
if exitErr.Code != ExitContentSafety {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, ExitContentSafety)
|
||||
if safetyErr.Category != errs.CategoryPolicy || safetyErr.Subtype != errs.SubtypeContentSafety {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", safetyErr.Category, safetyErr.Subtype, errs.CategoryPolicy, errs.SubtypeContentSafety)
|
||||
}
|
||||
if len(safetyErr.Rules) != 1 || safetyErr.Rules[0] != "r1" {
|
||||
t.Errorf("rules = %v, want [r1]", safetyErr.Rules)
|
||||
}
|
||||
if !errors.Is(result.BlockErr, errBlocked) {
|
||||
t.Error("BlockErr should preserve errBlocked cause")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
58
internal/output/envelope_success.go
Normal file
58
internal/output/envelope_success.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import "io"
|
||||
|
||||
// SuccessEnvelopeOptions configures the shortcut-compatible success envelope.
|
||||
type SuccessEnvelopeOptions struct {
|
||||
CommandPath string
|
||||
Identity string
|
||||
JqExpr string
|
||||
Out io.Writer
|
||||
ErrOut io.Writer
|
||||
}
|
||||
|
||||
// SuccessEnvelopeData extracts the business payload for the standard success
|
||||
// envelope from a Lark API response. Outer code/msg fields are transport
|
||||
// protocol details and are intentionally not exposed as business data.
|
||||
func SuccessEnvelopeData(result interface{}) interface{} {
|
||||
m, ok := result.(map[string]interface{})
|
||||
if !ok {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
data, ok := m["data"]
|
||||
if !ok || data == nil {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// WriteSuccessEnvelope emits the standard success envelope used by shortcuts.
|
||||
// JSON output carries content-safety alerts inside the envelope. When jq is
|
||||
// applied, the alert may be filtered away, so warn mode also writes stderr.
|
||||
func WriteSuccessEnvelope(data interface{}, opts SuccessEnvelopeOptions) error {
|
||||
scanResult := ScanForSafety(opts.CommandPath, data, opts.ErrOut)
|
||||
if scanResult.Blocked {
|
||||
return scanResult.BlockErr
|
||||
}
|
||||
|
||||
env := Envelope{
|
||||
OK: true,
|
||||
Identity: opts.Identity,
|
||||
Data: data,
|
||||
Notice: GetNotice(),
|
||||
}
|
||||
if scanResult.Alert != nil {
|
||||
env.ContentSafetyAlert = scanResult.Alert
|
||||
}
|
||||
if opts.JqExpr != "" {
|
||||
if scanResult.Alert != nil && opts.ErrOut != nil {
|
||||
WriteAlertWarning(opts.ErrOut, scanResult.Alert)
|
||||
}
|
||||
return JqFilter(opts.Out, env, opts.JqExpr)
|
||||
}
|
||||
PrintJson(opts.Out, env)
|
||||
return nil
|
||||
}
|
||||
173
internal/output/envelope_success_test.go
Normal file
173
internal/output/envelope_success_test.go
Normal file
@@ -0,0 +1,173 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcs "github.com/larksuite/cli/extension/contentsafety"
|
||||
)
|
||||
|
||||
func TestSuccessEnvelopeData_ExtractsBusinessData(t *testing.T) {
|
||||
result := map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{"id": "1"},
|
||||
}
|
||||
|
||||
got := SuccessEnvelopeData(result)
|
||||
m, ok := got.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("business data type = %T, want map", got)
|
||||
}
|
||||
if m["id"] != "1" {
|
||||
t.Fatalf("id = %v, want 1", m["id"])
|
||||
}
|
||||
if _, ok := m["code"]; ok {
|
||||
t.Fatal("business data must not contain outer code")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuccessEnvelopeData_MissingDataUsesEmptyObject(t *testing.T) {
|
||||
got := SuccessEnvelopeData(map[string]interface{}{"code": float64(0), "msg": "ok"})
|
||||
m, ok := got.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("business data type = %T, want map", got)
|
||||
}
|
||||
if len(m) != 0 {
|
||||
t.Fatalf("business data = %#v, want empty object", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuccessEnvelopeData_NilDataUsesEmptyObject(t *testing.T) {
|
||||
got := SuccessEnvelopeData(map[string]interface{}{"code": float64(0), "msg": "ok", "data": nil})
|
||||
m, ok := got.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("business data type = %T, want map", got)
|
||||
}
|
||||
if len(m) != 0 {
|
||||
t.Fatalf("business data = %#v, want empty object", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSuccessEnvelope_PrintsShortcutCompatibleEnvelope(t *testing.T) {
|
||||
var out strings.Builder
|
||||
|
||||
err := WriteSuccessEnvelope(map[string]interface{}{"id": "1"}, SuccessEnvelopeOptions{
|
||||
Identity: "bot",
|
||||
Out: &out,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteSuccessEnvelope() error = %v", err)
|
||||
}
|
||||
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out.String()), &env); err != nil {
|
||||
t.Fatalf("invalid JSON output: %v\n%s", err, out.String())
|
||||
}
|
||||
if env["ok"] != true || env["identity"] != "bot" {
|
||||
t.Fatalf("unexpected envelope: %#v", env)
|
||||
}
|
||||
data, ok := env["data"].(map[string]interface{})
|
||||
if !ok || data["id"] != "1" {
|
||||
t.Fatalf("unexpected data payload: %#v", env["data"])
|
||||
}
|
||||
if _, ok := env["code"]; ok {
|
||||
t.Fatalf("output leaked protocol field code: %#v", env)
|
||||
}
|
||||
if _, ok := env["msg"]; ok {
|
||||
t.Fatalf("output leaked protocol field msg: %#v", env)
|
||||
}
|
||||
if _, ok := env["_content_safety_alert"]; ok {
|
||||
t.Fatalf("output should omit empty content-safety alert: %#v", env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSuccessEnvelope_JqUsesEnvelope(t *testing.T) {
|
||||
var out strings.Builder
|
||||
|
||||
err := WriteSuccessEnvelope(map[string]interface{}{"id": "1"}, SuccessEnvelopeOptions{
|
||||
Identity: "bot",
|
||||
JqExpr: ".data.id",
|
||||
Out: &out,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteSuccessEnvelope() error = %v", err)
|
||||
}
|
||||
if strings.TrimSpace(out.String()) != "1" {
|
||||
t.Fatalf("jq output = %q, want %q", out.String(), "1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSuccessEnvelope_JqWarnsWhenSafetyAlertFiltered(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
|
||||
extcs.Register(&mockProvider{
|
||||
name: "mock",
|
||||
alert: &extcs.Alert{Provider: "mock", MatchedRules: []string{"r1"}},
|
||||
})
|
||||
t.Cleanup(func() { extcs.Register(nil) })
|
||||
|
||||
var out strings.Builder
|
||||
var errOut strings.Builder
|
||||
err := WriteSuccessEnvelope(map[string]interface{}{"id": "1"}, SuccessEnvelopeOptions{
|
||||
CommandPath: "lark-cli im +test",
|
||||
Identity: "bot",
|
||||
JqExpr: ".data.id",
|
||||
Out: &out,
|
||||
ErrOut: &errOut,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteSuccessEnvelope() error = %v", err)
|
||||
}
|
||||
if strings.TrimSpace(out.String()) != "1" {
|
||||
t.Fatalf("jq output = %q, want %q", out.String(), "1")
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "warning: content safety alert from mock") {
|
||||
t.Fatalf("expected content safety warning on stderr, got: %s", errOut.String())
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "r1") {
|
||||
t.Fatalf("expected rule in stderr warning, got: %s", errOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSuccessEnvelope_BlockModeReturnsTypedErrorWithoutStdout(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
|
||||
extcs.Register(&mockProvider{
|
||||
name: "mock",
|
||||
alert: &extcs.Alert{Provider: "mock", MatchedRules: []string{"r1"}},
|
||||
})
|
||||
t.Cleanup(func() { extcs.Register(nil) })
|
||||
|
||||
var out strings.Builder
|
||||
var errOut strings.Builder
|
||||
err := WriteSuccessEnvelope(map[string]interface{}{"id": "1"}, SuccessEnvelopeOptions{
|
||||
CommandPath: "lark-cli im +test",
|
||||
Identity: "bot",
|
||||
Out: &out,
|
||||
ErrOut: &errOut,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected content safety block error")
|
||||
}
|
||||
var safetyErr *errs.ContentSafetyError
|
||||
if !errors.As(err, &safetyErr) {
|
||||
t.Fatalf("expected ContentSafetyError, got %T: %v", err, err)
|
||||
}
|
||||
if safetyErr.Category != errs.CategoryPolicy || safetyErr.Subtype != errs.SubtypeContentSafety {
|
||||
t.Fatalf("problem = %s/%s, want %s/%s", safetyErr.Category, safetyErr.Subtype, errs.CategoryPolicy, errs.SubtypeContentSafety)
|
||||
}
|
||||
if len(safetyErr.Rules) != 1 || safetyErr.Rules[0] != "r1" {
|
||||
t.Fatalf("rules = %v, want [r1]", safetyErr.Rules)
|
||||
}
|
||||
if !errors.Is(err, errBlocked) {
|
||||
t.Fatal("content safety error should preserve errBlocked cause")
|
||||
}
|
||||
if out.String() != "" {
|
||||
t.Fatalf("stdout should stay empty on block, got: %s", out.String())
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,8 @@ const (
|
||||
var (
|
||||
skillsIndexFetchTimeout = 10 * time.Second
|
||||
officialSkillsIndexURL = "https://open.feishu.cn/.well-known/skills/index.json"
|
||||
isolatedSkillsSourceURL = "https://open.feishu.cn/lark-cli/isolated-skills"
|
||||
isolatedSkillsFallback = "larksuite/cli/isolated-skills"
|
||||
)
|
||||
|
||||
// DetectResult holds installation detection results.
|
||||
@@ -242,6 +244,14 @@ func (u *Updater) InstallAllSkills() *NpmResult {
|
||||
return r
|
||||
}
|
||||
|
||||
func (u *Updater) InstallSuiteSkill() *NpmResult {
|
||||
r := u.runSkillsInstall(isolatedSkillsSourceURL, []string{"lark-suite"})
|
||||
if r.Err != nil {
|
||||
r = u.runSkillsInstall(isolatedSkillsFallback, []string{"lark-suite"})
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (u *Updater) runSkillsAdd(source string) *NpmResult {
|
||||
return u.runSkillsCommand("-y", "skills", "add", source, "-g", "-y")
|
||||
}
|
||||
|
||||
@@ -208,6 +208,13 @@ func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
|
||||
},
|
||||
want: "-y skills add https://open.feishu.cn -s lark-mail -g -y",
|
||||
},
|
||||
{
|
||||
name: "install suite skill isolated source",
|
||||
run: func(u *Updater) *NpmResult {
|
||||
return u.runSkillsInstall(isolatedSkillsSourceURL, []string{"lark-suite"})
|
||||
},
|
||||
want: "-y skills add https://open.feishu.cn/lark-cli/isolated-skills -s lark-suite -g -y",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -371,3 +378,32 @@ func TestListOfficialSkillsFallsBack(t *testing.T) {
|
||||
t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallSuiteSkillFallsBackToIsolatedGitHubSource(t *testing.T) {
|
||||
called := []string{}
|
||||
updater := &Updater{
|
||||
SkillsCommandOverride: func(args ...string) *NpmResult {
|
||||
call := strings.Join(args, " ")
|
||||
called = append(called, call)
|
||||
r := &NpmResult{}
|
||||
if strings.Contains(call, isolatedSkillsSourceURL) {
|
||||
r.Err = fmt.Errorf("primary failed")
|
||||
}
|
||||
return r
|
||||
},
|
||||
}
|
||||
|
||||
result := updater.InstallSuiteSkill()
|
||||
if result.Err != nil {
|
||||
t.Fatalf("InstallSuiteSkill() err = %v, want nil", result.Err)
|
||||
}
|
||||
if len(called) != 2 {
|
||||
t.Fatalf("called %d commands, want 2: %#v", len(called), called)
|
||||
}
|
||||
if !strings.Contains(called[0], isolatedSkillsSourceURL) {
|
||||
t.Fatalf("primary call = %q, want %s", called[0], isolatedSkillsSourceURL)
|
||||
}
|
||||
if !strings.Contains(called[1], isolatedSkillsFallback) {
|
||||
t.Fatalf("fallback call = %q, want %s", called[1], isolatedSkillsFallback)
|
||||
}
|
||||
}
|
||||
|
||||
385
internal/skillscheck/layout.go
Normal file
385
internal/skillscheck/layout.go
Normal file
@@ -0,0 +1,385 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
LayoutSeparate = "separate"
|
||||
LayoutSuite = "suite"
|
||||
LayoutHybrid = "hybrid"
|
||||
|
||||
suiteSkillName = "lark-suite"
|
||||
sharedSkillName = "lark-shared"
|
||||
suiteRoutesPlaceholder = "<!-- LARK_SUITE_ROUTES -->"
|
||||
)
|
||||
|
||||
type GlobalSkillInfo struct {
|
||||
Name string
|
||||
Path string
|
||||
}
|
||||
|
||||
func NormalizeLayout(layout string) (string, bool) {
|
||||
switch strings.TrimSpace(layout) {
|
||||
case "", LayoutSeparate:
|
||||
return LayoutSeparate, true
|
||||
case LayoutSuite:
|
||||
return LayoutSuite, true
|
||||
case LayoutHybrid:
|
||||
return LayoutHybrid, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func ParseCollectedSkills(value string) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, part := range strings.Split(value, ",") {
|
||||
name := strings.TrimSpace(part)
|
||||
if name != "" {
|
||||
seen[name] = true
|
||||
}
|
||||
}
|
||||
return sortedKeys(seen)
|
||||
}
|
||||
|
||||
func ParseGlobalSkillInfosJSON(text string) []GlobalSkillInfo {
|
||||
infos, _ := parseGlobalSkillInfosJSON(text)
|
||||
return infos
|
||||
}
|
||||
|
||||
func parseGlobalSkillInfosJSON(text string) ([]GlobalSkillInfo, bool) {
|
||||
type globalSkill struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
var skills []globalSkill
|
||||
if err := json.Unmarshal([]byte(text), &skills); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
seen := map[string]GlobalSkillInfo{}
|
||||
for _, skill := range skills {
|
||||
name := strings.TrimSpace(skill.Name)
|
||||
path := strings.TrimSpace(skill.Path)
|
||||
if name == "" || path == "" || !skillNamePattern.MatchString(name) {
|
||||
continue
|
||||
}
|
||||
seen[name] = GlobalSkillInfo{Name: name, Path: path}
|
||||
}
|
||||
|
||||
out := make([]GlobalSkillInfo, 0, len(seen))
|
||||
for _, info := range seen {
|
||||
out = append(out, info)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out, true
|
||||
}
|
||||
|
||||
func installedSkillNamesFromInfos(infos []GlobalSkillInfo) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, info := range infos {
|
||||
seen[info.Name] = true
|
||||
if info.Name == suiteSkillName {
|
||||
for _, subskill := range listSuiteSubskills(info.Path) {
|
||||
seen[subskill] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return sortedKeys(seen)
|
||||
}
|
||||
|
||||
func listSuiteSubskills(suitePath string) []string {
|
||||
entries, err := os.ReadDir(filepath.Join(suitePath, "references", "subskills"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(entry.Name())
|
||||
if name != "" && skillNamePattern.MatchString(name) {
|
||||
seen[name] = true
|
||||
}
|
||||
}
|
||||
return sortedKeys(seen)
|
||||
}
|
||||
|
||||
func normalOfficialSkills(skills []string) []string {
|
||||
out := []string{}
|
||||
for _, skill := range uniqueSorted(skills) {
|
||||
if skill == suiteSkillName {
|
||||
continue
|
||||
}
|
||||
out = append(out, skill)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func resolveCollectedSkills(layout string, requested, official []string, previous *SkillsState, stateReadable bool, skippedDeleted []string) ([]string, error) {
|
||||
officialSet := toSet(official)
|
||||
deletedSet := toSet(skippedDeleted)
|
||||
switch layout {
|
||||
case LayoutSeparate:
|
||||
return []string{}, nil
|
||||
case LayoutSuite:
|
||||
return suiteEffectiveSkills(official, deletedSet), nil
|
||||
case LayoutHybrid:
|
||||
collected := []string{}
|
||||
for _, skill := range uniqueSorted(requested) {
|
||||
if skill == sharedSkillName {
|
||||
return nil, fmt.Errorf("%s is not selectable in hybrid layout", sharedSkillName)
|
||||
}
|
||||
if !officialSet[skill] {
|
||||
return nil, fmt.Errorf("collected skill %q is not in official skills", skill)
|
||||
}
|
||||
if !deletedSet[skill] {
|
||||
collected = append(collected, skill)
|
||||
}
|
||||
}
|
||||
for _, skill := range newlyOfficialSkills(official, previous, stateReadable) {
|
||||
if skill != sharedSkillName && !deletedSet[skill] {
|
||||
collected = append(collected, skill)
|
||||
}
|
||||
}
|
||||
if officialSet[sharedSkillName] {
|
||||
collected = append([]string{sharedSkillName}, collected...)
|
||||
}
|
||||
return uniqueSortedWithFirst(collected, sharedSkillName), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported skills layout %q", layout)
|
||||
}
|
||||
}
|
||||
|
||||
func suiteEffectiveSkills(official []string, deletedSet map[string]bool) []string {
|
||||
out := []string{}
|
||||
for _, skill := range normalOfficialSkills(official) {
|
||||
if skill == sharedSkillName || !deletedSet[skill] {
|
||||
out = append(out, skill)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func newlyOfficialSkills(official []string, previous *SkillsState, stateReadable bool) []string {
|
||||
if !stateReadable || previous == nil {
|
||||
return []string{}
|
||||
}
|
||||
previousSet := toSet(previous.OfficialSkills)
|
||||
out := []string{}
|
||||
for _, skill := range normalOfficialSkills(official) {
|
||||
if !previousSet[skill] {
|
||||
out = append(out, skill)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func uniqueSortedWithFirst(values []string, first string) []string {
|
||||
seen := toSet(values)
|
||||
if !seen[first] {
|
||||
return sortedKeys(seen)
|
||||
}
|
||||
delete(seen, first)
|
||||
return append([]string{first}, sortedKeys(seen)...)
|
||||
}
|
||||
|
||||
func assembleSuiteLayout(layout string, collected []string, infos []GlobalSkillInfo) error {
|
||||
if layout == LayoutSeparate {
|
||||
return nil
|
||||
}
|
||||
|
||||
infoByName := map[string]GlobalSkillInfo{}
|
||||
for _, info := range infos {
|
||||
infoByName[info.Name] = info
|
||||
}
|
||||
suiteInfo, ok := infoByName[suiteSkillName]
|
||||
if !ok {
|
||||
return fmt.Errorf("%s was not installed from isolated skills source", suiteSkillName)
|
||||
}
|
||||
|
||||
subskillsDir := filepath.Join(suiteInfo.Path, "references", "subskills")
|
||||
if err := os.RemoveAll(subskillsDir); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(subskillsDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, skill := range collected {
|
||||
info, ok := infoByName[skill]
|
||||
if !ok {
|
||||
return fmt.Errorf("collected skill %q was not installed", skill)
|
||||
}
|
||||
dst := filepath.Join(subskillsDir, skill)
|
||||
if layout == LayoutHybrid && skill == sharedSkillName {
|
||||
if err := copyDir(info.Path, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := moveDir(info.Path, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return rewriteSuiteRoutes(suiteInfo.Path, collected)
|
||||
}
|
||||
|
||||
func moveDir(src, dst string) error {
|
||||
if samePath(src, dst) {
|
||||
return nil
|
||||
}
|
||||
if err := os.RemoveAll(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(src, dst); err == nil {
|
||||
return nil
|
||||
}
|
||||
if err := copyDir(src, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.RemoveAll(src)
|
||||
}
|
||||
|
||||
func copyDir(src, dst string) error {
|
||||
if samePath(src, dst) {
|
||||
return nil
|
||||
}
|
||||
if err := os.RemoveAll(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
return filepath.WalkDir(src, func(path string, d os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
rel, err := filepath.Rel(src, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := filepath.Join(dst, rel)
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return os.MkdirAll(target, info.Mode())
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
link, err := os.Readlink(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Symlink(link, target)
|
||||
}
|
||||
return copyFile(path, target, info.Mode())
|
||||
})
|
||||
}
|
||||
|
||||
func copyFile(src, dst string, mode os.FileMode) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
return err
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
func samePath(a, b string) bool {
|
||||
aa, errA := filepath.Abs(a)
|
||||
bb, errB := filepath.Abs(b)
|
||||
return errA == nil && errB == nil && aa == bb
|
||||
}
|
||||
|
||||
func rewriteSuiteRoutes(suitePath string, collected []string) error {
|
||||
skillPath := filepath.Join(suitePath, "SKILL.md")
|
||||
data, err := os.ReadFile(skillPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
text := string(data)
|
||||
if !strings.Contains(text, suiteRoutesPlaceholder) {
|
||||
return fmt.Errorf("%s does not contain route placeholder", skillPath)
|
||||
}
|
||||
|
||||
routes := []string{}
|
||||
for _, skill := range collected {
|
||||
description, err := readSkillDescription(filepath.Join(suitePath, "references", "subskills", skill, "SKILL.md"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
routes = append(routes, fmt.Sprintf("- %s: %s", skill, oneLine(description)))
|
||||
}
|
||||
text = strings.Replace(text, suiteRoutesPlaceholder, strings.Join(routes, "\n"), 1)
|
||||
return os.WriteFile(skillPath, []byte(text), 0o644)
|
||||
}
|
||||
|
||||
func readSkillDescription(skillPath string) (string, error) {
|
||||
data, err := os.ReadFile(skillPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
text := string(data)
|
||||
if !strings.HasPrefix(text, "---") {
|
||||
return "", fmt.Errorf("missing frontmatter in %s", skillPath)
|
||||
}
|
||||
parts := strings.SplitN(text, "---", 3)
|
||||
if len(parts) < 3 {
|
||||
return "", fmt.Errorf("missing frontmatter in %s", skillPath)
|
||||
}
|
||||
lines := strings.Split(parts[1], "\n")
|
||||
for i, line := range lines {
|
||||
if !strings.HasPrefix(line, "description:") {
|
||||
continue
|
||||
}
|
||||
value := strings.TrimSpace(strings.TrimPrefix(line, "description:"))
|
||||
if value == "|" || value == ">" {
|
||||
block := []string{}
|
||||
for _, blockLine := range lines[i+1:] {
|
||||
if strings.TrimSpace(blockLine) == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(blockLine, " ") {
|
||||
break
|
||||
}
|
||||
block = append(block, strings.TrimSpace(blockLine))
|
||||
}
|
||||
return strings.Join(block, " "), nil
|
||||
}
|
||||
return strings.Trim(value, `"'`), nil
|
||||
}
|
||||
return "", errors.New("missing frontmatter description")
|
||||
}
|
||||
|
||||
func oneLine(value string) string {
|
||||
return strings.Join(strings.Fields(value), " ")
|
||||
}
|
||||
@@ -23,10 +23,12 @@ var ErrUnreadableState = errors.New("skills state is unreadable")
|
||||
|
||||
type SkillsState struct {
|
||||
Version string `json:"version"`
|
||||
Layout string `json:"layout,omitempty"`
|
||||
OfficialSkills []string `json:"official_skills"`
|
||||
UpdatedSkills []string `json:"updated_skills"`
|
||||
AddedOfficialSkills []string `json:"added_official_skills"`
|
||||
SkippedDeletedSkills []string `json:"skipped_deleted_skills"`
|
||||
CollectedSkills []string `json:"collected_skills,omitempty"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -76,6 +78,14 @@ func ReadSyncedVersion() (string, bool) {
|
||||
return state.Version, true
|
||||
}
|
||||
|
||||
func ReadSyncedVersionAndLayout() (version string, layout string, ok bool) {
|
||||
state, readable, err := ReadState()
|
||||
if err != nil || !readable || state.Version == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return state.Version, state.Layout, true
|
||||
}
|
||||
|
||||
func (s *SkillsState) ensureNonNilSlices() {
|
||||
if s.OfficialSkills == nil {
|
||||
s.OfficialSkills = []string{}
|
||||
@@ -89,4 +99,7 @@ func (s *SkillsState) ensureNonNilSlices() {
|
||||
if s.SkippedDeletedSkills == nil {
|
||||
s.SkippedDeletedSkills = []string{}
|
||||
}
|
||||
if s.CollectedSkills == nil {
|
||||
s.CollectedSkills = []string{}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ var (
|
||||
|
||||
type SyncInput struct {
|
||||
Version string
|
||||
Layout string
|
||||
OfficialSkills []string
|
||||
LocalSkills []string
|
||||
PreviousState *SkillsState
|
||||
@@ -195,7 +196,19 @@ func parseOfficialSkillsList(lines []string) []string {
|
||||
}
|
||||
|
||||
func PlanSync(input SyncInput) SyncPlan {
|
||||
official := uniqueSorted(input.OfficialSkills)
|
||||
official := normalOfficialSkills(input.OfficialSkills)
|
||||
layout, _ := NormalizeLayout(input.Layout)
|
||||
skippedDeleted := deletedOfficialSkills(official, input.LocalSkills, input.PreviousState, input.StateReadable, input.Force, layout)
|
||||
if layout != LayoutSeparate {
|
||||
toUpdate := suiteEffectiveSkills(official, toSet(skippedDeleted))
|
||||
return SyncPlan{
|
||||
Version: input.Version,
|
||||
OfficialSkills: official,
|
||||
ToUpdate: toUpdate,
|
||||
Added: newlyOfficialSkills(official, input.PreviousState, input.StateReadable),
|
||||
SkippedDeleted: skippedDeleted,
|
||||
}
|
||||
}
|
||||
if input.Force {
|
||||
return SyncPlan{
|
||||
Version: input.Version,
|
||||
@@ -229,22 +242,34 @@ func PlanSync(input SyncInput) SyncPlan {
|
||||
toUpdate := sortedKeys(updateSet)
|
||||
updateSet = toSet(toUpdate)
|
||||
|
||||
skipped := []string{}
|
||||
for _, skill := range official {
|
||||
if !updateSet[skill] {
|
||||
skipped = append(skipped, skill)
|
||||
}
|
||||
}
|
||||
|
||||
return SyncPlan{
|
||||
Version: input.Version,
|
||||
OfficialSkills: official,
|
||||
ToUpdate: toUpdate,
|
||||
Added: uniqueSorted(newAddedOfficial),
|
||||
SkippedDeleted: skipped,
|
||||
SkippedDeleted: skippedDeleted,
|
||||
}
|
||||
}
|
||||
|
||||
func deletedOfficialSkills(official, local []string, previous *SkillsState, stateReadable, force bool, layout string) []string {
|
||||
if force || !stateReadable || previous == nil {
|
||||
return []string{}
|
||||
}
|
||||
officialSet := toSet(official)
|
||||
localSet := toSet(local)
|
||||
deleted := map[string]bool{}
|
||||
for _, skill := range previous.OfficialSkills {
|
||||
if !officialSet[skill] || localSet[skill] {
|
||||
continue
|
||||
}
|
||||
if layout != LayoutSeparate && skill == sharedSkillName {
|
||||
continue
|
||||
}
|
||||
deleted[skill] = true
|
||||
}
|
||||
return sortedKeys(deleted)
|
||||
}
|
||||
|
||||
type SkillsRunner interface {
|
||||
ListOfficialSkillsIndex() *selfupdate.NpmResult
|
||||
ListOfficialSkills() *selfupdate.NpmResult
|
||||
@@ -252,13 +277,16 @@ type SkillsRunner interface {
|
||||
ListGlobalSkills() *selfupdate.NpmResult
|
||||
InstallSkill(nameList []string) *selfupdate.NpmResult
|
||||
InstallAllSkills() *selfupdate.NpmResult
|
||||
InstallSuiteSkill() *selfupdate.NpmResult
|
||||
}
|
||||
|
||||
type SyncOptions struct {
|
||||
Version string
|
||||
Force bool
|
||||
Runner SkillsRunner
|
||||
Now func() time.Time
|
||||
Version string
|
||||
Layout string
|
||||
CollectedSkills []string
|
||||
Force bool
|
||||
Runner SkillsRunner
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type SyncResult struct {
|
||||
@@ -271,6 +299,9 @@ type SyncResult struct {
|
||||
Err error
|
||||
Detail string
|
||||
Force bool
|
||||
Layout string
|
||||
Collected []string
|
||||
CanFallback bool
|
||||
}
|
||||
|
||||
func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
@@ -280,16 +311,26 @@ func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
if opts.Runner == nil {
|
||||
return &SyncResult{Action: "failed", Err: fmt.Errorf("skills runner is nil")}
|
||||
}
|
||||
layout, ok := NormalizeLayout(opts.Layout)
|
||||
if !ok {
|
||||
return &SyncResult{Action: "failed", Err: fmt.Errorf("unsupported skills layout %q", opts.Layout)}
|
||||
}
|
||||
|
||||
// --- Step 1: List official skills ---
|
||||
official, reason, ok := listOfficialSkills(opts.Runner)
|
||||
if !ok {
|
||||
if layout != LayoutSeparate {
|
||||
return failedSync(layout, opts.Force, fmt.Errorf("failed to discover official skills for %s layout: %s", layout, reason), reason)
|
||||
}
|
||||
return fallbackFullInstall(opts, reason, nil)
|
||||
}
|
||||
|
||||
// --- Step 2: List local (installed) skills ---
|
||||
local, ok := listLocalSkills(opts.Runner)
|
||||
if !ok {
|
||||
if layout != LayoutSeparate {
|
||||
return failedSync(layout, opts.Force, fmt.Errorf("failed to list local skills for %s layout", layout), "local skills list failed or parsed as empty")
|
||||
}
|
||||
return fallbackFullInstall(opts, "local skills list failed or parsed as empty", official)
|
||||
}
|
||||
|
||||
@@ -302,12 +343,17 @@ func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
|
||||
plan := PlanSync(SyncInput{
|
||||
Version: opts.Version,
|
||||
Layout: layout,
|
||||
OfficialSkills: official,
|
||||
LocalSkills: local,
|
||||
PreviousState: previous,
|
||||
StateReadable: readable,
|
||||
Force: opts.Force,
|
||||
})
|
||||
collected, err := resolveCollectedSkills(layout, opts.CollectedSkills, plan.OfficialSkills, previous, readable, plan.SkippedDeleted)
|
||||
if err != nil {
|
||||
return &SyncResult{Action: "failed", Err: err, Official: plan.OfficialSkills, Force: opts.Force, Layout: layout}
|
||||
}
|
||||
|
||||
result := &SyncResult{
|
||||
Action: "synced",
|
||||
@@ -316,25 +362,58 @@ func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
Added: plan.Added,
|
||||
SkippedDeleted: plan.SkippedDeleted,
|
||||
Force: opts.Force,
|
||||
Layout: layout,
|
||||
Collected: collected,
|
||||
}
|
||||
|
||||
if len(plan.ToUpdate) == 0 {
|
||||
if layout != LayoutSeparate {
|
||||
return failedSync(layout, opts.Force, fmt.Errorf("no target skills to assemble %s layout", layout), "toUpdate skills empty")
|
||||
}
|
||||
return fallbackFullInstall(opts, "toUpdate skills empty fallback", official)
|
||||
}
|
||||
|
||||
if len(plan.ToUpdate) > 0 {
|
||||
installResult := opts.Runner.InstallSkill(plan.ToUpdate)
|
||||
if installResult == nil || installResult.Err != nil {
|
||||
if layout != LayoutSeparate {
|
||||
return failedSync(layout, opts.Force, fmt.Errorf("failed to install skills for %s layout: %s", layout, resultDetail(installResult)), resultDetail(installResult))
|
||||
}
|
||||
return fallbackFullInstall(opts, resultDetail(installResult), official)
|
||||
}
|
||||
}
|
||||
if layout != LayoutSeparate {
|
||||
installSuiteResult := opts.Runner.InstallSuiteSkill()
|
||||
if installSuiteResult == nil || installSuiteResult.Err != nil {
|
||||
result.Action = "failed"
|
||||
result.Err = fmt.Errorf("failed to install %s from isolated skills source: %s", suiteSkillName, resultDetail(installSuiteResult))
|
||||
result.Detail = resultDetail(installSuiteResult)
|
||||
result.CanFallback = true
|
||||
return result
|
||||
}
|
||||
infosResult := opts.Runner.ListGlobalSkillsJSON()
|
||||
if infosResult == nil || infosResult.Err != nil {
|
||||
result.Action = "failed"
|
||||
result.Err = fmt.Errorf("failed to list installed skills for %s assembly: %s", suiteSkillName, resultDetail(infosResult))
|
||||
result.Detail = resultDetail(infosResult)
|
||||
return result
|
||||
}
|
||||
infos := ParseGlobalSkillInfosJSON(infosResult.Stdout.String())
|
||||
if err := assembleSuiteLayout(layout, collected, infos); err != nil {
|
||||
result.Action = "failed"
|
||||
result.Err = fmt.Errorf("failed to assemble %s layout: %w", layout, err)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
state := SkillsState{
|
||||
Version: opts.Version,
|
||||
Layout: layout,
|
||||
OfficialSkills: plan.OfficialSkills,
|
||||
UpdatedSkills: plan.ToUpdate,
|
||||
AddedOfficialSkills: plan.Added,
|
||||
SkippedDeletedSkills: plan.SkippedDeleted,
|
||||
CollectedSkills: stateCollectedSkills(layout, collected),
|
||||
UpdatedAt: opts.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
if err := WriteState(state); err != nil {
|
||||
@@ -346,6 +425,16 @@ func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
return result
|
||||
}
|
||||
|
||||
func failedSync(layout string, force bool, err error, detail string) *SyncResult {
|
||||
return &SyncResult{
|
||||
Action: "failed",
|
||||
Err: err,
|
||||
Detail: detail,
|
||||
Force: force,
|
||||
Layout: layout,
|
||||
}
|
||||
}
|
||||
|
||||
func listOfficialSkills(runner SkillsRunner) ([]string, string, bool) {
|
||||
reasons := []string{}
|
||||
|
||||
@@ -383,8 +472,9 @@ func listOfficialSkills(runner SkillsRunner) ([]string, string, bool) {
|
||||
func listLocalSkills(runner SkillsRunner) ([]string, bool) {
|
||||
jsonResult := runner.ListGlobalSkillsJSON()
|
||||
if jsonResult != nil && jsonResult.Err == nil {
|
||||
if local := ParseGlobalSkillsJSON(jsonResult.Stdout.String()); len(local) > 0 {
|
||||
return local, true
|
||||
infos, valid := parseGlobalSkillInfosJSON(jsonResult.Stdout.String())
|
||||
if valid {
|
||||
return installedSkillNamesFromInfos(infos), true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,6 +501,7 @@ func fallbackFullInstall(opts SyncOptions, reason string, official []string) *Sy
|
||||
Err: fmt.Errorf("full skills install failed: empty result (reason: %s)", reason),
|
||||
Detail: reason,
|
||||
Force: opts.Force,
|
||||
Layout: LayoutSeparate,
|
||||
}
|
||||
}
|
||||
if installResult.Err != nil {
|
||||
@@ -419,11 +510,13 @@ func fallbackFullInstall(opts SyncOptions, reason string, official []string) *Sy
|
||||
Err: fmt.Errorf("full skills install failed: %w (reason: %s)", installResult.Err, reason),
|
||||
Detail: reason + "\n" + resultDetail(installResult),
|
||||
Force: opts.Force,
|
||||
Layout: LayoutSeparate,
|
||||
}
|
||||
}
|
||||
|
||||
state := SkillsState{
|
||||
Version: opts.Version,
|
||||
Layout: LayoutSeparate,
|
||||
OfficialSkills: official,
|
||||
UpdatedSkills: official,
|
||||
AddedOfficialSkills: official,
|
||||
@@ -439,6 +532,7 @@ func fallbackFullInstall(opts SyncOptions, reason string, official []string) *Sy
|
||||
SkippedDeleted: []string{},
|
||||
Detail: reason + "\nstate write failed: " + writeErr.Error(),
|
||||
Force: opts.Force,
|
||||
Layout: LayoutSeparate,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,9 +544,23 @@ func fallbackFullInstall(opts SyncOptions, reason string, official []string) *Sy
|
||||
SkippedDeleted: []string{},
|
||||
Detail: reason,
|
||||
Force: opts.Force,
|
||||
Layout: LayoutSeparate,
|
||||
}
|
||||
}
|
||||
|
||||
func stateCollectedSkills(layout string, requested []string) []string {
|
||||
if layout != LayoutHybrid {
|
||||
return []string{}
|
||||
}
|
||||
out := []string{}
|
||||
for _, skill := range uniqueSorted(requested) {
|
||||
if skill != sharedSkillName {
|
||||
out = append(out, skill)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func resultDetail(result *selfupdate.NpmResult) string {
|
||||
if result == nil {
|
||||
return ""
|
||||
|
||||
@@ -205,6 +205,19 @@ func TestPlanForceRestoresAllOfficial(t *testing.T) {
|
||||
assertStrings(t, got.SkippedDeleted, []string{})
|
||||
}
|
||||
|
||||
func TestPlanSuiteInstallsAllNormalOfficialSkills(t *testing.T) {
|
||||
got := PlanSync(SyncInput{
|
||||
Version: "1.0.33",
|
||||
Layout: LayoutSuite,
|
||||
OfficialSkills: []string{"lark-calendar", "lark-suite", "lark-mail"},
|
||||
LocalSkills: []string{"lark-suite"},
|
||||
})
|
||||
|
||||
assertStrings(t, got.OfficialSkills, []string{"lark-calendar", "lark-mail"})
|
||||
assertStrings(t, got.ToUpdate, []string{"lark-calendar", "lark-mail"})
|
||||
assertStrings(t, got.SkippedDeleted, []string{})
|
||||
}
|
||||
|
||||
type fakeSkillsRunner struct {
|
||||
officialIndexOut string
|
||||
officialOut string
|
||||
@@ -216,8 +229,10 @@ type fakeSkillsRunner struct {
|
||||
globalErr error
|
||||
installErr error
|
||||
installAllErr error
|
||||
installSuiteErr error
|
||||
installed [][]string
|
||||
installedAll int
|
||||
installedSuite int
|
||||
listedIndex int
|
||||
listedOfficial int
|
||||
listedGlobalJSON int
|
||||
@@ -273,6 +288,43 @@ func globalSkillsJSONOutput(names ...string) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func globalSkillsJSONFromDir(dir string, names ...string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("[")
|
||||
for i, name := range names {
|
||||
if i > 0 {
|
||||
b.WriteString(",")
|
||||
}
|
||||
fmt.Fprintf(&b, `{"name":%q,"path":%q,"scope":"global","agents":["Codex"]}`, name, filepath.Join(dir, name))
|
||||
}
|
||||
b.WriteString("]")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func createTestSkill(t *testing.T, dir, name, description string) {
|
||||
t.Helper()
|
||||
skillDir := filepath.Join(dir, name)
|
||||
if err := os.MkdirAll(skillDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := fmt.Sprintf("---\nname: %s\ndescription: %s\n---\n\n# %s\n", name, description, name)
|
||||
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func createTestSuiteSkill(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
skillDir := filepath.Join(dir, suiteSkillName)
|
||||
if err := os.MkdirAll(skillDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := "---\nname: lark-suite\ndescription: suite\n---\n\n## 能力路由\n\n" + suiteRoutesPlaceholder + "\n"
|
||||
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeSkillsRunner) ListOfficialSkillsIndex() *selfupdate.NpmResult {
|
||||
f.listedIndex++
|
||||
r := &selfupdate.NpmResult{}
|
||||
@@ -319,6 +371,13 @@ func (f *fakeSkillsRunner) InstallAllSkills() *selfupdate.NpmResult {
|
||||
return r
|
||||
}
|
||||
|
||||
func (f *fakeSkillsRunner) InstallSuiteSkill() *selfupdate.NpmResult {
|
||||
f.installedSuite++
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Err = f.installSuiteErr
|
||||
return r
|
||||
}
|
||||
|
||||
func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
@@ -361,11 +420,243 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
|
||||
assertStrings(t, state.UpdatedSkills, []string{"lark-calendar", "lark-new"})
|
||||
assertStrings(t, state.AddedOfficialSkills, []string{"lark-new"})
|
||||
assertStrings(t, state.SkippedDeletedSkills, []string{"lark-mail"})
|
||||
if state.Layout != LayoutSeparate {
|
||||
t.Fatalf("state.Layout = %q, want %q", state.Layout, LayoutSeparate)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "skills.stamp")); !os.IsNotExist(err) {
|
||||
t.Fatalf("skills.stamp exists or stat failed with unexpected err: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_SuiteAssemblesSubskillsAndRoutes(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
createTestSkill(t, dir, "lark-calendar", "Calendar operations")
|
||||
createTestSkill(t, dir, "lark-shared", "Shared auth and troubleshooting")
|
||||
createTestSuiteSkill(t, dir)
|
||||
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-shared"),
|
||||
globalJSONOut: globalSkillsJSONFromDir(dir, "lark-calendar", "lark-shared", "lark-suite"),
|
||||
}
|
||||
result := SyncSkills(SyncOptions{
|
||||
Version: "1.0.33",
|
||||
Layout: LayoutSuite,
|
||||
Runner: runner,
|
||||
Now: func() time.Time { return time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC) },
|
||||
})
|
||||
|
||||
if result.Err != nil {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||
}
|
||||
if runner.installedSuite != 1 {
|
||||
t.Fatalf("installedSuite = %d, want 1", runner.installedSuite)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "lark-calendar")); !os.IsNotExist(err) {
|
||||
t.Fatalf("top-level lark-calendar still exists or stat failed: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "lark-suite", "references", "subskills", "lark-calendar", "SKILL.md")); err != nil {
|
||||
t.Fatalf("nested lark-calendar missing: %v", err)
|
||||
}
|
||||
suite, err := os.ReadFile(filepath.Join(dir, "lark-suite", "SKILL.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(suite), "- lark-calendar: Calendar operations") {
|
||||
t.Fatalf("suite routes were not generated from descriptions:\n%s", string(suite))
|
||||
}
|
||||
state, readable, err := ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Layout != LayoutSuite {
|
||||
t.Fatalf("state.Layout = %q, want %q", state.Layout, LayoutSuite)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_HybridCopiesSharedAndMovesCollected(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
createTestSkill(t, dir, "lark-calendar", "Calendar operations")
|
||||
createTestSkill(t, dir, "lark-mail", "Mail operations")
|
||||
createTestSkill(t, dir, "lark-shared", "Shared auth and troubleshooting")
|
||||
createTestSuiteSkill(t, dir)
|
||||
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail", "lark-shared"),
|
||||
globalJSONOut: globalSkillsJSONFromDir(dir, "lark-calendar", "lark-mail", "lark-shared", "lark-suite"),
|
||||
}
|
||||
result := SyncSkills(SyncOptions{
|
||||
Version: "1.0.33",
|
||||
Layout: LayoutHybrid,
|
||||
CollectedSkills: []string{"lark-calendar"},
|
||||
Runner: runner,
|
||||
Now: time.Now,
|
||||
})
|
||||
|
||||
if result.Err != nil {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "lark-calendar")); !os.IsNotExist(err) {
|
||||
t.Fatalf("top-level lark-calendar still exists or stat failed: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "lark-mail", "SKILL.md")); err != nil {
|
||||
t.Fatalf("top-level lark-mail should remain: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "lark-shared", "SKILL.md")); err != nil {
|
||||
t.Fatalf("top-level lark-shared should remain in hybrid: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "lark-suite", "references", "subskills", "lark-shared", "SKILL.md")); err != nil {
|
||||
t.Fatalf("nested lark-shared copy missing: %v", err)
|
||||
}
|
||||
state, readable, err := ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
assertStrings(t, state.CollectedSkills, []string{"lark-calendar"})
|
||||
}
|
||||
|
||||
func TestSyncSkills_HybridCollectsNewOfficialSkillsIntoSuite(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := WriteState(SkillsState{
|
||||
Version: "1.0.32",
|
||||
Layout: LayoutHybrid,
|
||||
OfficialSkills: []string{"lark-calendar", "lark-shared"},
|
||||
CollectedSkills: []string{"lark-calendar"},
|
||||
UpdatedAt: "2026-05-18T00:00:00Z",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
createTestSkill(t, dir, "lark-calendar", "Calendar operations")
|
||||
createTestSkill(t, dir, "lark-new", "New operations")
|
||||
createTestSkill(t, dir, "lark-shared", "Shared auth and troubleshooting")
|
||||
createTestSuiteSkill(t, dir)
|
||||
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-new", "lark-shared"),
|
||||
globalJSONOut: globalSkillsJSONFromDir(dir, "lark-calendar", "lark-new", "lark-shared", "lark-suite"),
|
||||
}
|
||||
result := SyncSkills(SyncOptions{
|
||||
Version: "1.0.33",
|
||||
Layout: LayoutHybrid,
|
||||
CollectedSkills: []string{"lark-calendar"},
|
||||
Runner: runner,
|
||||
Now: time.Now,
|
||||
})
|
||||
|
||||
if result.Err != nil {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "lark-suite", "references", "subskills", "lark-new", "SKILL.md")); err != nil {
|
||||
t.Fatalf("new official skill should be collected into suite: %v", err)
|
||||
}
|
||||
state, readable, err := ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
assertStrings(t, state.CollectedSkills, []string{"lark-calendar", "lark-new"})
|
||||
}
|
||||
|
||||
func TestSyncSkills_SuiteExcludesUserDeletedSubskillAndRebuildsRoutes(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := WriteState(SkillsState{
|
||||
Version: "1.0.32",
|
||||
Layout: LayoutSuite,
|
||||
OfficialSkills: []string{"lark-calendar", "lark-mail", "lark-shared"},
|
||||
UpdatedAt: "2026-05-18T00:00:00Z",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
createTestSkill(t, dir, "lark-calendar", "Calendar operations")
|
||||
createTestSkill(t, dir, "lark-new", "New operations")
|
||||
createTestSkill(t, dir, "lark-shared", "Shared auth and troubleshooting")
|
||||
createTestSuiteSkill(t, dir)
|
||||
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail", "lark-new", "lark-shared"),
|
||||
globalJSONOut: globalSkillsJSONFromDir(dir, "lark-calendar", "lark-new", "lark-shared", "lark-suite"),
|
||||
}
|
||||
result := SyncSkills(SyncOptions{
|
||||
Version: "1.0.33",
|
||||
Layout: LayoutSuite,
|
||||
Runner: runner,
|
||||
Now: time.Now,
|
||||
})
|
||||
|
||||
if result.Err != nil {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "lark-suite", "references", "subskills", "lark-mail")); !os.IsNotExist(err) {
|
||||
t.Fatalf("deleted subskill should not be regenerated, stat err: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "lark-suite", "references", "subskills", "lark-new", "SKILL.md")); err != nil {
|
||||
t.Fatalf("new official skill should be regenerated into suite: %v", err)
|
||||
}
|
||||
state, readable, err := ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
assertStrings(t, state.SkippedDeletedSkills, []string{"lark-mail"})
|
||||
}
|
||||
|
||||
func TestSyncSkills_SuiteInstallFailureDoesNotFallbackToSeparate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-shared"),
|
||||
globalJSONOut: globalSkillsJSONFromDir(dir, "lark-calendar", "lark-shared"),
|
||||
installErr: fmt.Errorf("ordinary install failed"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{
|
||||
Version: "1.0.33",
|
||||
Layout: LayoutSuite,
|
||||
Runner: runner,
|
||||
Now: time.Now,
|
||||
})
|
||||
|
||||
if result.Action != "failed" || result.Err == nil {
|
||||
t.Fatalf("SyncSkills() = %+v, want failed result", result)
|
||||
}
|
||||
if runner.installedAll != 0 {
|
||||
t.Fatalf("installedAll = %d, want 0; suite must not silently fallback to separate", runner.installedAll)
|
||||
}
|
||||
if state, readable, err := ReadState(); err != nil || readable || state != nil {
|
||||
t.Fatalf("ReadState() = (%+v, %v, %v), want no successful state", state, readable, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_SuiteSpecialSourceFailureDoesNotWriteState(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
createTestSkill(t, dir, "lark-calendar", "Calendar operations")
|
||||
createTestSkill(t, dir, "lark-shared", "Shared auth and troubleshooting")
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-shared"),
|
||||
globalJSONOut: globalSkillsJSONFromDir(dir, "lark-calendar", "lark-shared"),
|
||||
installSuiteErr: fmt.Errorf("special source failed"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{
|
||||
Version: "1.0.33",
|
||||
Layout: LayoutSuite,
|
||||
Runner: runner,
|
||||
Now: time.Now,
|
||||
})
|
||||
|
||||
if result.Action != "failed" || result.Err == nil {
|
||||
t.Fatalf("SyncSkills() = %+v, want failed result", result)
|
||||
}
|
||||
if runner.installedAll != 0 {
|
||||
t.Fatalf("installedAll = %d, want 0; special source failure must not fallback to separate", runner.installedAll)
|
||||
}
|
||||
if state, readable, err := ReadState(); err != nil || readable || state != nil {
|
||||
t.Fatalf("ReadState() = (%+v, %v, %v), want no successful state", state, readable, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_OfficialIndexSuccessSkipsOfficialListCommand(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
@@ -574,7 +865,7 @@ func TestSyncSkills_LocalListsFailureFallsBackToFullInstall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_ParseEmptyLocalListsFallBackToFullInstall(t *testing.T) {
|
||||
func TestSyncSkills_EmptyLocalJSONInstallsAllOfficialIncrementally(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
@@ -585,14 +876,15 @@ func TestSyncSkills_ParseEmptyLocalListsFallBackToFullInstall(t *testing.T) {
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "fallback_synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
|
||||
if result.Action != "synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want synced", result.Action)
|
||||
}
|
||||
if len(runner.installed) != 0 {
|
||||
t.Fatalf("installed = %#v, want no incremental installs", runner.installed)
|
||||
if len(runner.installed) != 1 {
|
||||
t.Fatalf("installed = %#v, want one incremental install", runner.installed)
|
||||
}
|
||||
if runner.installedAll != 1 {
|
||||
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
|
||||
assertStrings(t, runner.installed[0], []string{"lark-calendar", "lark-mail"})
|
||||
if runner.installedAll != 0 {
|
||||
t.Fatalf("installedAll = %d, want 0", runner.installedAll)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
33
isolated-skills/lark-suite/SKILL.md
Normal file
33
isolated-skills/lark-suite/SKILL.md
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
name: lark-suite
|
||||
version: 0.1.0
|
||||
description: 飞书/Lark 聚合能力入口:当用户需求涉及本文件列出的任一飞书能力时使用;仅负责选择并加载已安装到本 suite 的 lark-* 子能力,不替代具体子能力的操作细节。
|
||||
metadata:
|
||||
requires:
|
||||
bins:
|
||||
- lark-cli
|
||||
---
|
||||
|
||||
# Lark Suite
|
||||
|
||||
你是飞书/Lark 能力的聚合路由层。你的职责是先判断用户要使用哪个 `lark-*` 子能力,再读取并遵循对应子能力的说明。
|
||||
|
||||
`lark-suite` 不直接承载具体 API 操作步骤。除非对应子能力已被读取,否则不要仅根据本文件拼命令、猜参数或执行复杂操作。
|
||||
|
||||
## 使用流程
|
||||
|
||||
1. 根据用户意图从下方路由表选择一个或多个子能力;即使用户尚未提供链接、ID 或具体工作表,也先选择能力,再由子能力询问缺失信息。
|
||||
2. 读取对应子能力说明,优先使用 `references/subskills/<skill-name>/SKILL.md`。
|
||||
3. 如果目标能力没有出现在本文件中,不代表当前环境不可用;检查是否存在相关的独立 `lark-*` skill。
|
||||
4. 如果已选中的子能力说明列出必读、前置或继续阅读的文件,只读取该子能力当前任务所需的前置文件;不要读取无关子能力。
|
||||
5. 按目标子能力的说明执行;认证、租户、身份、权限和通用排障优先遵循 `lark-shared`。
|
||||
|
||||
`lark-shared` 是共享基础能力,不作为 `--collected-skills` 的可选项。为了保证 suite 内子能力可用,hybrid 布局会同时保留顶层 `lark-shared`,并在 `lark-suite/references/subskills/lark-shared/SKILL.md` 中维护一份副本。
|
||||
|
||||
多步任务可以组合多个子能力,但每一步都应由具体子能力驱动。例如“查联系人并发消息”先用 `lark-contact` 解析身份,再用 `lark-im` 发消息。
|
||||
|
||||
## 能力路由
|
||||
|
||||
根据用户意图从以下条目选择对应子能力;如果一个任务涉及多个能力,按实际操作顺序逐步读取并使用对应子能力。
|
||||
|
||||
<!-- LARK_SUITE_ROUTES -->
|
||||
@@ -3,9 +3,9 @@
|
||||
# with a skills/ grep sweep in the same PR.
|
||||
set -euo pipefail
|
||||
PATTERN='"type"\s*:\s*"(auth_error|api_error|infra_error|missing_scope|command_denied|external_provider)"'
|
||||
if git grep -E "$PATTERN" skills/ >/dev/null 2>&1; then
|
||||
echo "[WIRE-VOCAB-DRIFT] skills/ contains legacy wire strings — see spec §12.3" >&2
|
||||
git grep -nE "$PATTERN" skills/ >&2
|
||||
if git grep -E "$PATTERN" -- skills/ isolated-skills/ >/dev/null 2>&1; then
|
||||
echo "[WIRE-VOCAB-DRIFT] skills/ or isolated-skills/ contains legacy wire strings — see spec §12.3" >&2
|
||||
git grep -nE "$PATTERN" -- skills/ isolated-skills/ >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "skill wire-vocab clean."
|
||||
|
||||
130
scripts/generate-lark-suite-from-descriptions.js
Normal file
130
scripts/generate-lark-suite-from-descriptions.js
Normal file
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env node
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
const skillsDir = path.join(repoRoot, "skills");
|
||||
const templatePath = path.join(repoRoot, "isolated-skills", "lark-suite", "SKILL.md");
|
||||
const outPath = path.join(repoRoot, "lark-suite.generated-from-descriptions.SKILL.md");
|
||||
const statsPath = path.join(repoRoot, "lark-suite.generated-from-descriptions.stats.json");
|
||||
|
||||
function read(file) {
|
||||
return fs.readFileSync(file, "utf8");
|
||||
}
|
||||
|
||||
function parseFrontmatterDescription(skillPath) {
|
||||
const text = read(skillPath);
|
||||
const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
if (!match) {
|
||||
throw new Error(`missing frontmatter: ${skillPath}`);
|
||||
}
|
||||
|
||||
const lines = match[1].split(/\r?\n/);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const desc = line.match(/^description:\s*(.*)$/);
|
||||
if (!desc) continue;
|
||||
const value = desc[1].trim();
|
||||
if (value === ">" || value === "|") {
|
||||
return parseIndentedBlock(lines, i + 1, value);
|
||||
}
|
||||
return unquoteYamlScalar(value);
|
||||
}
|
||||
throw new Error(`missing frontmatter description: ${skillPath}`);
|
||||
}
|
||||
|
||||
function parseIndentedBlock(lines, start, style) {
|
||||
const block = [];
|
||||
for (let i = start; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (!line.trim()) {
|
||||
block.push("");
|
||||
continue;
|
||||
}
|
||||
if (!line.startsWith(" ")) break;
|
||||
block.push(line.replace(/^ ?/, ""));
|
||||
}
|
||||
if (style === "|") {
|
||||
return block.join("\n").trim();
|
||||
}
|
||||
return block.map((line) => line.trim()).filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
function unquoteYamlScalar(value) {
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function approxTokens(text) {
|
||||
// Crude but stable enough for comparing generated variants.
|
||||
return Math.ceil(Array.from(text).length / 1.7);
|
||||
}
|
||||
|
||||
function collectDescriptions() {
|
||||
const descriptions = new Map();
|
||||
for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const name = entry.name;
|
||||
if (!name.startsWith("lark-") || name === "lark-suite") continue;
|
||||
const skillPath = path.join(skillsDir, name, "SKILL.md");
|
||||
if (!fs.existsSync(skillPath)) continue;
|
||||
descriptions.set(name, parseFrontmatterDescription(skillPath));
|
||||
}
|
||||
return descriptions;
|
||||
}
|
||||
|
||||
function generate(template, descriptions) {
|
||||
const used = Array.from(descriptions.keys()).sort();
|
||||
const routes = used.map((skillName) => {
|
||||
const description = descriptions.get(skillName);
|
||||
return `- ${skillName}: ${description}`;
|
||||
});
|
||||
if (!template.includes("<!-- LARK_SUITE_ROUTES -->")) {
|
||||
throw new Error("missing route placeholder in lark-suite template");
|
||||
}
|
||||
|
||||
return {
|
||||
text: template.replace("<!-- LARK_SUITE_ROUTES -->", routes.join("\n")),
|
||||
used,
|
||||
};
|
||||
}
|
||||
|
||||
function main() {
|
||||
const template = read(templatePath);
|
||||
const descriptions = collectDescriptions();
|
||||
const { text, used } = generate(template, descriptions);
|
||||
fs.writeFileSync(outPath, text.endsWith("\n") ? text : `${text}\n`);
|
||||
|
||||
const stats = {
|
||||
generated_file: outPath,
|
||||
template_file: templatePath,
|
||||
rule: "Fill isolated-skills/lark-suite/SKILL.md route placeholder from installed skill descriptions.",
|
||||
skill_count: used.length,
|
||||
generated_bytes: Buffer.byteLength(text),
|
||||
generated_chars: Array.from(text).length,
|
||||
generated_approx_tokens: approxTokens(text),
|
||||
template_bytes: Buffer.byteLength(template),
|
||||
template_chars: Array.from(template).length,
|
||||
template_approx_tokens: approxTokens(template),
|
||||
route_entries: used,
|
||||
entry_token_stats: used
|
||||
.map((skill) => {
|
||||
const description = descriptions.get(skill);
|
||||
return [skill, approxTokens(description), Array.from(description).length];
|
||||
})
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])),
|
||||
};
|
||||
fs.writeFileSync(statsPath, `${JSON.stringify(stats, null, 2)}\n`);
|
||||
console.log(`Generated ${path.relative(repoRoot, outPath)} from ${path.relative(repoRoot, templatePath)}`);
|
||||
console.log(`Updated ${path.relative(repoRoot, statsPath)}`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -197,20 +197,33 @@ function getExistingAppId(binPath) {
|
||||
|
||||
/** Parse --lang from process.argv, returns "zh", "en", or null. */
|
||||
function parseLangArg() {
|
||||
const val = parseStringArg("--lang");
|
||||
if (val === "zh" || val === "en") return val;
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseStringArg(name) {
|
||||
const args = process.argv.slice(2);
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === "--lang" && args[i + 1]) {
|
||||
const val = args[i + 1].toLowerCase();
|
||||
if (val === "zh" || val === "en") return val;
|
||||
if (args[i] === name && args[i + 1]) {
|
||||
return args[i + 1];
|
||||
}
|
||||
if (args[i].startsWith("--lang=")) {
|
||||
const val = args[i].split("=")[1].toLowerCase();
|
||||
if (val === "zh" || val === "en") return val;
|
||||
if (args[i].startsWith(`${name}=`)) {
|
||||
return args[i].slice(name.length + 1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function skillsUpdateArgs() {
|
||||
const args = ["update"];
|
||||
const layout = parseStringArg("--skills-layout");
|
||||
const collected = parseStringArg("--collected-skills");
|
||||
if (layout) args.push("--skills-layout", layout);
|
||||
if (collected) args.push("--collected-skills", collected);
|
||||
return args;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Steps
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -270,8 +283,12 @@ async function stepInstallSkills(msg) {
|
||||
const s = p.spinner();
|
||||
s.start(msg.step2Spinner);
|
||||
try {
|
||||
if (await skillsAlreadyInstalled()) {
|
||||
s.stop(msg.step2Skip);
|
||||
const larkCli = whichLarkCli();
|
||||
if (larkCli) {
|
||||
await runSilentAsync(larkCli, skillsUpdateArgs(), {
|
||||
timeout: 120000,
|
||||
});
|
||||
s.stop(msg.step2Done);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Skill Format Check
|
||||
|
||||
This directory contains a script to validate the format of `SKILL.md` files located in the `../../skills` directory.
|
||||
This directory contains a script to validate the format of `SKILL.md` files located in the `../../skills` and `../../isolated-skills` directories.
|
||||
|
||||
## Purpose
|
||||
|
||||
@@ -13,7 +13,7 @@ The `index.js` script ensures that all `SKILL.md` files conform to the standard
|
||||
|
||||
## Usage
|
||||
|
||||
This script is executed automatically via GitHub Actions (`.github/workflows/skill-format-check.yml`) on pull requests and pushes that modify the `skills/` directory.
|
||||
This script is executed automatically via GitHub Actions (`.github/workflows/skill-format-check.yml`) on pull requests and pushes that modify the `skills/` or `isolated-skills/` directory.
|
||||
|
||||
To run the check manually from the root of the repository, execute:
|
||||
|
||||
|
||||
@@ -6,24 +6,27 @@ const path = require('path');
|
||||
|
||||
// Allow passing a target directory as the first argument.
|
||||
// If provided, resolve against process.cwd() so it behaves as the user expects.
|
||||
// If not provided, default to '../../skills' relative to this script's directory.
|
||||
// If not provided, default to the official skill directories.
|
||||
const targetDirArg = process.argv[2];
|
||||
const SKILLS_DIR = targetDirArg
|
||||
? path.resolve(process.cwd(), targetDirArg)
|
||||
: path.resolve(__dirname, '../../skills');
|
||||
const TARGET_DIRS = targetDirArg
|
||||
? [path.resolve(process.cwd(), targetDirArg)]
|
||||
: [
|
||||
path.resolve(__dirname, '../../skills'),
|
||||
path.resolve(__dirname, '../../isolated-skills'),
|
||||
];
|
||||
|
||||
function checkSkillFormat() {
|
||||
console.log(`Checking skill format in ${SKILLS_DIR}...`);
|
||||
function checkSkillFormatInDir(skillsDir) {
|
||||
console.log(`Checking skill format in ${skillsDir}...`);
|
||||
|
||||
if (!fs.existsSync(SKILLS_DIR)) {
|
||||
console.error('Skills directory not found:', SKILLS_DIR);
|
||||
if (!fs.existsSync(skillsDir)) {
|
||||
console.error('Skills directory not found:', skillsDir);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let skills;
|
||||
try {
|
||||
skills = fs
|
||||
.readdirSync(SKILLS_DIR, { withFileTypes: true })
|
||||
.readdirSync(skillsDir, { withFileTypes: true })
|
||||
.filter(entry => entry.isDirectory())
|
||||
.map(entry => entry.name);
|
||||
} catch (err) {
|
||||
@@ -40,7 +43,7 @@ function checkSkillFormat() {
|
||||
return;
|
||||
}
|
||||
|
||||
const skillPath = path.join(SKILLS_DIR, skill);
|
||||
const skillPath = path.join(skillsDir, skill);
|
||||
const skillFile = path.join(skillPath, 'SKILL.md');
|
||||
|
||||
if (!fs.existsSync(skillFile)) {
|
||||
@@ -88,12 +91,18 @@ function checkSkillFormat() {
|
||||
}
|
||||
});
|
||||
|
||||
if (hasErrors) {
|
||||
return !hasErrors;
|
||||
}
|
||||
|
||||
function checkSkillFormat() {
|
||||
const ok = TARGET_DIRS.map(checkSkillFormatInDir).every(Boolean);
|
||||
|
||||
if (!ok) {
|
||||
console.error('\n❌ Skill format check failed. Please fix the errors above.');
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('\n✅ Skill format check passed!');
|
||||
}
|
||||
|
||||
console.log('\n✅ Skill format check passed!');
|
||||
}
|
||||
|
||||
checkSkillFormat();
|
||||
|
||||
@@ -412,7 +412,10 @@ func (ctx *RuntimeContext) StreamPages(method, url string, params map[string]int
|
||||
return nil, false, err
|
||||
}
|
||||
req := ctx.buildRequest(method, url, params, data)
|
||||
return ac.StreamPages(ctx.ctx, req, onItems, opts)
|
||||
return ac.StreamPages(ctx.ctx, req, func(items []interface{}) error {
|
||||
onItems(items)
|
||||
return nil
|
||||
}, opts)
|
||||
}
|
||||
|
||||
func (ctx *RuntimeContext) buildRequest(method, url string, params map[string]interface{}, data interface{}) client.RawApiRequest {
|
||||
|
||||
Reference in New Issue
Block a user